Skip to content

lithos-loom develop converge — reference

Pulled from the lithos-loom repo

This page is imported at build time from docs/cli/converge.md on main. Edit it there, not here.

Converge an existing PR to review-green. converge runs the story-develop reviewer panel + deterministic gate against a PR, and if that blocks, runs a coder fix loop on the PR branch — implement→review→fix rounds until the panel LGTMs and the gate floor is clean — then fast-forward-pushes the fixed branch back to the PR head, ready for the human merge gate.

lithos-loom develop converge <pr> [flags]

This automates the operator's manual chore — take a review, hand it to the coder, tell the panel to look again, iterate until every reviewer is satisfied — as one command. It realises ADR 0003 §9 "Shape 1" (re-dispatch development on the PR branch without resolving the pr gate) as the on-demand / human-triggered variant. See ADR 0009 and SPECIFICATION.md §4.15a.

converge does not re-implement the develop loop: it runs an intake review (the same primitive develop review runs), then calls develop() with a LoopEntry override so the coder/panel/gate/dispute/stall/termination sequencing is single-sourced with story-develop. Round 1 is a cold-start turn that reconstructs the PR author's intent before changing anything.


TL;DR

# Converge a PR with the standard panel, then push the fixed branch
lithos-loom develop converge #142

# Thorough panel, codex as the coder, cap the loop at 4 rounds
lithos-loom develop converge #142 --profile thorough --coder codex --max-rounds 4

# Converge locally without pushing (inspect first)
lithos-loom develop converge #142 --no-push

# Supply the intent explicitly (overrides the PR body) and save the JSON summary
lithos-loom develop converge #142 --ac "the leak must close the handle on error" --json /tmp/c.json

