Skip to main content
Loom Core docs

Loom Plan Store

The plan store makes a plan a first-class, durable entity in the agent-context MCP server, addressable by a stable plan_id and reachable by any agent — Claude Code, Codex, or a Mills-spawned pod — from any worktree or repo.

Why it exists

Plans used to live only as git-tracked .loom/*.md files. Since git worktree add checks out a frozen copy of .loom/ at the branch's HEAD, a plan written on main (or in worktree A) is invisible to a fresh sub-agent in worktree B until it is committed and merged/rebased. Sub-agents in the parallel-slice-ship and Mills flows therefore could not reliably find the plan they were implementing.

The plan store fixes this by putting plans in the same shared global Qdrant that already backs sessions, tasks, handoffs, worktrees, and memory. A plan is looked up by plan_id; the .loom/*.md file becomes a rendered mirror for human/MR review (store-canonical, file-mirrored).

See the design spec and slice plan in .loom/160-product-spec-loom-plan-store-* and .loom/161-implementation-plan-loom-plan-store-*.

Scoping invariant

Plan reads are scoped by plan_id / project / namespace and are never filtered by agent_id. The default context-recall path is agent-scoped, which would hide a plan from exactly the parallel and Mills agents that need it. Writes are attributed (created_by) but not gated by identity — any agent may read a project's plans.

Entities

Plan (agent_plans_v1)

A unit of planned work: id, slug, title, project, namespace, phase, objective (the synthesized end-state + the through-line connecting the slices — the "why" a reviewer reads before the slice list; distinct from and NOT an echo of spec_doc), spec_doc (canonical markdown body), and provenance (created_by, source_session_id). Slice 2 adds the planning contract (success, budget, riskiest_assumption, kill_test/kill_test_status, dependencies), lifecycle pointers (mr_refs, pipeline_refs, deploy_refs), cross-system links (mirror_path, mills_backlog_id, gitlab_issue_iid), and phase_history. These fields align with the Mills BacklogItem so the two converge (Slice 7). The Plan is the source of truth; the .loom/*.md mirror is rendered from it.

Slice (agent_plan_slices_v1)

An independently shippable slice of a plan, stored as its own record so parallel slice-implementers update status/decisions without racing on the plan. id is <plan_id>#<order>. Fields: name, goal, files (the disjoint set — basis for Slice 4 claim enforcement), acceptance_criteria, test_strategy, interface_contracts, branch_name, depends_on, phase, assigned_agent_id, worktree_id, commit_refs, mr_ref, and decisions (blockers/decisions anchored to the slice rather than lost to a context window). A fresh implementer resolves its work with agent_plan_slice_get(slice_id).

The Spinning-Room producer (the council editor + spin) now populates the connective tissue — objective on the plan, and depends_on / interface_contracts / acceptance_criteria per slice. In seed slices passed to agent_plan_create, depends_on may reference sibling slices by name; the store resolves each name to the minted <plan_id>#<order> slice_id (self-edges and unknown names are dropped). This lets a producer that cannot know the final slice_ids still author a resolvable slice DAG. The HUD renders the DAG inline (depends on #1, #3) — it does not yet sequence Mills dispatch by it.

Lifecycle

A plan advances through a validated phase DAG; agent_plan_lifecycle_advance rejects illegal hops and records each transition in phase_history:

draft → planned → in_progress → in_review → merging → merged → deployed → done

abandoned is reachable from any non-terminal phase; done/abandoned are terminal. Slice phases: pending → claimed → implementing → implemented → in_review → integrated → merged.

Tools

ToolPurpose
agent_plan_createCreate a plan; returns a stable plan_id. Optionally seed slices.
agent_plan_getFetch a plan (+ aggregated slices) by plan_id. Cross-agent, cross-worktree.
agent_plan_listList plans by project / namespace / phase.
agent_plan_updatePatch spec/title/success and append mr/pipeline/deploy refs.
agent_plan_searchSemantic search over title+spec (best-effort; falls back to a list).
agent_plan_lifecycle_advanceValidated phase transition with recorded history.
agent_plan_slice_addAppend a slice to a plan.
agent_plan_slice_getFetch one slice by slice_id (how an implementer finds its scope).
agent_plan_slice_listList a plan's slices, ordered.
agent_plan_slice_updateUpdate phase/refs; append decisions/commit refs.
agent_plan_slice_claimClaim a slice for an agent (conflict unless force).
agent_plan_renderRender the plan's markdown mirror from the store; with path, write it atomically and record mirror_path.

Embedding for agent_plan_search is best-effort: a failed embedder never blocks a write (a deterministic fallback vector keeps the point valid), avoiding the embed-coupling outage class that previously blanked the task lane.

Parallel slice shipping (claim enforcement)

parallel-slice-ship persists its slice decomposition to the store, then spawns one slice-implementer per slice passing only plan_id+slice_id. Each implementer resolves its scope with agent_plan_slice_get, calls agent_plan_slice_claim, and records status/decisions with agent_plan_slice_update — so a fresh agent in another worktree never depends on the spawn prompt or a stale .loom/ checkout, and the orchestrator reconstructs full state from agent_plan_slice_list even after sub-agent sessions end.

agent_plan_slice_claim makes the slice's files an enforced boundary: it hard-claims them via the file-claim service (all-or-nothing). If any file is held by another active agent the claim is refused (conflicting_files), so two parallel implementers can never collide on a file — a real upgrade over the advisory agent_file_claim_acquire (which still defaults to report-only; pass enforce: true for hard rejection).

Plan-aware handoffs

agent_handoff_create accepts optional plan_id/slice_id. The receiver sees them in agent_handoff_inbox and agent_handoff_accept and resumes the work by id (agent_plan_get / agent_plan_slice_get) — a durable, cross-vendor scope (Claude ↔ Codex ↔ Mills) instead of a list of entry_ids that may be compacted away. Plain handoffs are unchanged (empty plan fields).

Markdown mirror (store-canonical)

The Plan in Qdrant is canonical; agent_plan_render projects it to a human/MR-reviewable .loom/*.md file. With a path, the file is written atomically (same-directory tempfile + os.Rename) because external watchers (codex, gemini, fs inotify) read .loom/ and a non-atomic O_TRUNC write exposes a partial-read window. Re-render after any plan/slice mutation so the committed file stays in sync; agents always read the store by plan_id, not the file. The plan-loom-core skill drives this flow (create in store → render mirror → edit via tools → re-render).

HUD lifecycle view

The HUD exposes the plan store read-only so you can review each plan/slice across plan→implement→review→merge→deploy:

  • GET /api/plans?project=&namespace=&phase= — list plans (with phase, MR/pipeline/deploy refs, phase_history).
  • GET /api/plans/{id} — one plan with its slices.

Served by the plans HUD domain (internal/hud/domain/plans) via the AgentBridge.Plans/Plan read methods. If the running daemon predates the plan store (the agent_plan_* tools are unknown), the endpoints degrade to {available:false} + an empty list (HTTP 200) rather than erroring, so the view shows a clean "deploy pending" state. The Svelte lifecycle card consumes these endpoints and is verified against live data once the daemon ships the tools.

Convergence: tasks + Mills backlog

The plan is the shared work unit across the platform:

  • Tasks (agent_task_add) accept plan_id/slice_id, so an agent task is a granular TODO under a plan slice (plan → slice → task). Tasks are indexed by plan_id/slice_id for per-plan rollups in the HUD/flexdeck.
  • Mills backlog items carry a plan_id (pkg/mills/store); when set, the Mills agent prompt instructs the spawned agent to resolve the live plan + slices via agent_plan_get{plan_id} instead of re-reading a .loom SpecDoc — so the factory and interactive sessions operate on the same plan.

Backfill (S7b-α, default-off). The Mills operator can author a first-class Plan for every backlog item that has no plan_id yet and stamp the returned id back onto the item, so existing backlog (which predates the store) converges with plans. Set LOOM_MILLS_PLAN_BACKFILL=1 and restart the operator: at boot it runs one best-effort pass (intake.PlanBackfillerclients.PlanClient.AuthorPlanagent_plan_create over the MCP hub), skipping already-linked items. The authored plan id is deterministic (plan-mills-<backlog-id>) so a re-run upserts rather than duplicating. Failures are logged, never fatal — the reconciler proceeds.

Born-linked import (S7b-β, default-off). The GitLab importer can author a Plan for each newly imported item at create time so it is born linked rather than waiting for the next boot backfill. Set LOOM_MILLS_PLAN_AUTHORING=1 (and a reachable MCP hub): GitLabImporter.Tick authors a Plan before the item's first Put, stamping plan_id in one write. Best-effort — an authoring failure leaves the item unlinked and it still imports (the backfill links it later), so plan authoring never blocks intake.

Council authoring plans inline (council.persistOne, the other create site) remains a follow-up (S7b-γ); it shares the same clients.PlanClient + LOOM_MILLS_PLAN_AUTHORING gate.

Configuration

EnvDefaultMeaning
AGENT_CONTEXT_PLANS_COLLECTIONagent_plans_v1Qdrant collection for plans.
AGENT_CONTEXT_PLAN_SLICES_COLLECTIONagent_plan_slices_v1Qdrant collection for slices.
LOOM_MILLS_PLAN_BACKFILL(unset)When set (e.g. 1) and the MCP hub is reachable, the Mills operator runs one boot-time pass authoring Plans for un-linked backlog items (S7b-α). Default-off; best-effort.
LOOM_MILLS_PLAN_AUTHORING(unset)When set (e.g. 1) and the MCP hub is reachable, the GitLab importer authors a Plan for each newly imported item so it is born linked (S7b-β). Default-off; best-effort.

Verifying cross-process reach (kill-test)

The load-bearing assumption — a plan written by one process/agent is retrievable byte-identical by a separate process using only plan_id, with no agent_id — is exercised against the real shared Qdrant:

RUN_PLAN_STORE_IT=1 QDRANT_URL=... QDRANT_API_KEY=... \
  go test ./pkg/agentcontext/ -run TestPlan_KillTest -count=1 -v

This proves the cross-worktree and cross-vendor (Codex) legs, since both are just "another process pointed at the same Qdrant".

Loom Plan Store | Loom Core docs