From bd67b2feb9b03bc7eac7e7190b53d8ef2b09441a Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 20 Jul 2026 11:05:20 +0400 Subject: [PATCH] [ai] Test through locked macOS sessions. --- .agents/shared/test-loop.md | 14 +- .agents/skills/continue/SKILL.md | 69 +++++++-- .agents/skills/perform-task/SKILL.md | 5 + .../references/computer-use-testing.md | 33 +++- .../perform-task/references/pipeline.md | 14 +- .agents/skills/process-inbox/SKILL.md | 16 +- .../skills/process-inbox/scripts/workspace.py | 116 ++++++++++++++ .../process-inbox/scripts/workspace_test.py | 145 ++++++++++++++++++ .claude/commands/continue.md | 3 +- .claude/commands/reflect.md | 3 +- 10 files changed, 393 insertions(+), 25 deletions(-) diff --git a/.agents/shared/test-loop.md b/.agents/shared/test-loop.md index b61fefda40..fa8d427610 100644 --- a/.agents/shared/test-loop.md +++ b/.agents/shared/test-loop.md @@ -250,7 +250,12 @@ How TEST verifies it (numbers over eyes): The overlay is ad-hoc, authored fresh against the CURRENT implementation, injected at the highest level that still exercises the change (often a direct data-layer call like -`item->applyEdition(...)` rather than a faked MTP response). It must: +`item->applyEdition(...)` rather than a faked MTP response). It is also a complete runtime driver +when external desktop control is unavailable. Drive the whole task-specific flow inside the Debug +binary by invoking application actions or posting Qt input events on the event loop, waiting for +observable state, logging assertions, capturing the rendered target in-process, and quitting. A +locked macOS session does not reduce required coverage and is never a testing blocker. The overlay +must: - Live entirely inside `#ifdef _DEBUG` blocks. - Pick a **test strategy** and record it in the spec: @@ -267,6 +272,10 @@ highest level that still exercises the change (often a direct data-layer call li it) so the target is unambiguously in frame at usable resolution. A full-window grab that leaves the target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass. +- When the desktop is locked or an OS screenshot is unavailable, capture from inside the process + with `QWidget::grab()` or a renderer-owned image after layout and paint have completed. A widget or + test-window grab plus logged geometry is primary visual evidence; never wait for unlock merely to + obtain a desktop screenshot. - **Lay down the oracle's references.** Save every applicable independent reference beside the crop. Exact asset work saves OLD and intended-NEW art as `_{old,new}.png`. Without target artwork, save the baseline/reference-component crop when available and log the contract anchors, @@ -332,6 +341,9 @@ actually land; likewise for screenshots. ## Build & run discipline +- On macOS, a locked graphical session disables external UI driving only. Launch `EXE` normally, + run the in-binary overlay flow, collect its logs and widget/window grabs, assess them, and clean up. + Do not try to unlock the session and do not return BLOCKED because the lock screen is present. - Build with `BUILD`. A single changed TU compiles fast; only the overlay-touched files + link rebuild between rounds. Proactive path-scoped cleanup may run before the build. If the build reports `LNK1104`, `C1041`, access denied, or file in use, follow `AGENTS.md`: stop immediately, do not diff --git a/.agents/skills/continue/SKILL.md b/.agents/skills/continue/SKILL.md index 0090b90a09..58fe2a47cf 100644 --- a/.agents/skills/continue/SKILL.md +++ b/.agents/skills/continue/SKILL.md @@ -26,12 +26,17 @@ python3 .agents/skills/process-inbox/scripts/workspace.py queue Use `python` or `py -3` when appropriate. Save `checkout_tag`, `ai_main`, `slot_worktree`, and `source_root` from its JSON. Read `ai_main/AGENTS.md`. -Stop before mutating anything when `violations` is nonempty, AI master is -dirty, or AI slot changes are not wholly inside its one active task. Never -clean, stash, reset, or absorb unrelated changes. Dirty task-scoped AI files -are the active checkout's local resumable phase state and are expected. -Unpublished clean AI slot commits are incomplete publication; run the helper's -`publish` command before selecting work. +Stop before mutating anything when `violations` is nonempty or AI master is +dirty. Dirty task-scoped AI files are the active checkout's local resumable +phase state and are expected; resume from them, never discard them. Other +uncommitted slot changes under `tasks/`, `projects/`, or `receipts/` are +disposable leftovers of an interrupted worker; the ignored inbox snapshot and +published task results retain every durable input needed to redo them. +Restore those exact tracked paths to the slot branch head, delete their +untracked files, and continue. Stop instead of cleaning when any other slot +path changed, and never clean the main worktree or stash anywhere. +Unpublished clean AI slot commits are incomplete publication; run the +helper's `publish` command before selecting work. The canonical lifecycle is deliberately small: @@ -56,8 +61,11 @@ artifacts; it is never automatic scheduler behavior. ## Interpret scope hints Treat text after `$continue` or `/continue` as optional natural-language -priority guidance. It may prefer tasks from one receipt, project, or explicit -list, but it does not reserve a batch. Start only one shared task at a time, +guidance for new shared work. Its own wording decides its strength. A +preference such as "payments tasks first" only reorders selection; the run +still drains every eligible task. A restriction such as "only the payments +tasks" limits new shared work to matching tasks and ends the run when none +remain. Neither form reserves a batch. Start only one shared task at a time, finish or exceptionally block it, refresh canonical state, then choose again. Always resume this checkout's `in-progress` task and previously blocked work @@ -69,6 +77,14 @@ asks to stop or reassign it. Create an empty invocation-local `attempted_blocked` set, then repeat. Refresh queue JSON after every delegated operation and state transition. +Before publishing any new canonical `Start` commit, require a startable +environment: a clean Telegram source checkout with clean submodules and no +unrelated untracked files, plus an existing +`out/Debug/test_TelegramForcePortable` golden account. A failed check is a +global hard stop before claiming; never reserve shared work this checkout +cannot immediately run. Resuming and retrying already-owned work keeps the +performer's own preflight rules instead. + ### 1. Resume active work If this checkout has an `in-progress` task, select it and spawn one performer. @@ -126,10 +142,12 @@ ready work. ### 5. Start shared work -Otherwise select the first ready unclaimed `todo` task matching the priority -hint, or the first ready task in normal queue order. Start it with the same -helper command. `start` atomically assigns and activates the task, then -publishes its canonical `Start` commit before source work begins. +Otherwise select the first ready unclaimed `todo` task the hint prefers. Under +a restrictive hint consider only matching tasks. With no hint, or when a mere +preference has no matching ready task left, select the first ready task in +normal queue order. Start it with the same helper command. `start` atomically +assigns and activates the task, then publishes its canonical `Start` commit +before source work begins. A concurrent start may mean another checkout won the task. Never overwrite shared state; refresh and choose again. @@ -141,12 +159,26 @@ Stop when the inbox is empty and none of these exist: - this checkout's active task; - a ready blocked task not attempted in this invocation; - a ready legacy reserved task; -- a ready unclaimed task matching the invocation's priority scope. +- a ready unclaimed task this invocation may still start: any under no hint or + a preference, only matching ones under a restrictive hint. Work owned by another checkout does not keep this run alive. A blocked task already attempted in this invocation remains visible in the final summary but does not cause a busy loop. +Immediately before this normal stop, run the housekeeping command once: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py archive-stale +``` + +It publishes one canonical `Archive ` commit for every project whose +tasks are all approved and whose newest task is older than its threshold, +after rewriting the project's relative links for the deeper path. Skip this +housekeeping on any global hard stop. Only routing new work to an archived +project restores it, through the helper's `unarchive` command; nothing +un-archives on a timer. + ## Spawn one performer Spawn exactly one performer for the selected task with `fork_turns: "none"` @@ -185,6 +217,9 @@ failure stops the loop. The missing `test_TelegramForcePortable` golden account is the only portable-folder global stop. All live/real folder combinations must be reconciled by `perform-task` according to the shared test-loop protocol. +Computer Use being unavailable because macOS is locked is never a scheduler +stop; the performer must continue with the in-binary overlay driver and +artifact-based assessment. ## Route discovered follow-ups @@ -209,6 +244,8 @@ conflict or unavailable-remote slot commit and stop. Return one compact summary: inbox receipt if processed, tasks approved, exceptionally blocked tasks with exact unverified behavior and retry status, -tasks started or left queued, routed discoveries, elapsed time, and why the -loop stopped. Make any global hard stop or unsafe state unmistakable. Never -include source or AI commit hashes; task ids are the only durable locators. +tasks started or left queued, routed discoveries, archived projects, any +discarded interrupted-worker leftovers, elapsed time, and why the loop +stopped. Make +any global hard stop or unsafe state unmistakable. Never include source or AI +commit hashes; task ids are the only durable locators. diff --git a/.agents/skills/perform-task/SKILL.md b/.agents/skills/perform-task/SKILL.md index 2fc453b76e..0dd3db9348 100644 --- a/.agents/skills/perform-task/SKILL.md +++ b/.agents/skills/perform-task/SKILL.md @@ -98,6 +98,11 @@ canonical `Block ` commit. Agent interruption, tool loss, and global environment stops leave the task `in-progress` with its task-scoped local state intact for the next invocation. +A locked macOS session is not an environment stop or verification blocker. +Skip interactive Computer Use and complete the same coverage through the +in-binary overlay: drive the flow, log/assert, capture widgets or windows, +quit, and assess the saved artifacts. + Do not report success from a source commit alone. The final AI commit must be canonical. Retry ordinary concurrent-master publication races until success. On a semantic conflict, unsafe checkout, or unreachable remote, preserve diff --git a/.agents/skills/perform-task/references/computer-use-testing.md b/.agents/skills/perform-task/references/computer-use-testing.md index 18fdea6510..a0e27274b6 100644 --- a/.agents/skills/perform-task/references/computer-use-testing.md +++ b/.agents/skills/perform-task/references/computer-use-testing.md @@ -9,6 +9,7 @@ authoritative. - [Driver policy](#driver-policy) - [Capability gate](#capability-gate) +- [Locked macOS override](#locked-macos-override) - [Exact-app gate](#exact-app-gate) - [Hybrid handshake](#hybrid-handshake) - [Safety envelope](#safety-envelope) @@ -23,7 +24,8 @@ Resolve one policy from the user's request and pass it to the task-runner: focus, scrolling, dragging, menus, windowing, or native UI materially improves coverage. - `overlay-only` — never use Computer Use. - `required` — use hybrid driving for the named flow; if it cannot run safely, return - `BLOCKED(test)` with the exact missing interaction rather than weakening the oracle. + `BLOCKED(test)` with the exact missing interaction rather than weakening the oracle, except for + the locked-macOS override below. For each check, select `Driver: overlay` or `Driver: hybrid`. Keep overlay-only for internal state, data, exact text, and geometry that the in-app harness can exercise deterministically. Select hybrid @@ -63,14 +65,37 @@ not arrive through `fork_turns: "none"`; the explicit path is the handoff. Do not infer capability from an installed directory or config entry. An installed plugin may be disabled by policy, lack its runtime tool, support a different platform, or still need app/OS approval. In `auto`, fall back to the prewritten overlay action. In `required`, or when no equivalent -overlay can exercise the physical interaction, return `BLOCKED(test)` with the canonical mapping below. -Missing capability or permission is never an implementation bug. +overlay can exercise the physical interaction, return `BLOCKED(test)` with the canonical mapping +below unless the locked-macOS override applies. Missing capability or permission is never an +implementation bug. Computer Use runs in the foreground on Windows. Use it only on an unlocked, reserved active desktop or isolated VM. Treat local pointer or keyboard interference, a focus steal, or a window switch as a contaminated run and restart within `MAX_TEST_RUNS`. Treat WSL/Linux/headless as unavailable unless the current host explicitly exposes a supported desktop adapter for the exact test app. +## Locked macOS override + +When macOS reports the graphical session locked, including +`CGSSessionScreenIsLocked=Yes`, do not wait for unlock, repeat capability checks, ask the human to +unlock, count repeated lock checks toward a blocked goal, or return `BLOCKED(test)`. Do not call +Computer Use against the lock screen. Force every planned check to `Driver: overlay`, regardless of +whether the original policy was `auto` or `required`, and continue the ordinary build/run/assess +loop. Skip the exact-app gate and hybrid handshake entirely for that run. + +Treat the in-binary overlay as a complete driver, not merely supplemental instrumentation. Extend it +until it performs the whole task-specific flow on the Qt event loop: create or inject the fixture, +invoke the same application actions or post the needed Qt input events, wait on observable +conditions, log actual values and assertions, grab the target widget or top-level test window from +inside the process, save artifacts, emit `TEST_COMPLETE`, and quit. Prefer `QWidget::grab()` or a +renderer-owned image over an OS desktop capture, which may show only the lock screen. Assess those +logs, measurements, and saved images exactly as in any other overlay run. + +Record the lock in `computer-use-capability.md` as the reason for selecting overlay-only and report +`UI-Driver: overlay`. Lock state is neither missing verification nor a global environment stop. If +the first overlay design depended on an external gesture, redesign that action inside the binary +rather than treating the locked session as evidence that the task cannot be tested. + ## Exact-app gate Keep process and account ownership in the ordinary runner: @@ -146,6 +171,8 @@ Whenever a planned hybrid check is unavailable before a run, write `/computer-use-capability.md` with the policy, host, active skill path or `none`, runtime tool status, OS/app-approval status, exact-app identity result, fallback decision, and reason. +- Locked macOS always continues overlay-only under the override above, including for `required`. + Never map screen lock to `BLOCKED(test)` or `UI-Driver: hybrid-unavailable`. - `auto` with an equivalent fallback continues overlay-only. Record `UI-Driver: overlay` and cite the capability report plus overlay evidence; capability failure is not a blocker. - `required`, or `auto` without an equivalent fallback, records `STATUS: BLOCKED`, diff --git a/.agents/skills/perform-task/references/pipeline.md b/.agents/skills/perform-task/references/pipeline.md index 10e7af5a9a..52653ad166 100644 --- a/.agents/skills/perform-task/references/pipeline.md +++ b/.agents/skills/perform-task/references/pipeline.md @@ -80,8 +80,10 @@ Before planning or editing: 3. Require the prepared portable test account. Its absence is a global hard stop before implementation. 4. Verify a usable Debug executable/build tree, safe path-scoped process - control, safe portable-folder operations, and an app-run display. Computer - Use remains optional unless policy is `required`. + control, safe portable-folder operations, and the ability to launch and + render the in-binary test flow. A locked macOS session disables Computer Use + only; it does not fail this preflight or block testing, even when policy was + `required`. 5. For a new run require a clean tracked Telegram worktree, clean submodules, and no unrelated untracked files, then initialize local recovery state: @@ -279,6 +281,11 @@ rules, with these external-task safety adaptations: clean tip before reapplying the overlay. - Reapply with `git apply --3way`; re-author a conflicting hunk from `test.md` rather than leaving conflict markers. +- On locked macOS, force overlay-only testing without waiting or blocking. + Encode the complete interaction inside the Debug binary using application + actions or Qt events, log assertions and geometry, capture widgets/windows + in-process, save the artifacts, and quit. Do not require an OS-level desktop + screenshot or interactive Computer Use evidence. - Missing `test_TelegramForcePortable` is the only portable-account setup blocker. If live exists without real, move live to real. If live and real both exist, recursively delete live completely. Then deep-copy golden to @@ -387,6 +394,9 @@ planner into new unclaimed dated tasks before selecting more shared work. environment problem stops the current invocation. - A file-lock build error always stops immediately and asks the human to close this checkout's Telegram/debugger. +- A locked macOS session and the resulting unavailable Computer Use driver + never stop or block the task; continue with the complete in-binary overlay + flow. - Missing optional screenshots or mockups never block. - Never silently pass unverified behavior. Surface every blocked or partially verified task with exact `work/test.md`, `work/result.md`, and evidence paths. diff --git a/.agents/skills/process-inbox/SKILL.md b/.agents/skills/process-inbox/SKILL.md index b08f5736f7..39c8890ea5 100644 --- a/.agents/skills/process-inbox/SKILL.md +++ b/.agents/skills/process-inbox/SKILL.md @@ -46,7 +46,8 @@ Read these before planning: - source checkout `AGENTS.md`; - `ai_main/AGENTS.md`; -- existing `projects/*/project.md` and task states relevant to the request; +- existing `projects/*/project.md`, including `projects/archive/`, and task + states relevant to the request; - the transaction's `inbox.md` and every file it references. Use one disposable leaf planner when the harness supports delegation; instruct @@ -67,6 +68,18 @@ domain projects. Group requests into one task only when they form one cohesive, independently testable behavior. Split work until every task is implementable in one pass and has an exact observable acceptance result. +Project slugs are unique across `projects/` and `projects/archive/`. When a +request belongs to an archived project, restore it before routing to it: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py unarchive \ + --project +``` + +The helper moves the project back to `projects/`, rewrites its relative +links, and leaves the restored files staged for this transaction's commit. +Never point a task at a path under `projects/archive/`. + Briefly inspect Telegram source when needed to understand scope and testable seams. Do not plan implementation internals and do not modify the source tree. @@ -157,6 +170,7 @@ Before committing, verify: - every new task has `task.md`, valid `state.yaml`, and a falsifiable acceptance result; - every task link, dependency, and copied input exists; +- no task or project reference points into `projects/archive/`; - no raw inbox path, `.local/`, browser profile, portable account, credential, complete run directory, or complete build log is tracked; - no Telegram or AI commit hash is copied into a task, project, or receipt; diff --git a/.agents/skills/process-inbox/scripts/workspace.py b/.agents/skills/process-inbox/scripts/workspace.py index 42b6b16027..e3a0fef3bb 100755 --- a/.agents/skills/process-inbox/scripts/workspace.py +++ b/.agents/skills/process-inbox/scripts/workspace.py @@ -21,6 +21,9 @@ COMMIT_HASH_PATTERN = re.compile( r"(?i)\b(?:commit|revision|sha(?:-1)?)\b[^\r\n]{0,32}(? activity[project]: + activity[project] = created + if task["status"] != "approved": + unfinished.add(project) + return activity, unfinished + + +def command_archive_stale(args): + if args.days < 1: + raise WorkspaceError("The archive threshold must be at least one day") + config = worktree_config(args, create=True) + sync_canonical(config) + slot = Path(config["slot_worktree"]) + activity, unfinished = project_last_activity(load_states(slot)) + cutoff = datetime.date.today() - datetime.timedelta(days=args.days) + projects = slot / "projects" + candidates = sorted( + entry.name for entry in projects.iterdir() + if entry.is_dir() and entry.name != PROJECT_ARCHIVE_DIR + ) if projects.is_dir() else [] + archived = [] + published = False + for slug in candidates: + if slug in unfinished or slug not in activity or activity[slug] > cutoff: + continue + target = projects / PROJECT_ARCHIVE_DIR / slug + if target.exists(): + raise WorkspaceError(f"The archive already contains a project: {slug}") + target.parent.mkdir(exist_ok=True) + run_git( + slot, + "mv", + f"projects/{slug}", + f"projects/{PROJECT_ARCHIVE_DIR}/{slug}", + ) + rewrite_project_links(target, PROJECT_LINK_PATTERN, "](../../../") + published = commit_paths( + config, + [f"projects/{PROJECT_ARCHIVE_DIR}/{slug}"], + f"Archive {slug}", + ) + archived.append(slug) + print(json.dumps({ + "archived": archived, + "days": args.days, + "published": bool(published), + }, indent=2, sort_keys=True)) + + +def command_unarchive(args): + slug = args.project + if slug == PROJECT_ARCHIVE_DIR or not TAG_PATTERN.fullmatch(slug): + raise WorkspaceError(f"Invalid project slug: {slug!r}") + config = worktree_config(args, create=True) + slot = Path(config["slot_worktree"]) + archived = slot / "projects" / PROJECT_ARCHIVE_DIR / slug + target = slot / "projects" / slug + if not archived.is_dir(): + raise WorkspaceError(f"No archived project: {slug}") + if target.exists(): + raise WorkspaceError(f"The project is already live: {slug}") + run_git( + slot, + "mv", + f"projects/{PROJECT_ARCHIVE_DIR}/{slug}", + f"projects/{slug}", + ) + rewrite_project_links(target, ARCHIVED_PROJECT_LINK_PATTERN, "](../../") + run_git(slot, "add", "--", f"projects/{slug}") + archive_root = slot / "projects" / PROJECT_ARCHIVE_DIR + if archive_root.is_dir() and not any(archive_root.iterdir()): + archive_root.rmdir() + print(json.dumps({ + "project": slug, + "published": False, + "unarchived": True, + }, indent=2, sort_keys=True)) + + def payload_entries(inbox): return sorted( (path for path in inbox.iterdir() if path.name != "backup"), @@ -1252,6 +1358,16 @@ def parse_args(): add_common_arguments(publish) publish.set_defaults(handler=command_publish) + archive_stale = subparsers.add_parser("archive-stale") + add_common_arguments(archive_stale) + archive_stale.add_argument("--days", type=int, default=90) + archive_stale.set_defaults(handler=command_archive_stale) + + unarchive = subparsers.add_parser("unarchive") + add_common_arguments(unarchive) + unarchive.add_argument("--project", required=True) + unarchive.set_defaults(handler=command_unarchive) + return parser.parse_args() diff --git a/.agents/skills/process-inbox/scripts/workspace_test.py b/.agents/skills/process-inbox/scripts/workspace_test.py index 7c85363725..21ee3bca15 100644 --- a/.agents/skills/process-inbox/scripts/workspace_test.py +++ b/.agents/skills/process-inbox/scripts/workspace_test.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import contextlib +import datetime import io from pathlib import Path import subprocess @@ -15,6 +16,12 @@ import workspace TASK_ID = "2026/07/19/correct-recent-search-peer-actions" +class FrozenDate(datetime.date): + @classmethod + def today(cls): + return cls(2026, 7, 20) + + def task_state(status, claimed_by="macbook-twork"): return { "id": TASK_ID, @@ -61,6 +68,42 @@ inbox_receipt: receipts/2026/07/19/test.md return directory +def write_project(slot, slug): + directory = slot / "projects" / slug + directory.mkdir(parents=True) + (directory / "project.md").write_text(f"# {slug}\n", encoding="utf-8") + (directory / "tasks.md").write_text( + "# Tasks\n\n- [Task](../../tasks/2026/01/10/some-task/task.md)\n", + encoding="utf-8", + ) + return directory + + +def write_project_task(slot, task_id, project, status, created): + directory = slot / "tasks" / task_id + directory.mkdir(parents=True) + (directory / "task.md").write_text( + f"# {task_id.rsplit('/', 1)[-1]}\n", + encoding="utf-8", + ) + claimed = status != "todo" + (directory / "state.yaml").write_text( + f"""status: {status} +created: {created} +project: {project} +depends_on: [] +claimed_by: {"macbook-twork" if claimed else "null"} +claimed_at: {f"{created}T10:00:00+04:00" if claimed else "null"} +claim_order: {"1" if claimed else "null"} +lease_until: null +phase: {"complete" if status == "approved" else "null"} +inbox_receipt: receipts/2026/01/10/test.md +""", + encoding="utf-8", + ) + return directory + + def git(repo, *args): return subprocess.run( ["git", "-C", str(repo), *args], @@ -71,6 +114,13 @@ def git(repo, *args): ).stdout.strip() +def git_repo(path): + path.mkdir(parents=True, exist_ok=True) + git(path, "init") + git(path, "config", "user.name", "Workflow Test") + git(path, "config", "user.email", "workflow@example.invalid") + + class WorkspaceTest(unittest.TestCase): def test_resolve_prefers_blocked_over_approved_history(self): blocked = task_state("blocked") @@ -337,6 +387,101 @@ inbox_receipt: receipts/2026/07/19/test.md workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "green")) ) + def test_archive_stale_moves_only_old_fully_approved_projects(self): + with tempfile.TemporaryDirectory() as temporary: + slot = Path(temporary) + git_repo(slot) + write_project(slot, "old-project") + write_project(slot, "fresh-project") + write_project(slot, "pending-project") + write_project_task( + slot, "2026/01/10/old-task", "old-project", "approved", "2026-01-10" + ) + write_project_task( + slot, "2026/07/19/fresh-task", "fresh-project", "approved", "2026-07-19" + ) + write_project_task( + slot, "2026/01/11/pending-task", "pending-project", "blocked", "2026-01-11" + ) + git(slot, "add", ".") + git(slot, "commit", "-m", "Seed projects") + config = { + "checkout_tag": "macbook-twork", + "slot_worktree": str(slot), + } + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + mock.patch.object(workspace, "sync_canonical"), + mock.patch.object(workspace, "commit_paths", return_value=True) as commit, + mock.patch.object(workspace.datetime, "date", FrozenDate), + contextlib.redirect_stdout(io.StringIO()), + ): + workspace.command_archive_stale(SimpleNamespace(days=90)) + + archived = slot / "projects" / "archive" / "old-project" + self.assertTrue(archived.is_dir()) + self.assertFalse((slot / "projects" / "old-project").exists()) + self.assertIn( + "](../../../tasks/", + (archived / "tasks.md").read_text(encoding="utf-8"), + ) + self.assertTrue((slot / "projects" / "fresh-project").is_dir()) + self.assertTrue((slot / "projects" / "pending-project").is_dir()) + commit.assert_called_once() + self.assertEqual( + commit.call_args.args[1], + ["projects/archive/old-project"], + ) + self.assertEqual(commit.call_args.args[2], "Archive old-project") + + def test_archive_stale_without_candidates_reports_nothing(self): + with tempfile.TemporaryDirectory() as temporary: + slot = Path(temporary) + config = { + "checkout_tag": "macbook-twork", + "slot_worktree": str(slot), + } + out = io.StringIO() + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + mock.patch.object(workspace, "sync_canonical"), + mock.patch.object(workspace, "commit_paths") as commit, + contextlib.redirect_stdout(out), + ): + workspace.command_archive_stale(SimpleNamespace(days=90)) + + commit.assert_not_called() + self.assertIn('"archived": []', out.getvalue()) + + def test_unarchive_restores_links_and_stages_the_project(self): + with tempfile.TemporaryDirectory() as temporary: + slot = Path(temporary) + git_repo(slot) + directory = slot / "projects" / "archive" / "old-project" + directory.mkdir(parents=True) + (directory / "project.md").write_text("# Old project\n", encoding="utf-8") + (directory / "tasks.md").write_text( + "# Tasks\n\n- [Task](../../../tasks/2026/01/10/some-task/task.md)\n", + encoding="utf-8", + ) + git(slot, "add", ".") + git(slot, "commit", "-m", "Seed archive") + config = {"slot_worktree": str(slot)} + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + contextlib.redirect_stdout(io.StringIO()), + ): + workspace.command_unarchive(SimpleNamespace(project="old-project")) + + restored = slot / "projects" / "old-project" + self.assertIn( + "](../../tasks/", + (restored / "tasks.md").read_text(encoding="utf-8"), + ) + self.assertFalse((slot / "projects" / "archive").exists()) + staged = git(slot, "diff", "--cached", "--name-only") + self.assertIn("projects/old-project/tasks.md", staged) + if __name__ == "__main__": unittest.main() diff --git a/.claude/commands/continue.md b/.claude/commands/continue.md index 908fa0b13c..5df951868b 100644 --- a/.claude/commands/continue.md +++ b/.claude/commands/continue.md @@ -16,7 +16,8 @@ in its initial prompt to read `.claude/ai-workflow-adapter.md` completely before the applicable shared skill or reference. Use the Agent tool for those workers; do not start Claude subprocesses through Bash. -Arguments, when present, are natural-language scheduling or priority hints: +Arguments, when present, are natural-language hints for new shared work; their +wording decides whether they merely prioritize or strictly restrict selection: ```text $ARGUMENTS diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md index 6093ee357d..ce37d2b504 100644 --- a/.claude/commands/reflect.md +++ b/.claude/commands/reflect.md @@ -44,7 +44,8 @@ Resolve the task and read its context: 1. Run `python3 .agents/skills/process-inbox/scripts/workspace.py resolve --name "$ARGUMENTS"` (use the host's Python 3 command). 2. Read the resolved task's `task.md` and `work/context.md` from the returned AI slot worktree. -3. When `project` is non-null, also read `projects//project.md`. +3. When `project` is non-null, also read `projects//project.md`, or + `projects/archive//project.md` when the project has been archived. This helps you distinguish between: - **Task-specific mistakes** — the agent misunderstood this particular feature's requirements or made a wrong choice within the specific problem. These are NOT documentation-worthy.