What it does

  1. Resolves the PR to a base..head pair, the pushable head branch, a fork flag and a merged flag (via the typed GitHub client — the same seam develop review uses). Two PRs are refused here, before any container runs. A merged PR → merged: it has already landed, so there is nothing to converge and no fix commit pushed to its branch could reach the base. (Reviewing a merged PR stays legitimate — develop review does not refuse one; only converge, which pushes, does.) A fork PR → fork_unsupported: loom pushes under origin credentials and cannot push to a fork. The merged check runs first — "push it from your fork" is useless advice for a PR that already merged.
  2. Intake review. Runs the resolved profile's deterministic gate + the reviewer panel once at the PR head, under a distinct run_id (<run_id>-intake) so its round-1 artifacts never collide with the fix loop's. If the panel is incomplete (interrupted / invalid) → failed (nothing trustworthy to seed the loop from). If the intake spend alone meets --max-cost → failed (checked before the clean/blocking split, so a clean intake can't bypass the budget). If it does not block → already_clean: no coder is built, nothing is pushed, exit 0 — this reports on the PR snapshot resolved before intake, not a live re-check, and is the cheapest path for the common re-check.
  3. Fix loop. If the intake blocks, enters develop() on a committable worktree at the PR head (base = the PR merge-base, tracked against the PR's live base ref so a base merge during the run moves the diff base with it — PRD S5c), seeded from the intake findings + the PR's own commit log. Round 1's coder is a cold-start turn: "you are picking up a PR you did not author — read the acceptance criteria, the commit history, and the code to reconstruct intent, then address the findings to satisfy that intent; dispute (don't silently comply with) a finding that undoes a deliberate decision." Rounds ≥2 are the normal coder_fix path. Termination is develop()'s own — approved / disputed / stalled / cost_exceeded / max_rounds. Story-develop's cheap needs-decision escalation (SPECIFICATION §5.5) is deliberately not part of it: it ends in a loom human gate on the story whose brief is the coder's question, and converge has no such surface — every unapproved run flattens to not_converged, which is what both watcher consumers branch on. Converge therefore leaves that paragraph out of the coder's prompt and builds its ledgers with the escape off, so a needs-decision mark is recorded as the ordinary disputed it also is and the two-round dispute guard bounds it.
  4. Push epilogue. On approval (and unless --no-push), pushes the fixed branch onto the PR head ref only if the PR head is still exactly the resolved head — an atomic lease (git push --force-with-lease=<ref>:<expected>) plus a local append-only ancestry check. A head deleted, advanced, or force-rewound mid-run is refused as merge_race (never silently recreated or overwritten), while a successful update stays a pure fast-forward — not a blind --force. A fork ref (absent on origin) is refused; auth / hook / branch-protection failures stay generic errors.

Three intake modes

Default (local panel). converge converges against loom's in-container codex/claude panel + check-floor — fast, local, no GitHub round-trip. The intake reviewers are cold by design (no coder-summary to anchor on); only the fixer is given the PR's intent.

--from-github (external findings — PRD S2 slice B). Instead of the local-panel intake, converge ingests the PR's external review material — reviews, inline comments and Conversation-tab comments left by bots or humans (the conversation is the only channel open to the PR's own author, so the operator's verdicts on loom-delivered PRs arrive there — #353):

  • Trust line (ADR 0011 d8): allowlisted bot logins ([github_watcher] trusted_bots) and humans with write/admin on the repo may seed the coder. Everyone else's findings are printed (untrusted (reported only)) and never placed on a prompt path. Roots already proven handled by an authenticated Fixed in <sha> reply are skipped.
  • Triage (S5a): one cheap read-only container turn checks each claim against the code before any fixing. It may REJECT only with cited evidence — a file:line citation resolving to a real tracked file in the repo at the reviewed commit (a bare filename, dotted version token, or HTTP:404-style code is not one; line-existence and content are deliberately NOT checked — semantic triage quality is the S8 eval fixtures' job, not the parser's); anything short of that — including a failed triage turn — proceeds (actioning a false positive is recoverable; suppressing a true one is not). All findings rejected → status triage_rejected, exit 0, no coder built (reported, not remediated: the watcher refunds the S5b round once per budget, as for already_clean). A third verdict, NOTHING_TO_REMEDIATE, is for material that is not a claim at all — a pure approval ("No findings. Ready to merge."), which reviewers leave on the same channels they review on. It cites nothing (there is nothing to refute) but must state a reason — that line is what the operator reads, so a bare NOTHING_TO_REMEDIATE proceeds like any other unevidenced verdict — and it is honoured only for an eligible row: one whose own body carries recognised approving words in the author's own voice and which is not a blocking CHANGES_REQUESTED review (decided in code from the batch, not from the verdict sentence, so a claim carrying no approval — "this leaks the token" — can never be dropped as one, and neither can one whose only approval word sits in a block quote, a code span or fence, an indented code block, a strike-through, an invisible HTML comment, an enumeration of values (a list, or a GFM table recognised by its delimiter row), a dotted identifier named in prose, the lazy continuation a quote, a list item or a table swallows, or the far side of a hard wrap — the floor reads the paragraph GitHub renders, not the author's line breaks). It injects nothing and answers no thread; a batch whose every finding is one ends the run already_clean at round 0 — the #380 outcome without the coder turn and panel pass remediation run 827cedf8 spent to learn what the comment already said. It is not a cheap REJECT: an approval that also asks ("LGTM, but rename X") PROCEEDs on its ask, and evals/triage's approval-and-ask fixture measures both halves (a must-proceed answered this way scores as over-suppression).
  • Injection: surviving findings seed the coder directly via the same LoopEntry seam, bypassing the local intake — dispatching converge on an external finding without injection would return already_clean from the very panel that missed the defect (ADR 0011 d1/d7). The loop's own panel and check-set then judge the result: the external reviewer proposes, loom's gate disposes.
  • Thread replies: after the run, each comment-backed finding gets a reply — Fixed in <sha> only when the coder explicitly acknowledged that finding as FIXED in its FINAL handoff's mandated ## External findings section, the loop approved, the final tree differs from the PR head outside the project's generated paths, and the branch was actually pushed (loop approval alone is tree-level evidence, so an id the coder omits stays unaddressed and its thread gets no reply — a silent partial fix can never earn a false claim; the acknowledgement is mandated in EVERY coder round and the verdicts describe the TREE, not the round's work — FIXED = a fix is in the tree, whichever round made it; NO CHANGE NEEDED = never a defect, nothing was ever changed for it; REVERTED = a fix undone — and the epilogue reads every round's: a decisive final ack (FIXED / REVERTED / DISPUTED) stands on its own, so a fix a later round undid is REVERTED — #387, lens #84's false Fixed in over a net no-op — while a final NO CHANGE NEEDED over round 1's FIXED restated unbroken through every round between is read as round 1's FIXED (round 1 predates the panel, so only its section is trusted to speak of the external ids — a later round's f-001 line may describe the panel's own f-001, as lens #87's rounds 2-3 did), any other disagreement — a later-round origin, a REVERTED / DISPUTED / omission in between — is unaddressed (no thread answered, a false Fixed in being the one unacceptable outcome), the reading recorded in the outcome's note (#399, lens #87's false Not changed over a round-1 fix); an id the final handoff omits stays unaddressed whatever an earlier round said; an approved run whose final tree equals the PR head outside the generated paths is read as reverted whatever the handoff says — answered Not fixed — a change was made and then reverted: <why> naming the operator decision, the run not succeeded, exit 1, and — under the watcher — a disputed human gate), Not changed — triage: <evidence> for rejections, the coder's reasoning for disputes, Not changed — no change needed: <why> for a finding the coder judged not a defect. Summary-only reviews (no inline comment) have no thread and appear in the rendered summary instead. A conversation-comment finding is answered with a conversation comment ending in _(replying to <comment url>)_; a landed Fixed in <sha> one by a write/admin author proves that comment handled on later fetches and sweeps, exactly like an inline thread reply.
  • A finding written against an older head sha is injected with a re-anchor note (verify before re-fixing); severities enter at minor (external reviewers state none).

--resolve-conflicts (conflict convergence — PRD S5, the CLI half). The merge is the intake. In a throwaway worktree at the PR head, converge merges the base's current tip with --no-commit; a clean merge (or a head that already contains the tip) exits no_conflict at once — no agent runs, the worktree is removed, the base-move re-gate (merge-gate) owns that case. Text conflicts only: a conflicted path that is binary, a symlink, a submodule, deleted on one side (modify/delete) or otherwise carries no textual markers is refused at intake — conflict_unsupported, exit 1, the paths and reasons named, no agent run, worktree removed — because nothing could distinguish a coder's resolution of such a path from an untouched one, and the round commit's git add -A would silently take whichever side git left in the tree (the coder cannot stage: the sandbox's git dir is read-only). A text conflict is left in progress (MERGE_HEAD set, the auto-merged paths staged, the conflicted paths carrying markers) and round 1's coder gets resolve_coder_init.md with a brief: the conflicted paths, the PR's title + body, the commits landed on the base since the merge-base, and the conflicted hunks (bounded per path). The coder resolves the marked files honouring both sides (a generated file is regenerated, never hand-merged) and never touches git state; the round commit then is the merge commit (two parents: the PR head, then the base tip). A pre-commit guard is the host-side enforcement behind that rule: it refuses a tree still carrying markers, any unmerged index entry outside the text conflicts the coder was given, a merge in progress of anything but the intended base tip onto the PR head, and — once HEAD has moved — any tree the intended base is not an ancestor of; the round fails and nothing is gated, reviewed or pushed. The project's check-set and the panel then judge the composed tree: the fork point moves to the base tip once the merge commit exists (S5c), so the reviewers' diff shows the PR's own changes on the new base — and because a conflicted path resolved to the base's version is absent from that diff, every round's reviewer prompt also carries a merge context naming the conflicted paths and both parents with a per-side diff to run (git diff <pr head> HEAD -- <paths> / git diff <base tip> HEAD -- <paths>), so a resolution that silently dropped the PR's behaviour is inspectable. An approved run pushes the merge commit onto the PR branch (append-only, ancestry-proved) after one more proof that the intended base is an ancestor of the approved HEAD (a loop that approved a tree without the merge is failed, nothing pushed); an unapproved one is not_converged like any other. --base <ref|sha> names the merge target instead of the PR's base (an explicit sha merges that exact commit). Exclusive with --from-github. The JSON summary carries conflict: {paths, base_ref, base_sha} (null on the other modes). The watcher half (SPECIFICATION §2.2 "Conflict resolver") runs this mode autonomously on a delivered PR whose base-move re-gate recorded a conflict — once per sha pair (reserved on the gate before the spawn, pinned with --expect-head / --expect-base), [ConflictResolved] on a pushed merge commit, a needs-human gate otherwise; host dial [github_watcher] conflict_resolve_enabled, per-project opt-out develop_conflict_resolve = false.

