Status: RATIFIED IMPLEMENTATION PLAN, written 2026-07-30 for the Opus driver.
This document turns execution-order items T8 and T9 plus the FG-2 deliverable
into commit-sized work items with the design decided in advance. The driver
works W1 to W8 in order, one commit each, ./verify.sh green before every
commit, rows marked DONE here with the commit hash. Questions raised mid-item
follow the standing rule in EXECUTION-ORDER.md section 0: pick the
least-surprising option, log it PROPOSED in ../DECISIONS.md, continue.
Verified starting state (2026-07-30): stage_executions and review_rulesets
are orphaned tables no code writes; there is no rulesets repository, no
instrumentation file, no way to set currentStage without a status
transition, and the stage-to-schema map is private to engine-runner.ts.
01-ARCHITECTURE.md promises "completed stages are never re-run". The
pipeline today is one function that seeds the ledger and runs S1 to S6; run it
twice and it would duplicate ledger rows, re-pay for every completed stage,
and duplicate candidate findings. The plan makes resume exact and cheap with
three mechanisms, chosen so runReviewPipeline itself changes as little as
possible:
- Checkpointing at the runner boundary. Every stage request is keyed by
(stage, sha256(prompt)). Prompts are deterministic for a pinned review (same inventory, same batches, same numbering), so the key is stable across attempts. A wrapper around the engine runner consultsstage_executionsbefore delegating: a stored succeeded output for the key is replayed without spawning anything and without re-adding usage; a miss runs live. This works unchanged for batched S3, because each batch is its own request with its own prompt hash. - Idempotent S0. If ledger rows already exist for the review, seeding (recordChangedFiles, recordSweepHits) is skipped. The inventory is deterministic, so the existing rows are the same rows.
- Findings wiped on re-entry. Candidates and their verdicts are derived entirely from stage outputs, which are checkpointed. On resume the service deletes all findings rows for the review and lets the pipeline recreate them by replaying the stored outputs. Hunk and sweep dispositions are idempotent set-operations and need no special handling. This avoids every duplicate-row hazard between the S3 apply step and the S5 apply step, and it is deterministic: the same stored outputs produce the same findings and the same verdicts, with the quotation check re-run against the same pinned worktree.
Chain sessions survive resume because the CLI persists sessions on disk: the wrapper primes the engine runner with the sessionId of the most recent succeeded chained-stage row, so a live S4 after replayed S1-S3 still resumes the original conversation.
Migration 0002 (append-only; databases exist now):
stage_executions: addprompt_hashtext not null default"", addoutput_jsontext null. New index on (review_id,stage,prompt_hash).
New repository src/server/db/repositories/stage-executions.ts:
recordAttempt(db, {reviewId, stage, promptHash, attempt, sessionId, status, usage, outputJson?, errorClass?, errorText?, logPath?})inserting one row; timestamps startedAt/endedAt supplied by the caller.latestSucceeded(db, reviewId, stage, promptHash)returning the newest succeeded row with a non-null outputJson, or undefined.latestChainedSession(db, reviewId)returning the sessionId of the newest succeeded row whose stage is one of s1..s4, for priming resume.listForReview(db, reviewId)ordered by startedAt, for the SSE snapshot and the demo's timeline.
Additions to reviews.ts:
setCurrentStage(db, reviewId, stage | null): a plain column write that never touches status. Today the only path is through a transition, and a stage change mid-runningis not a status change.deleteFindingsForReviewbelongs to findings, not reviews: adddeleteAllForReview(db, reviewId)tofindings.ts(used only by the resume path; the comment must say so, because deleting findings anywhere else would violate the human-gatekeeper rule).
New repository src/server/db/repositories/rulesets.ts (minimal, enough for
T8 and the snapshot FK; the full manager UI is T15):
saveImportedRuleset(db, imported: ImportedRuleset, meta: {name, tier, sourceDoc}) -> {rulesetId, version}: upsert by name; on content change bumpversion; replace rules and directives rows inside one transaction (db.transaction), preserving rulesortOrderfrom the import and the unique (rulesetId, code) constraint.loadRuleset(db, rulesetId) -> ImportedRuleset: inverse mapping, used by the snapshot writer and later by T10.writeReviewSnapshot(db, reviewId, rulesetId): composes the frozen JSON (directives + rules verbatim fields) intoreview_rulesetswith the ruleset name and version.readReviewSnapshot(db, reviewId)parses it back into{directives, rules}through zod. Resume reads the snapshot and never re-imports, which is what makes "editing a ruleset never changes a past review" true.
Export stageSchemaFor(stage) from src/lib/review/stage-schemas.ts and
make engine-runner.ts consume it, deleting its private map. The service
needs the same map to build output contracts, and two copies would drift.
Tests: repository round-trips; saveImportedRuleset bumps version only on
content change; snapshot read-back equals what was written; a second
recordAttempt for the same key increments attempt numbering via
latestSucceeded semantics (assert replay picks the newest).
The service tests and the demo's --fake mode need the fake to answer a
whole pipeline, not one call, and to fail on demand at a chosen point.
Extend tests/fixtures/fake-claude.mjs with one new scenario, script:
FAKE_CLAUDE_DIR: a directory containing001.json,002.json, ... The Nth invocation (counter file inFAKE_CLAUDE_COUNTER, already supported) emitsresultEvent({result: contents of the Nth file}). Missing file: exit 2 with a message naming the call number, so an unexpected extra call fails loudly instead of hanging.FAKE_CLAUDE_FAIL_AT=<n>: on call n, print the usage-limit message to stderr and exit 1 (thelimit-reachedbehaviour), without consuming the file. The next call after a resume gets file n. This is what drives the pause/resume tests deterministically.
New shared helper tests/helpers/ideal-answers.ts: extract the
manifest-driven reviewer logic from quality-gate.test.ts into
buildIdealStageOutputs({files, manifest, worktreeRoot, protocol})
returning the ordered stage outputs (s1, s2, s3, s4, s5) as plain objects,
plus writeAnswersDir(dir, outputs) that materialises them as numbered JSON
files for the fake. The quality gate keeps its in-process runner but builds
its answers through the same helper, so the two cannot drift. The s5 output
needs candidate ids it cannot know in advance; the helper emits verdicts
keyed by (path, lineStart) and a small adapter maps them to findingIds at
runtime for the in-process runner, while the file-based fake path uses the
service test's knowledge that verification prompts embed candidates as JSON:
for the scripted fake, s5's file contains verdicts with a findingId of
"BY_LINE" and the service-side test asserts on outcomes, not ids. Keep
this adapter inside the helper with a comment; it is test plumbing, not
product behaviour.
Tests: a script-mode smoke (three calls, three files, counter advances); fail-at emits the limit error exactly once and then continues the sequence.
New src/server/review/checkpointing-runner.ts:
createCheckpointingRunner({db, reviewId, inner: ReviewEngine['run'], onLiveAttempt}) -> StageRunner.- On each request: compute
promptHash = sha256(stage + "\n" + prompt)(node:crypto). Look uplatestSucceeded; on hit, return{output: JSON.parse(outputJson), sessionId, usage: undefined}and do NOT callonLiveAttempt; replays are free and must not inflate usage. - On miss:
startedAt = now, delegate toinner. The engine runner reports per-attempt usage through its existingonStageComplete; the wrapper is the single writer of rows. On success: one row per reported attempt, the final onesucceededwithoutputJsonand the response usage, earlier onesfailedwith errorClassinvalid_output(they were the repair round's rejected attempts). On thrownStageFailedErrororStageOutputUnreadableError: rows for reported attempts, final rowfailedwith the error's class, message, and logPath. Rethrow. - Resume priming: before the first live chained-stage call, if the engine
runner has no chain session, pass
resumeSessionIdfromlatestChainedSession. Concretely: the wrapper setsrequest.resumeSessionId ??= latestChainedSession(...)for s1..s4 requests. The engine runner already prefers an explicit resumeSessionId.
Tests (fake CLI, json-result and script scenarios): a second identical request replays without invoking the fake (counter file proves no spawn); usage recorded once; a changed prompt is a miss; failed attempts leave rows with the right classes; after simulating a restart (new wrapper over the same db), a chained stage resumes with the recorded sessionId (assert via FAKE_CLAUDE_RECORD argv).
New src/server/review/service.ts. One exported entry point and two
lifecycle helpers; everything else private.
prepareAndRun(db, reviewId, options) where options carries
{claudePath?, signal?, onEvent?, onStageLifecycle?}:
- Load the review; require status
draft,paused_limit, orinterrupted. Transition torunning(setCurrentStage null) via the state machine;paused_limit/interrupted -> runningis already legal. - Resolve projects (primary, linked if
linkedProjectId). Fetch both bare clones (fetchAll), writinglastFetchedAt. This fetch keeps the pinned commits reachable after a prune or a restart; it deliberately does not re-pin (D-28). Re-resolve every pinned commit withresolveCommit; a commit that no longer resolves fails the run with a message naming it. Nothing re-pins: the review describes the commits it was created with. Freshness is guaranteed one step earlier instead, at creation, where the draft pins from refs fetched moments before (D-27); that is the only place a tip may move, and T10 owns it. - Slugs:
repoSlug(project.name)for each side; if equal, suffix the linked one with-dep(deterministic, recorded in a run note). - Ensure worktrees: for each side, if
worktreeRepoDiris missing,addWorktree(clone, dir, pinnedCommit). Present-but-wrong-commit (checked withworktreeCommit) is removed and re-added; a worktree is disposable, the pin is not. - Ensure bundle: rebuild with
buildBundleifinventory.jsonis absent. Deterministic, so rebuild-if-missing is safe. Readlinks.jsonpackage name from the project_links row each time rather than trusting the file. - Ruleset: if
review_rulesetshas no row for this review, the caller must have suppliedoptions.ruleset: {imported, name, tier, sourceDoc}; save it (W1) and write the snapshot. Then, always,readReviewSnapshotand use ONLY the snapshot from here on. - Resume hygiene:
deleteAllForReview(findings)(section 0.3), then run. - Build the engine runner (model from the review row, timeout =
stageTimeoutMinutessetting, default 20 minutes; claudePath fromoptions.claudePath ?? process.env.TRYSQUARE_CLAUDE_PATH; logsDir under the run dir; onEvent forwarded). Wrap with the checkpointing runner.contextWindowcomes from the models registry row for the review's model when its availability is current; otherwise undefined, and the pipeline's no-window behaviour applies. systemPromptFor(stage, batch):composeSystemPromptwith the snapshot's directives,batch?.rules ?? snapshot.rules,includeFullRules: stage === "s3_adversarial", andoutputContractFor(stageSchemaFor(stage)).- Stage lifecycle: the wrapper's live-attempt callback drives
setCurrentStageand, when the stage iss5_verification, therunning -> verifyingtransition (only if stillrunning: a replayed s5 during resume may mean the review is alreadyverifying).onStageLifecycleis also forwarded to the caller for the bus. - Run
runReviewPipeline. On success:assertWorktreeCleanon every worktree, append a run note summarising the result counts, transitionverifying -> awaiting_confirmation, return the PipelineResult. - Failure mapping, in one catch: errorClass
limit-> transition topaused_limitwith pausedReason = the error message plus the reset time when the engine surfaced one;cancelled->cancelled; everything else ->failedwith a run note carrying message and logPath. Usage already accumulated per live attempt viaaddReviewUsagein the wrapper's row writer. Rethrow nothing; return a discriminated outcome{kind: "completed" | "paused" | "cancelled" | "failed", ...}so the manager does not parse exceptions.
removeReviewArtifacts(db, reviewId): remove worktrees through
removeWorktree (both sides), then rm the run directory. Called on the
terminal transitions (complete, cancelled, failed) per D-12, and by delete.
deleteReviewEntirely(db, reviewId): artifacts, then deleteReview row
cascade. Refuses while status is running/verifying (cancel first).
Tests (real git fixtures via build-seeded-repos, fake CLI in script mode, temp TRYSQUARE_DATA): the nine scenarios listed in EXECUTION-ORDER T8/T9 proofs, of which W4 covers: happy path status walk with stage rows and usage; pinned-commit-missing failure; worktree recreated at the pin; worktree-dirty failure; snapshot governs resume (edit the ruleset rows after start, resume, assert the prompt still carries the old text via FAKE_CLAUDE_RECORD of a live stage).
First, the defect W4 found and recorded: a resumed review re-asks the verification stage, because candidates are recreated with fresh ids and the verification prompt embeds them, so its prompt hash moves. Fix it by keying the verification contract on place in the code (path and line range) rather than on a candidate id, which is the shape the fake CLI and the ideal-answers helper already use, and which removes the dependency on id stability altogether. The existing test that records the current behaviour flips to asserting that every stage replays and that a fully resumed run makes no model call at all.
Service-level tests that exercise section 0 end to end, all with
FAKE_CLAUDE_FAIL_AT:
- Limit at S3: run ->
paused_limit, pausedReason contains the CLI's message. Resume: completes; the counter file proves the number of live calls after resume equals the remaining stages only;usageInputTokensequals the sum over live attempts with no double count (fake reports fixed usage per call, so the expected total is computable exactly). - Limit at S5: candidates existed before the pause; resume wipes and recreates them; final finding set (path, line, status) equals a straight-through run's set, field for field.
- Interrupted: force the row to
runningvia transitions, callmarkOrphanedReviewsInterrupted(as instrumentation will), resume, complete. - Cancel: signal aborts a
hangscenario; statuscancelled; artifacts removed per D-12.
New src/server/jobs/bus.ts: a per-review event emitter with types
{kind: "status", status, pausedReason?} | {kind: "stage", stage, phase: "started" | "replayed" | "finished" | "failed", attempt?, usage?} | {kind: "engine", stage, event: "tool-use" | "text", detail} | {kind: "rate-limit", status, resetsAt?} | {kind: "note", note} | {kind: "done", outcome}. Durable facts (status, stage rows, notes, usage)
are persisted by the service and wrapper BEFORE the bus emits them; the bus
carries no state a restart would need.
New src/server/jobs/manager.ts:
- Singleton stored under a
globalThissymbol so Next dev hot reload does not spawn a second manager (const KEY = Symbol.for("trysquare.jobs")). init({db, dataDir}): run migrations,markOrphanedReviewsInterrupted, seedDEFAULT_MODEL_CANDIDATES(registerCandidate is conflict-free), and kick a background non-blocking probe of stale models. Called fromsrc/instrumentation.ts(register()), which is the file Next runs once per server start.start(reviewId, options?): if a review is running, enqueue (in-memory FIFO; on restart the queue is empty and drafts stay drafts, which is acceptable for a local single-user tool and recorded as such); else mark active, create an AbortController, subscribe the service callbacks to the bus, runprepareAndRunon a floating promise with a final.thenthat releases the slot and starts the next queued review. Concurrency cap frommaxConcurrentReviewssetting, default 1.cancel(reviewId): abort the controller; the service maps it tocancelled.resume(reviewId): same as start; the service validates the status.subscribe(reviewId, listener) -> unsubscribe;snapshot(reviewId)assembling review row + stage executions + run notes + finding counts for the SSE hello and the polling fallback.
SSE route src/app/api/reviews/[id]/events/route.ts:
GETreturns aReadableStreamwithContent-Type: text/event-stream. First frame:event: snapshotwithmanager.snapshot(). Then bus events asevent: update. A comment-line heartbeat every 15 seconds keeps proxies from closing the stream.request.signalabort unsubscribes. Runs in the Node runtime (export const runtime = "nodejs").
Tests: manager queue order with two reviews (fake, fast scripts); cancel mid-run; init recovers orphans; bus receives status/stage/done events in order and after the rows exist (subscribe, then read db inside the listener and assert the row is already there); SSE handler invoked directly with a Request whose signal is aborted mid-stream stops cleanly, and its first frame parses as the snapshot.
New scripts/demo-fixture.ts, npm script "demo:fixture": "tsx scripts/demo-fixture.ts".
Step zero of this work item, before anything else: prove tsx resolves the
@/ alias by running a one-line script importing @/lib/ids. If it does
not, the fallback (decided now, not asked later) is a dedicated
tsconfig.scripts.json with the same paths compiled by tsc to
dist-scripts/ and run with plain node; the npm script changes, nothing
else does.
Flags: --model <id> (default claude-haiku-4-5-20251001 per D-18),
--fake (route through the fake CLI in script mode with ideal answers, so
the whole demo is provable free), --keep (do not delete the temp data
dir).
Sequence:
- Temp
TRYSQUARE_DATA; open db;manager.init. - Build seeded repos (tmp); create both projects with
cloneBarefrom the local fixture paths (file URLs are accepted by the validator); link the dependency with packageName@acme/shared-core. - Import
tests/fixtures/example-protocol.md, save as ruleset ("Example protocol", tier global). - Unless
--fake: probe the chosen model; abort with a clear message if unavailable or indeterminate, printing the probe error. Record the context window. - Create the linked review (pinned commits from the clones, model,
profile from the model's registry row or
full-contextfor fable/1m). - Subscribe to the bus and render live: one line per stage (started/replayed/finished, duration, tokens), tool-use lines indented, rate-limit warnings loud, run notes as they land.
manager.startand await the outcome.- Outcome table, computed against the manifest: for each defect, FOUND (verified finding at the file with line within 2) or MISSED; false positives (verified findings in clean files); killed-by-quote-check count; open questions; total tokens, cost-equivalent, wall time.
- Write the table and the full event log to
review/<UTC date>-fg2/(gitignored directory, per repo convention). - Exit code: 0 if the pipeline completed (whatever the model found), 1 on pause/cancel/failure. Finding quality is FG-2's human judgement, not an exit code, and the haiku default is a plumbing smoke, not a quality claim (D-18).
Proof for the commit: npm run demo:fixture -- --fake output captured in
the commit message showing 8/8 found with the ideal answers; a real haiku
run captured into review/ by the driver.
Update PROJECT-STATE.md, mark T8/T9 DONE in EXECUTION-ORDER.md with
hashes, append the decisions below to DECISIONS.md, update GATES.md G1
row evidence with the fake-run numbers, and write the FG-2 checklist for the
maintainer at the top of the demo output directory:
- Run
npm run demo:fixture(haiku smoke; a few cents equivalent). - Run
npm run demo:fixture -- --model "claude-fable-5[1m]"for the quality look. Judge: defects found, wording of findings, false-positive rate, cost, wall time. - Verdict on G1 in
GATES.md: pass, or name what must change. Prompt wording changes go through T18's rule: fake gate first, then re-run.
| W | Contents | Depends on | Status |
|---|---|---|---|
| W1 | migrations 0002-0003, stage-executions and rulesets repositories, setCurrentStage, deleteAllForReview, stageSchemaFor export | - | DONE |
| W2 | fake CLI script mode and FAIL_AT, ideal-answers helper, quality-gate refactor onto it | - | DONE |
| W3 | checkpointing runner and its tests | W1, W2 | DONE |
| W4 | review service, artifact lifecycle, its tests | W1, W2, W3 | DONE |
| W5 | pause/resume/cancel determinism tests | W4 | DONE |
| W6 | bus, manager, instrumentation, SSE route, tests | W4 | DONE |
| W7 | demo script, tsx alias proof, npm script | W2, W6 | DONE |
| W8 | docs, gate evidence, FG-2 checklist | W7 |
- D-21 Resume replays checkpointed stage outputs keyed by
(stage, sha256(prompt))fromstage_executions.output_json; replayed stages add no usage. Findings are wiped and deterministically recreated on re-entry, because every finding is derived from checkpointed outputs and wiping avoids all duplicate-row hazards without touching the pipeline. - D-22 The checkpointing wrapper is the only writer of
stage_executionsrows; the engine runner only reports attempts. One writer, no drift. - D-23 The ruleset snapshot is written at first start and is the only thing a resume reads; the live ruleset tables are never consulted mid-review.
- D-24 The in-memory start queue does not survive a restart: drafts stay drafts and are restarted by the user. A durable queue is not worth its complexity for a local single-user tool; revisit only if FG-3 shows otherwise.
- D-25 The manager singleton lives on a
globalThissymbol so Next dev hot reload cannot create two managers with two queues. - D-26 The demo's exit code reflects pipeline completion, never finding quality. Quality is the founder gate's judgement, made on the fable run.
- tsx alias resolution is unproven for scripts (an earlier attempt failed in /tmp). W7 step zero proves it or falls back to compiled scripts; neither outcome blocks anything else.
- Session resume across a real process restart is assumed to work because the CLI persists sessions; W3's test proves the argv, and the first real FG-2 run proves the behaviour. If the CLI declines a stale session, the engine falls back to a fresh one (the runner already treats resumeSessionId as optional); the cost is a re-derived context, not a wrong result.
- The demo's haiku default may produce a weak review. That is expected and stated in its output header; it is a plumbing smoke (D-18, D-26).