@cjlapao/loop-state-mcp
v0.5.2
Published
SQLite-backed, schema-validated MCP server owning dev-loops bookkeeping state.
Readme
@cjlapao/loop-state-mcp
A stdio Model Context Protocol server that owns the mutable bookkeeping state of a
dev-loops run in SQLite and renders the human-readable markdown views after every mutation.
Install & run
This package is a stdio-hosted MCP server: it has no HTTP port, no daemon process, and no lifecycle of its own. It reads/writes the MCP JSON-RPC protocol over stdin/stdout and exits when the host closes the stream. In normal use opencode starts it as a child process for each conversation turn; direct execution is for testing or debugging only.
Preferred — let opencode start it via the config below. If you need to run it standalone (e.g. to inspect diagnostics), simply launch the binary directly:
npx -y @cjlapao/loop-state-mcpOr install globally first and invoke the bin entry:
npm install -g @cjlapao/loop-state-mcp
loop-state-mcpTo build and run from source:
npm install
npm run build
node build/index.jsWhen launched directly, diagnostic output goes to stderr so stdout stays clean for
the MCP protocol; see the ## How it works section for what gets printed.
OpenCode MCP configuration
Add the following to your opencode.json under the mcp key:
"mcp": {
"loop-state": {
"type": "local",
"command": ["npx", "-y", "@cjlapao/loop-state-mcp"],
"enabled": true
}
}Note: this MCP is not pre-registered in any project config on this repository. You must add the block above yourself before using it. The workspace at
opencode-mcp/opencode.jsoncontains an example that pins a specific version (@0.1.6); omit the version pin if you prefer to always pull the latest.
No --root flag is needed. The rendered views always live under <cwd>/.projects/loops,
and the shared database defaults to ~/.dev-loop-state/loops.db (override the directory
with the DEV_LOOP_STATE_DIR environment variable — used by the test suite so it never
touches the real home).
How it works
The MCP is started once by OpenCode. It resolves two locations:
- the project root = the current working directory it was spawned in. This is the
stored
project_rootthat scopes every loop to its project. - the global database directory =
~/.dev-loop-state(or$DEV_LOOP_STATE_DIR).
Loop identity: id / slug / title / goal
A loop's identity is split four ways:
| Field | What it is | Used for |
| ------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| id | A UUID v4, minted at loop_init (crypto.randomUUID()) | The globally-unique, stable handle. The identifier the cross-project browser addresses a loop by. |
| slug | A SHORT (≤64-char) kebab summary, supplied by the caller | The FOLDER NAME (.projects/loops/{slug}/) and the human/agent addressing key that every tool takes. |
| title | A long human-readable string (defaults to goal) | Display only — the # Loop — {title} heading. |
| goal | The raw user goal, verbatim | Recorded as-is. |
Tools address a loop by its slug, never its uuid. Every tool takes loopSlug;
the server resolves (project_root, slug) → the loop row (and its uuid id) internally,
so the orchestrator never has to pass a UUID around. The uuid is surfaced by loop_get
(and by loop.md's frontmatter id:) so a later cross-project browser can identify a
loop unambiguously.
A loop is scoped by the pair (project_root, slug) — the same slug can exist
independently in different projects (each gets its own uuid). Tools still take only
loopSlug; the server derives project_root from its cwd and scopes every read and write
to it.
Data layout
There is ONE global SQLite database, shared by every project on the machine:
~/.dev-loop-state/loops.db. It holds loops from ALL projects (each row keyed by its uuid
id and tagged with its project_root), which is the basis for a future cross-project
visual browser.
The rendered views and plain files stay per-project, in a directory named after the
loop's slug — {project_root}/.projects/loops/{slug}/ — rebuilt after every mutation:
| Path | Contents |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
| ~/.dev-loop-state/loops.db | Single GLOBAL SQLite DB holding loops from every project (scoped by project_root) |
| {project_root}/.projects/loops/<slug>/loop.md | Consolidated loop snapshot (phase, counters, tasks checklist) |
| {project_root}/.projects/loops/<slug>/events.jsonl | Append-only event log (one JSON object per line) |
| {project_root}/.projects/loops/<slug>/memory.md | Dated lessons learned during the loop |
The database is the source of truth; the markdown views are disposable renders rebuilt
after every commit. Use loop_render to force a full re-render if any view drifts.
Creating version (mcp_version)
Each loop records the loop-state-mcp version that created it, captured once at
loop_init from the shipped package.json. It is immutable — set at creation and
never updated afterward, even when a newer MCP re-renders the loop — so it always reflects
the version that generated the loop, which is useful for support and debugging. It surfaces
in loop.md as the - mcp_version: {version} field in the ## Loop block. Loops created before
this field existed render as unknown. Note the distinction from the mcp: key in that file's
frontmatter, which records the version that RENDERED it — the
running server — and is therefore known even for a loop created before the column existed.
Concurrency
Because the DB is now shared across processes, it is opened in WAL journal mode with a
5 s busy-timeout (on both the better-sqlite3 primary driver and the node:sqlite
fallback). Multiple project MCP instances can therefore read and write the one global
database concurrently and safely.
Multi-agent & multi-project usage
Multiple agents can run independent loops in parallel by choosing distinct slugs; and the
same slug can be reused across different projects without collision. Each agent calls
loop_init with its own slug and then operates only on that loop's tools:
Project A (cwd = /work/app-a) Project B (cwd = /work/app-b)
────────────────────────── ──────────────────────────
loop_init( loop_init(
loopSlug="dup", loopSlug="dup",
goal="Implement feature X") goal="Fix bug Y")
→ renders → renders
/work/app-a/.projects/loops/ /work/app-b/.projects/loops/
dup/loop.md dup/loop.md
task_setStatus( task_setStatus(
loopSlug="dup", loopSlug="dup",
taskid="doc-setup", taskid="bugfix-core",
status="in-progress") status="pr-open")Both dup loops coexist in the ONE global loops.db, distinguished by their
project_root; each agent's reads and writes are scoped to its own (project_root, slug).
Within a single project, distinct slugs also coexist — the one-active-loop guard has been
removed so concurrent loops are fully supported.
Which project a loop belongs to
project_root scopes the loop; it does not decide which project owns it. A project is a
repository, and one repository is usually several directories — every git worktree of it is
another place to work in the same codebase. ensureProject resolves the cwd in this order:
| Step | Signal | Why |
| ---- | ---------------------------------------------------------- | ------------------------------------------------------------------------- |
| 1 | project_root alias table — this exact directory | Already known. No git call at all, so the common case costs nothing. |
| 2 | project.remote_key — the normalized origin URL | Identical in a repo and every worktree of it. Survives the repo moving. |
| 3 | project.repo_key — the shared git directory | For a repository with no remote, where step 2 has nothing to match on. |
| 4 | root_folder — the directory itself | The original behaviour, for a plain directory that is not a repository. |
Whatever matches, the cwd is registered as a root of that project, so step 1 answers next time. A match on step 2/3/4 also back-fills the identity onto the project row, so a row written by an older version stops being legacy the first time it is seen.
remote_key is normalized so the spellings of one repository collapse to one key:
[email protected]:Acme/App.git, https://github.com/acme/app, and
ssh://[email protected]/acme/app.git all become github.com/acme/app. Credentials, ports and
trailing slashes are dropped. A file: or plain-path remote yields no key — two people
cloning from /srv/git/app.git are not working on the same repository, and grouping them would
merge unrelated projects. The remote is read with git config --get remote.origin.url rather than
git remote get-url, so a local url.insteadOf rewrite cannot change a repository's identity.
Projects created before this existed keep their own identity — the migration back-fills one alias
and a remote_key per row, but does not silently fold duplicates together, because choosing
which name, icon and tags survive is a decision with taste in it. The API surfaces them via
GET /projects/duplicates and merges on request via POST /projects/:id/merge.
Tools
Every tool accepts loopSlug as its first parameter — this string identifies which loop
to operate on. Required parameters have no ?; optional ones do. (The one exception is
loop_recover, where loopSlug is optional so a session that has lost the slug can still
find its loop.)
Progress guards — the DB refusing to walk away from unfinished work
Validation catches an illegal call. These catch an illegal sequence — the class of failure where
every individual call is legal but the loop has abandoned work. Observed in a real run: task T2
reached in-review, the reviewer's task_setStatus was refused, and the orchestrator declared "T2
complete" and opened an issue for T3. The status machine held (complete is reachable only from
merged, so the DB never recorded it), but nothing stopped T3 from starting, and T2's PR was
never merged.
One task in flight at a time. task_setStatus (when the destination is a working status) and
now_set are refused while a different task sits in issue-open/in-progress/verified/
secure/pr-open/in-review/changes-requested/merged. The error names the stranded task, its
status, and the step it still owes (in-review → "dispatch git-loop (phase=git-merge)"). Advancing
the in-flight task is never blocked — that is how it finishes — and neither is parking another
task (skipped/blocked/superseded), which is how a loop legitimately steps past a dead end.
loop_finish("done") is refused while any task is unfinished, listing them. "aborted" is
always allowed: it is the honest exit, and refusing it would leave a stuck loop no way to close.
A refusal LATCHES. When the MCP rejects a call it records blocked_op/blocked_task/
blocked_reason on the loop and writes a tool-refused event. Until it resolves, other work is
refused with a message pointing back at it. Crucially, calls on the task the refusal named stay
allowed — that is the corrective path, and it is why the latch cannot deadlock a loop that is
genuinely trying to recover. A successful call on that task clears the latch by itself. Reads
(loop_get, loop_recover, loop_list, loop_render), the escalation tools
(loop_setAwaitingUser, event_append, memory_append) and loop_finish("aborted") are never
blocked, so the loop can always reach the user. loop_recover surfaces the latch as a
LOOP BLOCKED warning.
For the case only a human can settle, loop_clearBlock(loopSlug, resolution) lifts it and records
the decision on the timeline — waiving a guard leaves a trace naming what was waived.
Step timing
Every dispatched step is timed, and there is no timing call to make. A scheme built on
step_start/step_end would measure only the steps an agent remembered to bracket — worse than no
data, because the gaps are invisible in the totals. So each write is a side effect of a call the
protocol already mandates:
| Existing call | Side effect |
| --- | --- |
| now_set | close whatever was open, open a new step (with an attempt counter per task+phase) |
| now_clear | close the step, ended_by: 'explicit' |
| event_append(event: 'agent-return') | stamp agent_returned_at + outcome |
| task_complete | end the task clock; close that task's step |
| loop_finish | end the loop clock; close anything still running |
Two clocks. agent_ms (started_at → agent_returned_at) is what the agent itself took;
wall_ms (started_at → ended_at) is that plus the orchestrator's own time before it moved on. The
gap between them is orchestrator overhead. agent_ms stays null when a subagent skips its
checkpoint, rather than quietly reporting a wall-clock number as agent time.
Historical loops are backfilled. Loops that ran before timing existed have their steps
reconstructed from the event log on first open — now_set writes a dispatch carrying the task,
step and agent, and each subagent writes an agent-return, which together bracket every step. What
cannot be recovered is now_clear, so a reconstructed step is closed by the next dispatch and marked
source: "backfill"; its wall_ms is an upper bound, and quality.backfilled counts them. Only
loops with no steps at all are touched, so reconstruction can never interleave with measured data.
Missed calls repair themselves. A step is closed by whatever happens next, so a forgotten
now_clear costs an inflated wall_ms and an ended_by of auto-* — never a lost row or a step
that runs forever. ended_by is what keeps the data honest about which numbers include overhead.
It reports its own reliability. loop_timings returns a quality block: steps closed
explicitly vs. auto-repaired, how many agents skipped their checkpoint, and orphan_returns — an
agent-return that arrived with no open step, which means a dispatch was made without now_set and
the totals under-count. That is currently the only signal anywhere that a dispatch went unrecorded.
Surfaced in four places: loop_timings(loopSlug) (full report), a ## Timings block in loop.md
(closed durations only, so the render stays deterministic — in-flight steps read running), a
one-line summary on loop_audit, and the in-flight step's elapsed time on loop_recover.
The final audit
loop_audit(loopSlug) reconciles the whole loop against its own record. It is meant to run as the
last step before the feature-merge gate, and it closes a gap the progress guards leave open: they
protect task-to-task progression and loop closure, but the feature→main merge sits between those
two. A loop could reach the gate with a task stranded at in-review, bracket the phase, dispatch the
merge — every call legal — and only hit a refusal at loop_finish("done"), long after the merge
landed on the default branch.
Returns { verdict: 'pass' | 'warn' | 'fail', tasks[], findings[], selfCorrectable[], userDecisions[],
summary, guidance }. Each finding carries a stable code, a severity, an owner, and the exact
fix.
Enforcement: a fail verdict makes loop_setPhaseStatus('feature-merge', 'in-progress') return
an error listing the blocking findings. That call is the documented bracket before dispatching
git-feature, so the audit sits in front of the merge without the orchestrator needing any new
discipline. No other phase is gated.
What it checks — tasks: mid-lifecycle at the gate, never started, parked-and-unresolved, skipped
(warn), a placeholder or missing merge SHA, completed with no issue/PR (warn), counters over cap
(warn). Loop: an unresolved refusal block, an unanswered awaiting_user gate, QA that never ran with
no recorded skip, deferred findings with an unasked findings gate, and the non-gating phases (warn).
The merge SHA is read from the timeline, not task.last_evidence — task_complete overwrites
that column with its own mergeEvidence, so a completed task's row no longer holds the SHA at all.
Ownership is deliberately lopsided. selfCorrectable (owner orchestrator) means a state an
agent produced but failed to record, where the fix is mechanical and invents nothing — today only
merged-not-completed, whose fix quotes the SHA already on the timeline. Everything else is
userDecisions. Auto-recovering at this point — re-running a skipped task unattended at the moment
the user believes the loop has finished — is the failure the audit exists to surface, committed one
step later.
Scope: this reads the database, not GitHub. It cannot know whether a PR really merged;
git-loop's completeness backstop (squash commits vs. completed tasks) covers that. The two catch
different classes and neither substitutes for the other.
Document headers (YAML frontmatter)
Every markdown document in a loop directory carries a small YAML header:
---
type: plan
loop: add-oauth
taskid: T3 # task specs only
generated: "2026-08-18T09:12:00.000Z"
updated: "2026-08-18T11:40:00.000Z"
mcp: 0.3.0
---The MCP writes it, never the agent. loop_writeFile stamps the header on every write — a caller
supplies only the document. Every rule an agent must remember is a rule that eventually gets skipped,
and a header that is sometimes wrong is worse than none, so ownership sits where it cannot drift.
loop_readFile strips it back off and returns it as a separate frontmatter field, which also stops
a model reading a file, editing it, and writing the old header back in as body text.
Identity, never state. The header says what the document is. It must never carry status,
phase or a counter — those change constantly, the database owns them, and a stale copy in a file
header is exactly the drift that "DB is truth, views are disposable" exists to prevent.
Why it earns its place: a loop document is routinely read where the database is not — pulled out
of an archive, restored years later, opened in an editor, landing in a git diff. plan.md is
self-evident; T22.md and qa-report-20260814-0912.md are not, and a renamed file is anonymous.
| | Header? |
| --- | --- |
| plan.md, discovery.md, prompt.md, legal.md, security.md, unresolved-pr-comments.md, tasks/*.md, qa-reports | yes — stamped by loop_writeFile |
| loop.md, memory.md | yes — written by the renderer |
| events.jsonl | no — it is line-delimited JSON; a header would break every line parser |
| scratch/* | no — working notes, not documents |
On the rendered files the header replaces the old <!-- loop id: … --> anchors, so one mechanism
states each fact instead of two. Its updated is the loop's own updated_at, not the wall clock:
a render is a pure function of the database, and stamping render time would make identical state
produce different bytes on every call.
The parser is a deliberate YAML subset — flat key: value scalars, no nesting, no lists. No
dependency, nothing surprising to mis-parse, and anything it does not understand is treated as
absent rather than as an error: a malformed header must never make a document unreadable.
Archive — cold storage
A finished loop is still noise: it lengthens every list and buries the runs anyone cares about. But
it is also the only record of what was built and why, and its plain files (plan.md, discovery.md,
the task specs) live outside the database entirely — so "delete the rows" loses the reasoning and
"keep everything" never gets quieter.
loop_archive(loopSlug) resolves that: it captures the loop's rows and every file in its loop
directory as gzipped blobs, then removes it from the working tables. plan.md compresses roughly
5–10×, so an archive is usually a fraction of the directory it captured, and a loop can be browsed —
or restored whole — years later without the original working tree existing at all.
Capture, verify, then destroy. Every stored file is read back and checked against its own sha256 BEFORE the live rows are deleted, all inside one transaction. A verification failure rolls the whole thing back, so a half-written archive can never be paid for with deleted data.
Refuses on an active loop (an orchestrator may be mid-dispatch), and never deletes the loop
directory — archiving copies work, it does not destroy it. Files over 5 MB, a total over 100 MB and
.tmp//node_modules//.git/ are excluded, and every exclusion is NAMED in skipped rather than
silently dropped.
| Tool | Does |
| --- | --- |
| loop_archive(loopSlug) | capture + verify + remove from the working tables |
| archive_list({ projectRoot?, search? }) | headers only, newest first — never inflates a blob |
| archive_get(archiveId) | header + file manifest (path, size, sha256, mtime) |
| archive_readFile(archiveId, path) | one file: text for markdown/JSONL, base64 otherwise, checksum-verified |
| archive_restore(archiveId, { asSlug?, targetDir? }) | rows back to live, files back to disk |
| archive_delete(archiveId, confirm) | permanent; confirm repeats the id |
| db_vacuum() | return freed pages to the filesystem |
Restoring puts everything back into the archive's own project_root, not the current working
directory — archives are global, and a loop from project A reappearing in project B would silently
corrupt both. It refuses if the slug is live again (asSlug overrides), and the archive is kept,
so you can look at a loop and put it back without re-capturing it.
On disk size: archiving shrinks the working set immediately, but SQLite does not return freed
pages to the filesystem — the file only gets smaller after db_vacuum(), and only if the archived
rows outweighed their compressed files. What improves unconditionally is everything that queries the
live tables.
Retention
The database is global and append-only by design — every project's loops, every event, kept. These
two operations are the way data leaves, and both are built to be hard to regret. Neither touches
the filesystem: plan.md, discovery.md and the task specs are the reasoning a loop produced, and
destroying work is not a cleanup operation.
loop_prune(olderThanDays, dryRun?)
Drops the timeline and step history of loops finished longer ago than the window, and keeps the record — the loop row, its tasks, its phase tracker and its lessons all survive. So "what did we ship and what did we learn" outlives "every state transition that got us there".
Only done/aborted loops are eligible; an active loop's history is load-bearing (recovery reads
it, and the step backfill reconstructs from it). dryRun defaults to true, so the first call
always previews and returns the exact candidate list with per-loop event and step counts.
Irreversible once applied: the timings of a pruned loop cannot be reconstructed, because the events they were derived from are what was removed.
loop_delete(loopSlug, confirm)
Removes a loop and every row belonging to it. confirm must repeat the slug — not ceremony, but so
a mistyped slug deletes nothing rather than the wrong loop. Refuses while the loop is active:
abort it first, so the removal follows a decision instead of surprising an orchestrator mid-dispatch.
Exposed over HTTP as DELETE /loops/:id.
Recovery tools
loop_recover(loopSlug?, eventLimit?)— Rebuild an orchestrator's working context for a loop it has no memory of. Call it first in any session that did not start the loop: after a context compaction, after a crash, or when taking over from a session that went rot. Read-only — it mutates nothing, so calling it twice is always safe. See Recovery for the returned shape.loop_files(loopSlug)— The plain files actually on disk in the loop dir (top level +tasks/), with sizes. Use this, notglob, to check whether an agent wrote its deliverables.git-loopadds.projects/loops/to.gitignore(sogit clean -fdcannot destroy loop state), which makes every gitignore-aware glob silently return NOTHING for that directory — whilereadon an explicit path still works. That combination produces a baffling failure:plan.mdreads fine, thetasks/*.mdit points at look missing, and the caller concludes the writer lied. It bites from the SECOND loop in a repo onward, because the ignore is added partway through the first. This tool reads the filesystem and cannot be blinded that way.loop_list(includeFinished?)— List this project's loops (slug, title, phase, status, task rollup,updated_at), newest-touched first. Answers "which loop?" beforeloop_recover(loopSlug)loads one. Active loops only unlessincludeFinished.
Loop tools
loop_init(loopSlug, goal, title?, mode?, defaultBranch?, branchMode?, featureBranch?)— Create a loop: mint a uuidid, store the shortslug(folder name) and longtitle(display; defaults togoalwhen omitted), and render the initial views. Returns{ id, loopSlug, title, loopdir }, or the existing loop withreused: true(its original uuid) if the slug already exists.loop_get(loopSlug)— Return the full structured state (loop + tasks) for the given slug. The loop row includesid(uuid),slug, andtitle.loop_setPhase(loopSlug, phase)— Set the top-level phase pointer.loop_setPhaseStatus(loopSlug, phase, status)— Set the durable status of one loop-level phase (the## Phasestracker). Bracket each phase:in-progressbefore dispatch,done/skipped/failedafter.loop_setAwaitingUser(loopSlug, awaiting)— Set/clear the "needs you" banner at a gate/escalation. Setting it is only half of a gate: it records that the loop is waiting, it does not ask anyone. The other half is the caller'squestiontool call. Because setting the banner and then merely describing the question in chat leaves the UI showing "needs you" for a prompt the user never saw, a SET returns areminderfield saying so; clearing it does not.loop_setField(loopSlug, field, value)— Set a settable field (mode, default_branch, branch_mode, feature_branch, legal, memory_harvest, docs_sync, deferred_gate, qa_report, findings counts).loop_bumpCounter(loopSlug, counter, by?)— Bump a loop-level counter (total_failed_gates, splits_done, qa_rounds). Returns{ value, capExceeded }.loop_finish(loopSlug, status)— Flip the loop to done/aborted and write the final event."done"is REFUSED while any task is not complete/skipped/superseded;"aborted"is always allowed.loop_render(loopSlug)— Force a full re-render of all views from the DB (self-heal / repair).loop_timings(loopSlug)— Per-step durations (agent_ms= what the agent took,wall_ms= that plus orchestrator overhead), per-task and per-phase rollups, slowest steps, anything still running, and aqualityblock saying how much to trust the totals. Recorded automatically — there is no timing call. Read-only. See Step timing.loop_audit(loopSlug)— FINAL CHECK before the feature-merge gate. Reconciles every task against its record plus the loop-level phases; returns a verdict and findings split by who can resolve them. Afailrefusesloop_setPhaseStatus("feature-merge","in-progress"). Read-only. See The final audit.loop_archive(loopSlug)— Capture a finished loop's rows AND files into cold storage, verify every file's checksum, then remove it from the working tables. See Archive.archive_list(...)/archive_get(id)/archive_readFile(id, path)/archive_restore(id, ...)/archive_delete(id, confirm)— browse, read and restore archives.db_vacuum()— Return freed pages to the filesystem. Archiving, pruning and deleting free space inside the file but never shrink it until this runs.loop_prune(olderThanDays, dryRun?)— Drop the timeline + step history of loops finished beyond the window, keeping each loop's row, tasks and lessons.dryRundefaults to true. See Retention.loop_delete(loopSlug, confirm)— Delete a loop and all its rows;confirmmust repeat the slug. Refuses while active. Files on disk are kept. See Retention.loop_clearBlock(loopSlug, resolution)— Lift the refusal block after the USER decided how to proceed. Making the corrective call clears it by itself; use this only when a human decision is needed. Theresolutionis required and is written to the timeline. See Progress guards.
Task tools
tasks_derive(loopSlug, tasks)— Create task rows from an approved plan; render the tasks section of loop.md. Idempotent (upsert by taskid). Refuses the whole batch when adepends_onnames a task that does not exist, a task depends on itself, or a taskid appears twice — each of those produces a task that can never complete, and the failure would otherwise surface much later asdependency X is not complete (status=missing), naming the symptom instead of the plan.tasks_split(loopSlug, taskid, subtasks)— Replace an oversized task with sub-tasks; mark the original superseded.tasks_append(loopSlug, kind, tasks)— Append QAT/cleanup tasks (qa-fix or deferred-findings modes). Continues the task-id sequence. Unliketasks_derivethis INSERTs, so reusing an existing taskid is refused by name rather than leaking a rawUNIQUE constraint failed. Same dependency checks as derive.task_setStatus(loopSlug, taskid, status, evidence?, issue?, pr?, branch?)— Advance one task, validating the transition against the table. Records evidence/issue/pr/branch. Benign work-step tolerance: in a rework/fix/debug re-entry the orchestrator often records a re-verify straight fromissue-open/failed/changes-requestedtoverified(skippingin-progress). Becausein-progressbypasses no gate, if it is the only missing intermediate the call auto-inserts it (recording both hops on the timeline — the auto hop astask_setStatus:auto) instead of rejecting. Any skip that would jump a real gate (verify/security/review/merge — e.g.secure -> complete) is still rejected with the shortest-path hint.task_bumpCounter(loopSlug, taskid, counter, by?)— Bump retries/security_rounds/review_rounds. Returns{ value, capExceeded }.task_complete(loopSlug, taskid, mergeEvidence)— Atomic completion op: set status=complete, flip the checkbox, append the merge event — one transaction, one render. Refusals RETURNisError, they do not throw, matching every other tool. Adepends_onstill incomplete is refused and deliberately NOT latched — the fix is to finish the dependency, and a latch scoped to this task would refuse exactly that. Reachingcompletefrom anything butmergedIS latched: it means the merge never happened.
Now tools
now_set(loopSlug, task, agent, step)— Set the## Nowlive pointer (task/agent/step) before a dispatch.now_clear(loopSlug)— Clear the## Nowlive pointer after a dispatch resolves.
Misc tools
event_append(loopSlug, task?, phase?, from?, to?, agent?, event, note?)— Append an explicit transition (dispatch/gate/escalate) not implied by a status change, or an agent'sagent-returncheckpoint. Field consistency is enforced server-side: "absent" spellings (""/n/a/none/null) collapse to a single canonicalnull; well-known outcome synonyms infrom/to(completed/finished/success/succeeded→done,partial/unfinished→incomplete,stuck→blocked) fold to the canonical agent vocabulary (real task statuses likemerged/in-revieware never rewritten); and omittedtask/agent/phaseare backfilled from the loop's live now-pointer (now_task/now_agent) andphase— sodispatch/agent-returnevents stay populated even when the caller drops a field (an explicitly-passed value always wins). The auto-generatedtask_setStatus/task_completeevents likewise stamp the loop's currentphase.memory_append(loopSlug, agent, taskid?, note)— Append a lesson. Called by every loop agent (memory is now MCP-owned).
Recovery
A loop outlives the session that runs it. When an orchestrator's context is compacted, its process dies mid-dispatch, or it simply drifts off-protocol and has to be restarted, the work is intact — the DB has every transition and the loop dir has every deliverable — but the new session knows only its cwd.
loop_get is not enough to restart from: it returns rows, not a resume point, and says nothing
about the ## Phases tracker, the timeline, or what is on disk. loop_recover derives all of
it, so recovery is a single read instead of a glob, a guess, and a re-run phase.
It never mutates anything. A confused agent calling it repeatedly cannot make its situation worse.
What it returns
| Field | What it carries |
|---|---|
| loop | the full loop row (phase, status, mode, branch fields, counters, rollups) |
| phases | the ## Phases tracker, in ladder order |
| tasks | every task with status, counters, evidence, issue/pr/branch |
| now | the ## Now pointer plus stale: true when a dispatch was in flight when the session died |
| resume | the derived resume point — { kind, point, taskid, agent, why, nextAction } |
| context | the prioritized file manifest — what to read, and why, before dispatching |
| recentEvents | the timeline tail (default 25, oldest-first) — how the old session actually died |
| warnings | everything off-nominal: stale pointer, open gate, blocked/failed tasks, exceeded caps, interrupted phases, missing must-read files, a missing loop dir, version drift |
| protocol | the short list of rules a recovering orchestrator drops first |
The resume point
Derived from the DB alone, in strict precedence order:
statusisdone/aborted→kind: "done"; the loop must not be resumed.awaiting_useris set →kind: "gate"; re-ask the user, never answer for them.- A task is not
complete/skipped/superseded→kind: "task"on the first such task, with the ONE next lifecycle step read backwards from its status (secure→git-prviagit-loop,pr-open→reviewviacode-reviewer-loop,merged→ the orchestrator's owntask_completewith no dispatch,blocked→ ESCALATE, …). Abuildstep resolves the engineer from the task'stechtag; an unknown tech says ESCALATE rather than naming a substitute. - Every task closed →
kind: "phase"on the first open post-build phase (qa → findings-gate → memory → agents-sync → feature-merge). - No tasks at all →
kind: "phase"on the first open early phase (clarify → discovery → legal → plan).
An in-progress phase counts as open, never as finished: it was interrupted mid-flight, and
assuming otherwise is exactly how a recovery silently skips a gate. The bundle says so explicitly.
The context manifest
Entries are { path, exists, bytes, priority, why }, sorted must → should → optional.
- Paths are project-root-relative, always (
.projects/loops/{slug}/plan.md). The MCP never hands back an absolute path, so nothing can reintroduce the/home/<user>drift that slug-addressing removed. - Manifest, not content. The recovering agent reads the files itself, in its own order, and only what it needs — the server never pushes file bodies into the context that is already scarce.
whyis resume-point-specific, not a generic description:discovery.mdis amustwhen resuming atplan(the planner refuses to plan a standard goal without it) and ashouldotherwise.- A missing must-read is still listed, with
exists: false— noplan.mdat a build resume point is itself the finding, and it also surfaces inwarnings.
Finding the loop
Omit loopSlug and recovery resolves it:
- exactly one active loop in this project → recovered outright;
- several →
{ recovered: false, reason: "ambiguous", candidates, guidance }— the orchestrator must ESCALATE and let the user pick, because resuming the wrong loop orphans the other's work; - none →
{ recovered: false, reason: "no-active-loop", candidates, guidance }, listing every loop including finished ones, so "I'm in the wrong cwd" and "this loop is already done" are distinguishable.
An explicit slug that does not exist is an error (loop not found: {slug}) — the same message
loop_get returns, so it lands in the caller's existing hard-stop branch.
Reference
Loop phases
Accepted by loop_setPhase. Reflects the dev-loop-protocol stage gate.
| Phase | Meaning |
| --------------- | -------------------------------------------------------------------- |
| preflight | Initial setup; goal recorded, no work dispatched yet |
| clarify | Intent-clarify stage; confirm the goal and constraints with the user |
| discovery | Investigating scope; reading code, researching constraints |
| legal | License / security / privacy review gate |
| plan | Approved plan written; tasks derived |
| gate | Pre-build approval checkpoint |
| build | Implementation in progress |
| qa | Testing and quality gates |
| findings-gate | Review QA findings; decide fix vs defer |
| memory | Harvest lessons into memory.md |
| feature-gate | Post-QAT feature approval checkpoint |
| done | Loop completed successfully |
| blocked | Loop cannot proceed without external input |
Loop phase-status tracker
The ## Phases block in loop.md is a durable per-phase ladder, one row per
loop-level phase, seeded as pending at loop_init. Set each phase's status with
loop_setPhaseStatus(loopSlug, phase, status): bracket every phase in-progress
before dispatch and done/skipped/failed after. This is the loop-level phase
ladder from the dev-loop-protocol — a superset of the loop_setPhase pointer enum
above; it additionally tracks agents-sync and feature-merge, which are not
top-level phase-pointer values.
Accepted by loop_setPhaseStatus.phase (rendered in this order):
| Phase | Meaning |
| --------------- | ---------------------------------------- |
| clarify | Intent-clarify stage |
| discovery | Scope investigation |
| legal | License / security / privacy review gate |
| plan | Plan approved; tasks derived |
| build | Implementation |
| qa | Testing and quality gates |
| findings-gate | Review QA findings; decide fix vs defer |
| memory | Harvest lessons into memory.md |
| agents-sync | Sync agent definitions / instructions |
| feature-merge | Merge the feature branch |
Accepted by loop_setPhaseStatus.status:
| Status | Meaning |
| ------------- | --------------------------------------- |
| pending | Not started (seed value) |
| in-progress | Set before dispatching the phase |
| done | Phase completed |
| skipped | Intentionally not run |
| failed | Phase failed |
| blocked | Cannot proceed without external input |
| not-needed | Not applicable to this loop |
| left-open | Deliberately deferred / left unresolved |
Loop finish status
Accepted by loop_finish.
| Status | Effect |
| --------- | -------------------------------------- |
| done | Sets status=done, phase=done |
| aborted | Sets status=aborted, phase=blocked |
Loop settable fields
Accepted by loop_setField.field. Value may be a string, number, or null.
| Field | Purpose |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| mode | Execution mode (simple/standard/poc) — the value is validated; see Loop modes |
| default_branch | Default branch name (e.g. main) |
| branch_mode | Branch strategy (new-feature-branch/existing-worktree) |
| feature_branch | Pre-existing worktree branch to target |
| legal | Legal review outcome / sign-off reference |
| memory_harvest | Summary of lessons captured |
| docs_sync | Documentation sync status |
| deferred_gate | Notes on a deferred gate decision |
| qa_report | QA report reference or summary |
| f_unresolved_pr | Count of unresolved PR comments (integer) |
| f_security_deferred | Count of deferred security findings (integer) |
Loop counters
Accepted by loop_bumpCounter.counter. The by parameter defaults to 1. Only
qa_rounds is capped (cap = 2); capExceeded is returned when exceeded (per the
dev-loop-protocol: qa_rounds > 2 escalates).
| Counter | Cap | Meaning |
| -------------------- | --- | ------------------------------- |
| total_failed_gates | — | Number of gates that failed |
| splits_done | — | Number of task splits performed |
| qa_rounds | 2 | Number of QA iterations |
Task counters
Accepted by task_bumpCounter.counter. The by parameter defaults to 1. All three
are capped at 2; capExceeded is returned when the cap is exceeded (signals escalation).
| Counter | Cap | Meaning |
| ----------------- | --- | --------------------------------- |
| retries | 2 | Implementation retries for a task |
| security_rounds | 2 | Security review rounds |
| review_rounds | 2 | Code review rounds |
Loop documents
A loop's deliverables — prompt.md, discovery.md, plan.md, tasks/*.md, security.md,
qa-report-*.md — are stored as rows in loop_document at the moment an agent writes them, and
mirrored into {project_root}/.projects/loops/{slug}/.
The row is the record; the file is a mirror. The loop directory is gitignored and, in the
worktree-per-loop workflow, deleted the moment the branch merges — so the reasoning behind a change
used to survive exactly as long as the worktree that produced it. loop_archive could not rescue
it either: it captured from that same directory, and a loop whose worktree was already gone
archived successfully with file_count: 0.
- Revisions are append-only.
plan.mdis rewritten on a task split and again when QA defects become tasks; each write appendsrev + 1and reads take the head. A write whose content hashes identically to the head creates no revision, so the numbers keep meaning "this changed". - Content is gzipped, with
sha256over the raw bytes — the same shape asloop_archive_file. Markdown compresses 4–5×. loop_readFilereads the database first, falling back to disk for loops that predate this. A loop whose worktree is gone still answers for itself.loop_writeFilewrites the row first, then the mirror. A failed mirror write is reported, not fatal — the document is already safe, andloop_materializewrites the files back later.loop_materializere-creates the mirror in a fresh worktree, for the agents that still read.projects/loops/{slug}/prompt.mdby path.loop_archivecaptures documents from the database, carries every revision inside the state snapshot, and now recordsNO DOCUMENTS CAPTUREDinskippedinstead of silently storing zero.loop_deleteKEEPS documents unlesspurgeDocumentsis passed. Deleting state rows is a cleanup; deleting the record of why the work was done is not.
Project key — finding a loop after its worktree is gone
project_root is the cwd the MCP was spawned in, which in a worktree is the worktree's path. Once
that directory is removed, matching on it alone makes the loop invisible from the main checkout
even though every row survives.
loop.project_key is the REPOSITORY's identity, resolved at loop_init from the origin remote
(normalised, so git@/https:///.git spellings collapse to one value) or, with no remote, from
git rev-parse --git-common-dir — which resolves to the main repository's git directory from
inside any linked worktree. loop_list and loop_recover match project_root OR project_key,
so worktree loops keep showing up where someone would look for them. Loops created before the key
existed have it NULL and are still found by path.
Such a loop is reported with detached: true and its original project_root.
Loop modes
Set at loop_init(mode) or via loop_setField(loopSlug, "mode", …). Unlike every other
settable field, this one is validated — the transition table depends on it, and an
unrecognised value would silently fall back to the strict ladder.
| Mode | Effect |
| ---------- | --------------------------------------------------------------------------------------------------------- |
| standard | Default. The full PR lifecycle: complete is reachable only from merged. |
| simple | Same transition table as standard. The difference is orchestrator behaviour (discovery is skipped), not enforced here. |
| poc | Adds the edge secure → complete, so a task can finish on a local commit with no issue, PR, review or merge. |
What poc does NOT relax. secure is still reachable only through verified, and both
still require non-empty evidence, so a POC task cannot complete without real verification and
security proof — only the PR rungs are gone. task_complete still gates on depends_on, and the
PR states remain reachable for a poc loop that does open one. The overlay lives in
POC_TRANSITION_OVERRIDES (src/validation.ts); transitionsFor(mode) returns the strict table
by identity for every other mode.
loop_audit reports missing-issue and missing-pr as warnings, not blockers, so a
completed POC task does not fail the audit for artifacts it never had.
Task statuses
Accepted by task_setStatus.status. Transitions are validated against the allowed
transition table for the loop's mode (see source: validation.ts).
| Status | Meaning |
| ------------------- | -------------------------------------------- |
| pending | Task created but not yet started |
| issue-open | Tracking issue opened |
| in-progress | Work actively underway |
| verified | Local verification passed |
| secure | Security check passed |
| pr-open | Pull request opened |
| in-review | PR under review |
| changes-requested | Review feedback received; needs work |
| merged | PR merged; terminal pre-complete |
| complete | Fully done; reached via task_complete only |
| failed | Task failed and is blocked |
| blocked | Cannot proceed without external input |
| skipped | Intentionally not done |
| superseded | Replaced by sub-tasks via tasks_split |
Append task kinds
Accepted by tasks_append.kind.
| Kind | Meaning |
| ------------------- | -------------------------------------- |
| qa-fix | Fix task generated from QA findings |
| deferred-findings | Findings carried forward to next cycle |
Development
From the package directory:
npm install # install dependencies
npm run build # compile TypeScript → build/
npm run typecheck # type-check without emitting
npm run lint # eslint src and test
npm run format # prettier --write
npm test # vitest run (unit + integration + golden)To regenerate golden fixtures after intentional renderer changes:
UPDATE_GOLDEN=1 npm testLicense
MIT