Acceptance criteria (the reviewer's + fixer's brief)

Precedence: --ac-file > --ac > the PR body. A PR with no body and no --ac / --ac-file is rejected — a reviewer with no criteria is near-useless. converge requires a PR, so a bare range / branch is rejected up front (use develop review for a read-only review of an arbitrary range).

Flags

Flag Meaning
<pr> The PR to converge (positional): #142 / 142 / a PR URL. A range / branch is rejected.
-p, --profile Review profile — selects the persona panel + check-set (default standard).
--reviewer NAME Override the panel personas (repeatable).
--ac TEXT Acceptance criteria text.
--ac-file PATH Read acceptance criteria from a file (wins over --ac).
--base REF Override the diff base (default: the PR merge-base); with --resolve-conflicts, the merge target (a sha merges that exact commit).
--check-command NAME=CMD Override a gate check's command (repeatable), e.g. --check-command typecheck='make typecheck'. Runs the repo's own command verbatim — raw exit code, no uv-wrap, no tool-probe, and no finding-adapter rewriting even for ruff/bandit/pip-audit (so an overridden check opts out of structured findings) — instead of the catalog default that over-scopes vs the repo's real policy and forces extra fix rounds (bare uv run pyright scanning a test tree full of pre-existing type debt). Overridable: lint / typecheck / sast / dep-audit / coverage / semgrep (the test check uses --test-command; format is the autoformat pass). An unknown / non-overridable check fails closed.
--check-state NAME=STATE Override a gate check's blocking state (repeatable): required / informational / off, e.g. --check-state sast=off. Beside --story, merges per key over the story's develop_check_states table (never a whole-table replace); --check-command likewise over develop_check_commands. off drops the check cleanly (runs nothing, records nothing) — distinct from a required-but-tool-absent check, which still blocks. Generalizes --no-test-gate (--check-state test=off). Stateable: lint / typecheck / test / sast / dep-audit / coverage / semgrep (format is the autoformat pass). Wins over the legacy test_gate for the test check.
--expect-head SHA Refuse before anything runs unless the PR's current head is this sha — an autonomous caller pins the run to the head it authorised (PR #366); with --json a mismatch writes {status: "head_moved", expected_head, actual_head} and exits 0 (nothing ran; the caller re-keys).
--expect-base SHA With --resolve-conflicts: refuse before any agent runs unless the base's current tip is this sha (base_moved, exit 0). Together with --expect-head the run can neither spend nor push on a pair other than the one the watcher keyed on.
--expect-repo OWNER/NAME Refuse to act unless the checkout's origin is this repository — compared before any fetch; a mismatch exits 2. A PR number resolves against the checkout, so the watcher's remediation dispatcher pins every run to the repo its gate names (after its own cheap origin read, which refuses a mis-mapped or unresolvable checkout before any budget round is spent). With --json, a refusal writes {status: "repo_mismatch", expected_repo, actual_repo, message} so the dispatcher refunds the round and re-parks the review trigger.
--test-command CMD Command for the test gate check (overrides auto-detection). The test check has bespoke detection, so it takes this dedicated flag rather than --check-command.
--parity-command CMD The repo's aggregate verification command (e.g. make check), run once as a required repo-parity gate check so the converged tree passes whatever CI enforces beyond the structured check-set (diagram/codegen drift, docs lint, …) — closing "gate-green ≠ CI-green". Runs verbatim (raw exit, no adapter) regardless of ecosystem — the primary gate for a repo the catalog can't model (C/C++). See ADR 0010.
--generated-path PATH Repo-relative path whose content is generated (repeatable; PRD S4). In --resolve-conflicts mode a conflict in these paths is not the coder's: it is resolved to the base's copy at intake and set aside, the coder is handed the real conflicts only, the brief names the paths and the command to run before handing off, and a post-commit pass regenerates them after every round commit (after the format pass) as a story-develop r<n>: regenerate commit — deterministic, whatever the coder ran; the panel's merge context names them as generated, not hand-merged. The pass fails closed: the generator's verdict is a required regenerate check in the round's gate (a red run — non-zero exit or timeout — holds approval, reaches the next coder prompt with its output and is named in the outcome's blocking checks, since the committed copies are the intake's and a project need not have a drift check), — a green run whose output cannot be applied (a path git refuses, a file that became a directory) counts as the generator's red — and a pass that never gets a verdict (export / container runtime) ends the run infra_failed with the host action. A merge that conflicts ONLY there is no_conflict here — the merge-gate regenerates and pushes it. With --story, the story's develop_generated_paths.
--regenerate-command CMD The command that rebuilds every --generated-path (e.g. make diagrams). Required with --generated-path.
--image REF Sandbox container image for the agents and the gate (default ralph-sandbox:latest). converge does not read the project-context doc, so a project pinning develop_image must pass it here — otherwise the gate runs in an image that may lack the tooling its checks need (a browser for an e2e parity command, say) and can never pass.
--artifacts-path PATH Repo-relative dir a gate check writes rendered output to (the project's develop_artifacts_path). Enables the artifact review pass; without it converge reviews the diff only.
--coder claude\|codex Coder engine for the fix turns (default: the config's coder).
--max-rounds N Cap the implement→review→fix rounds (validated ≥ 1).
--max-cost USD Soft phase-boundary ceiling on whole-command spend (intake + loop): converge stops before the next phase once recorded spend reaches it (validated finite and > 0). Not a hard cap — an in-flight turn may overshoot and a same-round approval is delivered even if it crossed the ceiling.
--test-timeout N Max seconds for one gate check run — the test check, other check-set checks, and autoformat (default 900, validated ≥ 1). Raise it for a repo whose non-integration suite exceeds the default; otherwise the gate floor never clears and converge stalls with green reviewers.
--no-push Converge locally but do not push to the PR branch.
--resolve-conflicts PRD S5: merge the base's current tip into the PR head in a throwaway worktree and, when it conflicts, have the coder resolve the marked files; the check-set + panel judge the composed tree and an approved run pushes the merge commit. A clean merge exits no_conflict (0) and spends nothing. Exclusive with --from-github.
--from-github Ingest the PR's external review findings instead of running the local-panel intake (see "Two intake modes"): trusted findings are triaged (S5a) and, if they survive, seed the fix loop directly; untrusted authors are printed but never fed to an agent; thread replies are posted afterwards.
--repo PATH Repository to converge in (default: current directory).
--json PATH Write the structured JSON summary.
--story TASK_ID Resolve the story's develop settings (project doc + task develop_*: rounds, profile, panel, check-set, image, models) exactly as the daemon path does, as the base layer under any explicit flags. The watcher-dispatched run passes it.
-c, --config Host config path.

Output

  • Plain-text summary to stdout: the status line, the message, the round + fixer-commit count, and (on a push) the pushed sha → PR branch.
  • JSON (--json): a stable object — status, succeeded (the one success verdict: already_clean / converged / triage_rejected — false when a converged run left an external finding reverted), head_ref, head_branch, base_sha, head_sha, rounds, develop_status, host_action (what to fix on the host when the status is infra_failed, else ""), fixer_commits (only the coder's commits, PR head → HEAD, first-parent — not the PR's original commits, nor any base commits a merge brought in), pushed, pushed_sha, intake_cost_usd (in external mode: the triage spend), total_cost_usd, message, deferred_findings, and external_outcomes (external mode: per injected finding — finding_id, author, source, stream, activity_id, reply_mode, thread_url, disposition rejected/nothing_to_remediate/disputed/fixed/reverted/no_change_needed/unaddressed, detail, note (how a final acknowledgement that disagreed with an earlier round's was read — #399; shown on the story's outcome finding and in the summary, never on the thread); fixed requires the coder's per-id FIXED acknowledgement — its final handoff's, or round 1's restated unbroken when the final handoff says NO CHANGE NEEDED over it — and the loop's approval and a final tree that differs from the PR head — anything less reports unaddressed; reverted is a fix a later round undid; no_change_needed is a finding the coder judged not a defect — an approval verdict ingested as a finding, intended behaviour — and, like fixed, it requires the loop's approval. #380: a round-1 coder that commits nothing because every injected id needs no change has made a claim, and the loop admits the empty round for review instead of failing it: the fast checks run on the unchanged head and the panel — given the injected findings as review context, with the coder's acknowledgements named as claims to verify — judges it. When the loop approves and every injected finding is refuted or no_change_needed, the run is already_clean — reported, not remediated, exit 0, the watcher refunds the round once per budget — never the not_converged that spent lens #83's last budget round and raised a needs-human gate on a mergeable PR. The admitted round is a validation pass, not an entry to the fix loop: a claim the panel rejects (or a red required check the floor holds on) ends the run not_converged with the rationale, so a trigger that asked for nothing never drives paid rounds or pushes unrelated commits onto a delivered PR; the claim stays unaddressed, so no thread is answered "no change needed" on the coder's word alone).
  • Statuses / exit codes: already_clean (intake didn't block; reports the pre-intake snapshot — or, in external mode, every injected finding refuted or no_change_needed with nothing committed and the loop's gate + panel approving the unchanged head, #380, or triaged NOTHING_TO_REMEDIATE at round 0 — an approval-only batch, 827cedf8), converged, and triage_rejected (external mode: nothing survived triage and at least one finding was rejected with cited evidence; no coder built, rejection replies posted — the message names each disposition, so an approval beside a rejection is not reported as refuted) → 0; not_converged (loop stopped unapproved), infra_failed (the intake fetch of the PR head + base failed — a transport failure (Could not read from remote repository / a reset / a timeout / an early EOF / a kex_exchange, matched against git's WHOLE stderr — the reported line is a collapse) is retried 3× with a short backoff first, so a daemon SSH-agent hiccup costs seconds rather than a restart, and loom's own watchdog kill (the timeout elapsing with no answer) is retried like the rest — while the attempts share ONE fetch timeout (the ref-lock retry inside each included, a per-attempt reserve keeping the retries runnable) so a hung origin cannot hold a dispatcher's slot for three of them, and a failure that persists ends the run here with host_action naming git's own fatal: line — #431; or the coder or a reviewer died on the host — an auth / transport / spawn failure that persisted through its retries — either in the intake panel or in the loop; NOT a verdict on the change, no replies posted, host_action says what to fix; the github-watcher refunds the S5b round and re-arms the conflict pair on the next daemon boot, #377), merge_race (PR head advanced remotely), and failed (incomplete intake panel, or pre-loop spend — intake review, or triage where a finding survives into the fix loop — exhausted --max-cost; a no-survivor batch keeps its round-0 disposition instead, the spend having already happened with nothing paid left to stop) → 1; fork_unsupported and merged (the PR already landed) → 2; no_conflict (resolve mode: the base merges cleanly, nothing to resolve — not a success verdict, succeeded is false), head_moved and base_moved (the pinned pair no longer holds; nothing ran) → 0; conflict_unsupported (resolve mode: a binary, symlink, submodule, modify/delete or markerless conflict — a human must resolve it; no agent ran) → 1. Every path and hunk in the coder brief and the reviewer context sits inside a fence sized past the longest backtick run it encloses, so repository content cannot close a fence and become prompt prose.

Intake exceptions propagate. An unexpected error while producing the intake review (e.g. a container crash, a bad config) is raised, not silently mapped to failed — a traceback is the honest signal for an internal fault. failed is reserved for the expected incomplete-review and budget-exhausted cases.

An exhausted run's rounds — develop converge-push

A run that stops unapproved (not_converged on max_rounds / disputed / stalled / cost_exceeded) pushes nothing, by design: an unapproved push would put unreviewed rounds on a delivered PR. Its rounds are still committed, on a local branch in <work_dir>/converge/<run>/worktree. lithos-loom develop converge-push <run> is the operator's decision about them: without --yes it reports what those rounds produced (the gate verdict, the findings left open, the fixer commits) and whether they would fast-forward onto the PR's current head, writing nothing; with --yes it pushes them through this command's own append-only seam, answers the reviewers' threads and records [ConvergePushed] on the story naming what it was pushed with. The watcher reads that push as a human one, so the remediation budget re-arms.

To make that possible, converge writes a converge block into the run dir's state.json at intake, before the first paid turn — pr_url, pr_number, pr_head_branch (the PR's head branch, not the run's own local branch), intake_head_sha, base_sha, repo, and the --story id when given — and seeds the run's handoff/ dir in the same step, since that is what every run lookup recognises a run by and the intake review seeds only <run>-intake's. A run killed during intake (SIGTERM, exit 143) is therefore still resolvable. The loop's own exit merges into the same file rather than overwriting it, and converge merges the whole-command spend in beside the loop's own (total_cost_usd = the intake / triage turn + the loop; develop() records only cost_usd) — the pre-loop half first, before the loop writes the terminal status every reader stops on, so a run killed in between can still be summed. A successful push is recorded as well (converge_push, by: converge): approval is not that signal, since --no-push and a raced push end approved with the rounds still only local. In --from-github mode the run also records the injected batch (external.json: the id→row map, triage's verdicts, the surviving ids), which is what the replayed thread replies are composed from — and the run's own reply epilogue adds each id whose reply the transport confirmed to that file's replied set, so the salvage answers only the threads still owed one rather than inferring that from the run's status (which the loop writes before the epilogue runs).

v1 limit — dispute-all round 1

If round 1's coder disputes every finding and commits nothing, the deterministic gate still runs on the unchanged head. Such a round converges only if the head was already gate-green. This is rare (the coder is told to fix, not dispute-all) and acceptable for v1.

Stopping a run

SIGTERM — what the daemon sends a dispatched run on shutdown — is turned into SystemExit(143) for every develop subcommand (#407 slice 2a), so the finally blocks that stop the run's sandbox containers and tear down its worktree run on the way out instead of being skipped by the signal's default disposition. The run's outcome is still lost (nothing is pushed, no JSON is written); the daemon refunds the budget round it killed and reaps any container that outlived its owner at the next boot.

Requirements

Host-only, like a develop run: docker + the agent CLIs (claude / codex) + gh (for PR resolution and the push). Not part of the hermetic make check.