npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-mcp

Or install globally first and invoke the bin entry:

npm install -g @cjlapao/loop-state-mcp
loop-state-mcp

To build and run from source:

npm install
npm run build
node build/index.js

When 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.json contains 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_root that 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_evidencetask_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, not glob, to check whether an agent wrote its deliverables. git-loop adds .projects/loops/ to .gitignore (so git clean -fd cannot destroy loop state), which makes every gitignore-aware glob silently return NOTHING for that directory — while read on an explicit path still works. That combination produces a baffling failure: plan.md reads fine, the tasks/*.md it 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?" before loop_recover(loopSlug) loads one. Active loops only unless includeFinished.

Loop tools

  • loop_init(loopSlug, goal, title?, mode?, defaultBranch?, branchMode?, featureBranch?) — Create a loop: mint a uuid id, store the short slug (folder name) and long title (display; defaults to goal when omitted), and render the initial views. Returns { id, loopSlug, title, loopdir }, or the existing loop with reused: 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 includes id (uuid), slug, and title.
  • 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 ## Phases tracker). Bracket each phase: in-progress before dispatch, done/skipped/failed after.
  • 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's question tool 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 a reminder field 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 a quality block 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. A fail refuses loop_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. dryRun defaults to true. See Retention.
  • loop_delete(loopSlug, confirm) — Delete a loop and all its rows; confirm must 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. The resolution is 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 a depends_on names 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 as dependency 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. Unlike tasks_derive this INSERTs, so reusing an existing taskid is refused by name rather than leaking a raw UNIQUE 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 from issue-open/failed/changes-requested to verified (skipping in-progress). Because in-progress bypasses no gate, if it is the only missing intermediate the call auto-inserts it (recording both hops on the timeline — the auto hop as task_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 RETURN isError, they do not throw, matching every other tool. A depends_on still 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. Reaching complete from anything but merged IS latched: it means the merge never happened.

Now tools

  • now_set(loopSlug, task, agent, step) — Set the ## Now live pointer (task/agent/step) before a dispatch.
  • now_clear(loopSlug) — Clear the ## Now live 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's agent-return checkpoint. Field consistency is enforced server-side: "absent" spellings (""/n/a/none/null) collapse to a single canonical null; well-known outcome synonyms in from/to (completed/finished/success/succeededdone, partial/unfinishedincomplete, stuckblocked) fold to the canonical agent vocabulary (real task statuses like merged/in-review are never rewritten); and omitted task/agent/phase are backfilled from the loop's live now-pointer (now_task/now_agent) and phase — so dispatch/agent-return events stay populated even when the caller drops a field (an explicitly-passed value always wins). The auto-generated task_setStatus/task_complete events likewise stamp the loop's current phase.
  • 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:

  1. status is done/abortedkind: "done"; the loop must not be resumed.
  2. awaiting_user is set → kind: "gate"; re-ask the user, never answer for them.
  3. A task is not complete/skipped/supersededkind: "task" on the first such task, with the ONE next lifecycle step read backwards from its status (securegit-pr via git-loop, pr-openreview via code-reviewer-loop, merged → the orchestrator's own task_complete with no dispatch, blocked → ESCALATE, …). A build step resolves the engineer from the task's tech tag; an unknown tech says ESCALATE rather than naming a substitute.
  4. Every task closed → kind: "phase" on the first open post-build phase (qa → findings-gate → memory → agents-sync → feature-merge).
  5. 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 mustshouldoptional.

  • 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.
  • why is resume-point-specific, not a generic description: discovery.md is a must when resuming at plan (the planner refuses to plan a standard goal without it) and a should otherwise.
  • A missing must-read is still listed, with exists: false — no plan.md at a build resume point is itself the finding, and it also surfaces in warnings.

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.md is rewritten on a task split and again when QA defects become tasks; each write appends rev + 1 and 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 sha256 over the raw bytes — the same shape as loop_archive_file. Markdown compresses 4–5×.
  • loop_readFile reads the database first, falling back to disk for loops that predate this. A loop whose worktree is gone still answers for itself.
  • loop_writeFile writes the row first, then the mirror. A failed mirror write is reported, not fatal — the document is already safe, and loop_materialize writes the files back later.
  • loop_materialize re-creates the mirror in a fresh worktree, for the agents that still read .projects/loops/{slug}/prompt.md by path.
  • loop_archive captures documents from the database, carries every revision inside the state snapshot, and now records NO DOCUMENTS CAPTURED in skipped instead of silently storing zero.
  • loop_delete KEEPS documents unless purgeDocuments is 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 test

License

MIT