Named Plans - alexherrero/agentm GitHub Wiki

Named plans (multi-plan state)

Note

Status: implemented (2026-06-13, updated 2026-09-22). The multi-plan-state substrate β€” the round-trip lock, resolve_active_plan, the check-multi-plan-naming gate β€” ships in main; crickets' development-lifecycle plugin writes the .harness/active-plan marker this page's reader consumes. Build history below has the full rollout, through plan 15's retirement of the vault fallback.

Named plans let the harness hold more than one active plan in the same shared vault. Each named plan lives in its own PLAN-<name>.md with a matching progress-<name>.md, alongside the unnamed pair a solo session already uses. A resolution model then binds every session to exactly one plan β€” the one it owns. This is the substrate the coordinator-directed worker team stands on: give each worker its own plan file, and the team never collides on harness state.

Why this exists

Through V5-0, harness state assumed one active plan per project: read_state_file / write_state_file resolved a fixed PLAN.md and progress.md under the project's _harness/, and every phase spec, hook, and the doctor skill looked for the PLAN.md. That is fine for a solo session, but V5's direction is concurrent agents β€” one session per worker β€” and several workers driving one singleton plan file would serialize the whole team onto one document.

Named plans remove that bottleneck. A plan can be named β€” PLAN-foo.md with its own progress-foo.md β€” so two workers touch different files and never contend on the same document. The contract is strictly additive: the unnamed PLAN.md / progress.md path is unchanged, so a solo session keeps working exactly as before. There is no data migration.

This page is the agentm substrate slice only. The behavioral half β€” the /work <named-plan> phase argument, the writer that appends progress-<name>.md, the staging UX that emits a named plan β€” lives in the companion crickets development-lifecycle plugin (the V5-unbundling boundary: agentm owns the durable state, crickets owns the phase loop). See The agentm/crickets seam below.

The naming contract

Named plan files are flat at the repo-local .harness/ root (agentm-vault plan 15 retired the vault-side _harness/ this used to also apply to) β€” not a per-plan subdirectory, not a generalized queued-plans/ staging tier (that staging tier is a separate, crickets-side concept).

File Style Written by
PLAN.md (unnamed) replace (content-hash CAS) the singleton path β€” unchanged, back-compatible
PLAN-<name>.md replace (content-hash CAS) the named-plan path
progress.md (unnamed) append-only the singleton path β€” unchanged
progress-<name>.md append-only the named-plan writer (crickets-side)

A task's plan takes a third shape (agentm-vault plan 09): tasks/<slug>/plan.md, with its progress.md and tracker.md beside it in the same directory, all three addressed by absolute path rather than a bare filename under the repo-local .harness/. It resolves only on a synced backend, since a device-local .harness/ has no project directory to hold a tasks/ tree. Within a slug, a task at tasks/<slug>/plan.md is tried first, and the flat PLAN-<slug>.md pair is the fallback β€” a second place a slug can resolve, alongside the flat naming contract above.

Optional YAML frontmatter on plan files

Plan files may carry a YAML frontmatter block (delimited by ---) with two optional coordinator fields introduced in V5-11:

Field Type Purpose
depends_on list[string] Slugs of plans that must complete before this one is ready to start. Used by scripts/readiness.py for dependency-readiness checks and scripts/merge_order.py for topo-sort ordering.
touches list[string] Glob patterns of files this plan modifies (e.g. scripts/foo*.py, wiki/reference/*.md). Used by scripts/readiness.py to detect safe-to-run-together overlap. Plans without touches: are loudly degraded by the readiness checker β€” never silently assumed safe to run concurrently.

Both fields are purely additive β€” absent fields mean "no declared deps" and "no declared file scope" respectively. A solo-session plan without frontmatter is unchanged by this convention.

The replace-vs-append split is what the design leans on. PLAN-* files are replace-style and go through the vault-write protocol's content-hash compare-and-swap, exactly like the unnamed PLAN.md. progress-* files are append-only β€” they are never CAS-replaced, because two workers appending to one progress file merges naturally through Drive's append handling, while a replace-style write of a progress file would reintroduce the contention this whole design avoids. The append discipline for progress-<name>.md is enforced where the writer lives (crickets-side); this substrate slice locks the round-trip read contract and documents the append contract so the writer cannot drift.

Why this is a naming convention, not a dispatcher rewrite

The state resolver is already filename-agnostic. vault_state_path(resolution, filename) does pure path construction, and safe_write_replace_style runs its content-hash CAS on an arbitrary path β€” neither keys on the literal string "PLAN.md". Named plans fall out of that resolver for free: a named file is just a different filename argument threading through the same path construction and the same atomic-write-under-mutex machinery (see Vault write protocol).

What the substrate work adds is not new resolution logic but two guards that lock the contract: a test suite that proves the round-trip β€” a PLAN-foo.md resolves under the repo-local .harness/, named content reads and writes back, content-hash CAS raises on a stale hash exactly as it does for the singleton, and the conflict janitor flags PLAN-foo (conflicted copy …).md β€” and the check-multi-plan-naming gate (CI gate #13), which asserts the resolver still exposes the named-plan entry points (resolve_active_plan and state_dir β€” harness_state_dir retired under agentm-vault plan 15) and that no curated harness doc silently reasserts a singleton. Both are locked by dedicated test suites; see the shipped-surfaces table below for the files and counts.

If any of that work surfaces a caller somewhere that hard-codes "PLAN.md" (the resolver itself is clean; a caller might not be), the fix is to close it minimally.

Binding a session to its plan β€” resolve_active_plan

A worker must end up bound to exactly one plan, and a mis-binding (a worker silently working the wrong plan) is the foot-gun this design most wants to prevent. The resolve_active_plan helper makes the binding explicit with a strict precedence:

explicit argument
  β†’ worktree-local .harness/active-plan marker
    β†’ legacy PLAN.md / progress.md

It returns the resolved plan filename and its matching progress filename. The marker pattern mirrors the existing .project-mode repo-local marker (reachable on disk without consulting the vault).

The rule that matters here: resolve_active_plan errors loudly and never silently falls back to the singleton. When .harness/active-plan exists but names a plan whose PLAN-<name>.md is absent, empty, or ambiguous, it raises rather than quietly degrading to PLAN.md. A silent fallback there is exactly how a worker would end up editing the wrong plan; raising turns a mis-binding into an immediate, observable failure. Only the unset case β€” no argument and no marker β€” resolves to the legacy singleton, which is the intended back-compat path.

resolve_active_plan and its four helpers (_read_active_plan_marker, _normalize_plan_name, _is_safe_plan_slug, _plan_pair), plus a dedicated ActivePlanError, live in scripts/harness_memory.py. What is locked: the precedence order above, an explicit-arg traversal guard (ValueError on a slug that is not a single path component), and ActivePlanError on every present-but-unresolvable marker (blank, malformed, traversal, or naming an absent/empty plan). This helper is the reader half of the contract; the marker writer (the crickets worktree-spawn helper) shipped in development-lifecycle v3.7.0.

resolve_active_plan returns an ActivePlan (agentm-vault plan 09) rather than a bare tuple. It still unpacks and compares as the (plan, progress) pair every existing caller reads, so nothing that destructures it breaks. It also carries .tracker (the tracker file beside the pair), .layout ("local" for the repo-local .harness/-relative names above β€” renamed from "flat" when agentm-vault plan 15 retired the vault's _harness/ fallback β€” and "task" for the absolute paths a task directory resolves to), and .slug. Within a slug, a task at tasks/<slug>/plan.md is tried first and the flat PLAN-<slug>.md pair is the fallback β€” see The naming contract above. active_plan_paths(resolution, plan_arg=...) composes the full (plan, progress, tracker) paths in one call: it joins a flat name onto the resolved repo-local .harness/ and leaves a task's already-absolute paths alone.

Placement, lookup by either form, and the bare-call refusal (agentm-vault plan 10, task 7; the directory-existence check itself retired under plan 15, task 2). On a project that keeps its plans in numbered tasks β€” every project on a synced backend, unconditionally, since plan 15 stopped consulting whether a _harness/ directory happened to still exist on disk β€” a slug that names no task is placed rather than left unresolved: _placed_task composes tasks/NNN-<verb-slug>/ with the next free three-digit number and writes nothing β€” /plan writes the plan there, and the directory is what makes the task exist, so a plan written the day after the move doesn't land back in a directory that no longer exists. A task's name is its directory name, number included, and _resolve_task_dir (via _match_task_dirs) finds it by either form β€” the full 042-build-the-brief, or the verb slug alone, build-the-brief β€” with an exact directory name short-circuiting so it's never ambiguous with a numbered one. Two tasks sharing a verb slug is a real ambiguity, not a guess the resolver makes for you: it raises ActivePlanError naming both, exit 2 at the CLI. A project reads this way whenever _keeps_plans_in_tasks is true β€” which, since plan 15, is simply "a synced backend resolves" (_state_backend_target(resolution) is not None), nothing on disk consulted; a repo with no vault and a .project-mode=local opt-out are the only cases left that keep the repo-local flat-pair behavior.

Placement never doubles up or collides (#691, from the plan 15 release review). A slug that already matches exactly one plan-less task directory β€” the leftover of a /plan interrupted before it wrote β€” is reused rather than joined by a second numbered one, which would otherwise leave two directories answering the same verb slug. A slug typed with its own number (042-build-the-brief) is refused, not placed, when that number or its verb slug already belongs to another task: placing it would create the same two-tasks-one-name ambiguity the lookup above raises on rather than guesses through. _is_safe_plan_slug also rejects a colon β€” D:evil joined onto a directory is a drive-relative path on Windows, not a subdirectory of it.

Because the helper is a Python function and phase specs shell out from bash, a thin resolve-active-plan CLI verb wraps it: it emits the resolved <plan_path>\t<progress_path> pair on stdout so the crickets development-lifecycle phase specs can target named plans without reimplementing precedence. --with-tracker appends the tracker path as a third tab-separated field, for a caller that also wants tracker.md (or tracker-<name>.md) without re-deriving it. Exit codes mirror the reader's stance β€” 0 resolved, 1 a repo-local pair with no project root to join it to (unreachable in practice: resolve_project always sets one), 2 loud on a dangling .harness/active-plan marker, an unsafe slug, or two tasks sharing one verb slug, never a silent singleton fallback, and 4 β€” nothing on stdout, one line on stderr β€” when a bare call names no plan on a project that keeps its plans in numbered tasks: it has no singleton PLAN.md to answer with, so resolve_active_plan raises TaskNameRequired instead and the CLI dispatch maps it to exit 4. process_seam.py's state_path shim answers a bare state-path call the same way β€” see Process seam for that side of the contract. It is read-only, and it is a consumption bridge for the crickets side rather than one of the substrate's own numbered tasks. See the shipped-surfaces table below for both files and their test counts.

A project with no repo checkout (agentm-vault, resolve-the-project-homes). harness_memory.py list-plans --project SLUG lists a project's open tasks by slug, for a caller like crickets' /orient that has a project name but no checkout to stand in; it prints no active-binding= line, because a binding lives in a checkout, and it is exclusive with --project-root. resolve-active-plan --plan NAME --project SLUG [--with-tracker] answers one of those tasks by its directory name or verb slug, on the checkout form's line. --plan is required, since no marker is read, and a name no task carries exits 2 rather than placing a new task: a slug lookup is a reader, and only a checkout's /plan places. crickets' bridge uses it to give /open each task's progress and tracker. Its sibling, process_seam.py project-path {tasks|designs|desk}, names where the project keeps its tasks, designs and machine files β€” see the process seam reference. A project named by slug has no checkout, so only the device-level state_mode applies to it, never a .harness/.project-mode marker in whatever repo the caller stands in.

resolve_active_plan is a reader of the marker. The marker is written by component (2)'s worktree-spawn helper (crickets development-lifecycle' spawn_worker.py, shipped v3.7.0, 2026-06-13) β€” a separate plan on the crickets side. That split follows the architecture (see the seam below); the reader's own tests create the marker by hand.

The agentm/crickets seam

This substrate and its behavioral half split along a real architectural seam:

Owned by agentm (this slice) Owned by crickets (sibling plan)
the named-plan resolver contract + its test lock the /work <named-plan> phase argument
resolve_active_plan (reads .harness/active-plan) the progress-<name>.md append writer
the resolve-active-plan CLI verb β€” the bash entry point the crickets phase specs shell to the phase specs that consume the emitted (PLAN, progress) pair
list_plan_files(harness_dir) public function + list-plans CLI verb βœ… (shipped, V5-5 task 3) the session-start hook callers (hooks delegate to this verb)
the queue_status_lite read logic the /queue-status-lite command surface
the check-multi-plan-naming gate /plan + /design sequence emitting PLAN-<name>.md
named-plan-aware session-start hooks + doctor βœ… (shipped, task 5; hooks updated V5-5 task 3) the two-tier staging β†’ activation UX

Crickets-side status (2026-06-12): the named-plan writers shipped β€” /work, /plan, and /review take a uniform --name <slug> flag (the right column's first three rows), resolving the pair through a resolve_plan.py bridge that shells to the resolve-active-plan verb rather than reimplementing precedence. Still crickets-side pending: the /queue-status-lite command surface, /design sequence emitting PLAN-<name>.md, and the two-tier staging β†’ activation UX.

The seam point: this plan's resolve_active_plan reads .harness/active-plan; component (2)'s worktree-spawn helper writes it. Because a single /work session is single-repo and reads only its own repo's .harness/, the split is forced by the architecture β€” the substrate can be built and verified independently, but an end-to-end "a worker runs /work --name foo" demo needs both the crickets sibling plan (shipped 2026-06-12) and the marker writer (shipped 2026-06-13). Both are now in place.

What has shipped so far

This substrate is landing task-by-task under "V5-10 part 1". What is in main today:

Surface Task Where it lives
Filename-agnostic round-trip lock (PLAN-<name>.md reads/writes back through the same CAS) 1 scripts/test_harness_memory_named_plans.py (9 tests, no production-code change)
resolve_active_plan — the session→plan binder (reader of .harness/active-plan) 2 scripts/harness_memory.py, locked by scripts/test_resolve_active_plan.py (13 tests)
resolve-active-plan CLI verb β€” the bash bridge that emits the (PLAN, progress) pair so crickets phase specs can consume the reader (read-only; exit 0/1/2) bridge (not a tasks-1–5 deliverable β€” shipped for the crickets "multi-plan writers" plan) scripts/harness_memory.py#L1714 (dispatch #L1884), locked by the ResolveActivePlanCLI class in scripts/test_resolve_active_plan.py (8 CLI tests β†’ file now 21: 13 function + 8 CLI)
check-multi-plan-naming gate (CI gate #13) 4 scripts/check-multi-plan-naming.sh, locked by scripts/test_check_multi_plan_naming.py
Named-plan-aware session-start hooks + doctor 5 see below
list_plan_files(harness_dir) public function + list-plans CLI verb β€” plan enumeration via the bridge (V5-5 task 3, commit a7e3bee) V5-5 task 3 scripts/harness_memory.py#L518 (function) + #L1540 (parser) + #L1743 (dispatch); locked by TestListPlanFiles + TestListPlansCLI in scripts/test_harness_memory.py (13 tests)

Task 5 β€” named-plan-aware discovery (readers only). Session boot and doctor now see named plans, while remaining strict readers of the (still-unwritten) .harness/active-plan marker:

  • Session-start hooks (both twins). Before the locked DC-7 singleton check, each hook delegates plan discovery to the list-plans CLI verb in harness_memory.py (V5-5 task 3, commit a7e3bee). The verb enumerates PLAN*.md in the resolved state dir via harness_state_dir() (V5-6-compatible; renamed state_dir() under agentm-vault plan 15, when the vault side of that dir stopped being _harness/) and emits the .harness/active-plan binding when set. When β‰₯1 named plan exists the hook emits a named-plan block listing every PLAN*.md and the binding; when zero exist it falls through to the byte-identical locked singleton block (back-compat β€” a solo repo is unchanged). A present marker naming an absent PLAN-<name>.md is surfaced as DANGLING, never fatal β€” since agentm-vault plan 09 the dangling message names both candidates, PLAN-<name>.md and tasks/<name>/plan.md. Once the project-brief-session-start hook (also plan 09) is registered in the user or project settings, this hook leaves the plan block to it β€” still printing its own observability line β€” and prints its own block again only when that hook asks it to, via AGENTM_PLAN_BLOCK_ONLY=1; see Build history below. Bash: harness-context-session-start.sh#L69 (list-plans call) + #L87 (named-plan branch); PowerShell twin: harness-context-session-start.ps1#L50 (list-plans call) + #L72 (named-plan branch). Covered by 5 named-plan methods in scripts/test_harness_context_hook.py. 13 new tests for list_plan_files + list-plans CLI in scripts/test_harness_memory.py (TestListPlanFiles 6, TestListPlansCLI 7).
  • doctor item 4. Reports the full named-plan set (e.g. 2 named plans: PLAN-foo.md, PLAN-bar.md), treats a named-only repo (named plans present, unnamed singleton absent) as healthy, and flags a dangling .harness/active-plan as [WARN] β€” never FAIL, mirroring the hook's non-fatal surfacing. harness/skills/doctor.md#L50.
  • Gate assertion 3. check-multi-plan-naming now also asserts both hook twins keep the PLAN-*.md glob, so the twins cannot drift apart and silently lose named-plan discovery. scripts/check-multi-plan-naming.sh#L89, with 3 assertion-3 methods in scripts/test_check_multi_plan_naming.py.
  • Conflict-merger operator message. The GDrive conflict-janitor's notice now names PLAN-<name>.md / progress-<name>.md alongside the singletons. conflict-merger-session-start.sh#L111.

All dependencies are shipped on both sides of that seam β€” see The agentm/crickets seam above for the full split.

What this slice does not do

This slice stops at a deliberate boundary:

  • It creates no worktrees itself β€” this slice ships only the reader of the .harness/active-plan marker. The writer (originally spawn_worker.py, now crickets' worktree_marker.py binding a host-created worktree) lives entirely on the crickets side of the seam.
  • It ships no integration / merge command β€” combining two workers' output is a later plan.
  • It defines no role agent-defs (researcher / project-manager / tech-lead / worker) β€” also later.
  • It does not retire the worktrees-never-auto convention β€” that changes behavior for concurrent sessions and lands with the worktree component, not here.
  • It does not arbitrate claims or leases between workers. The read-model (Queue status lite) is informational only; the human is the arbiter.

Build history

The substrate shipped under "V5-10 part 1 β€” Multi-plan state" (the local, gitignored .harness/PLAN.md): the round-trip lock, resolve_active_plan, the check-multi-plan-naming gate, and the named-plan-aware session-start hooks and doctor all landed together. The crickets development-lifecycle half shipped the --name <slug> flag across /work, /plan, and /review on 2026-06-12, and the .harness/active-plan marker writer shipped in development-lifecycle v3.7.0 on 2026-06-13, when spawn_worker.py began writing <worktree>/.harness/active-plan for resolve_active_plan to read back.

2026-07-06 update. spawn_worker.py is retired β€” crickets' worktree-native flow moved worktree creation to the installed host's own primitive (Claude Code EnterWorktree; Antigravity New-Worktree-Mode), with a much smaller worktree_marker.py now the marker writer. The marker's own format is unchanged (still the bare slug resolve_active_plan reads back), so nothing on this page's substrate side needed to change β€” validated end-to-end via a real throwaway plan the same day.

Plan 09 update (2026-09-13). A task's plan (tasks/<slug>/plan.md, with progress.md and tracker.md beside it) is a second place a slug resolves, tried before the flat PLAN-<slug>.md pair; resolve_active_plan returns this as an ActivePlan carrying .tracker / .layout / .slug rather than a bare tuple, and the resolve-active-plan CLI verb gained --with-tracker. The new project-brief-session-start hook opens a bound session on its project's and task's tracker State and Next instead of the plan block, once an operator registers it; unregistered, or with no tracker yet, a session opens exactly as it did before. See scripts/harness_memory.py, scripts/tracker.py, and scripts/project_brief.py.

Plan 10 update (2026-09-15) β€” the resolver half of the projects migration, task 7. Landing this ahead of the data move is deliberate: a project only reads the task layout above once it has already lost its _harness/, and the resolver has to be in place first so a plan written the day after the move doesn't land back in the directory the move removed. Three things are new, all in resolve_active_plan's own module: placement β€” a slug naming no task on a project that keeps its plans in tasks resolves to a freshly composed tasks/NNN-<verb-slug>/, path-only, nothing written; lookup by either form β€” a task's directory name, number included, or the verb slug alone, both find it, and two tasks sharing a verb slug raise rather than guess; and the bare-call refusal β€” such a project has no singleton to fall back to, so a call naming no plan at all raises the new TaskNameRequired, which both harness_memory.py resolve-active-plan and process_seam.py state-path answer as exit 4, nothing on stdout, one line on stderr β€” the code the installed crickets development-lifecycle release already handles. project_brief.py catches the same exception and renders the project's brief without a plan line rather than failing. Locked by the new TaskPlacementAndLookup class in scripts/test_resolve_active_plan.py (18 tests) and 2 new methods in scripts/test_process_seam.py. The data half β€” the migration that actually dissolves _harness/ into this shape β€” is Run the projects migration.

Plan 15 update (2026-09-22) β€” the retirement, task 2. With the projects migration long since run against every real project, _keeps_plans_in_tasks stopped asking whether a _harness/ directory happened to still exist and now answers purely from whether a synced backend resolves β€” a writer that ever recreated a stray _harness/ on a migrated project could otherwise silently turn a bare call back into the retired singleton, which is exactly the failure mode this closes. ActivePlan.layout is renamed "flat" β†’ "local" (the task-layout label, "task", is unchanged), since the flat pair now only ever means the repo-local .harness/, never a vault one. resolve-active-plan's exit code 1 β€” "a repo-local pair with no project root to join it to" β€” was already unreachable in practice before this change and stays so; it is no longer documented as the vault's "no resolvable _harness/" case, because that case no longer exists. read_state_file / write_state_file (the flat-filename-only readers/writers this page's "Why this exists" section describes as V5-0's original shape) are retired outright, replaced by read_machine_file / write_machine_file (a project's desk/, keyed by filename, not a plan pair) and the new append-progress CLI verb, which appends to whichever progress log resolve_active_plan answers β€” a task's or the repo-local pair's. See CI gates for check-no-harness-paths, the gate that now keeps the retired directory's name out of the code entirely.

Related

⚠️ **GitHub.com Fallback** ⚠️