Crew run ledger — landed evidence

The durable half of a run now lives where the plan it implemented lives: <repo>/docs/state/<project>/crew.json, committed beside index.json and version-paired the same way. The transient half never leaves reckon's config home. Eight declared measures produced evidence, 28 new tests cover them, and the suite is green at 1,628. One measure is reported as mechanism-proven but data-empty: reckon's own ledger begins now, so it has no measured spread to show yet.

Declared measures and what each produced

MeasureEvidenceVerdict
Round-tripA real CLI round-trip: pointer written at dispatch, ledger at version 2 after promotion, pointer gone, and git status showing crew.json as the only changed state file — § round-trippass
Nothing transient is committedNo pointer, log or crew-home path resolves inside the working tree, and an in-flight run leaves git status --porcelain empty — § transientpass
Interruption is recoverableA killed run that delivered classifies as completed-but-unpromoted with its manifest path, and promotion then succeeds; all three classes exercised in one report — § recoverypass
Concurrent writes are safeTwo interleaved promotions both survive, the retry provably forced; a stale version is refused and a double promotion is refused — § concurrencypass
Session reuseA null-session member captures an id on its first run; a second node's argv carries resume <that id>§ sessionspass
Worktree routingAgainst a real git worktree, crew(…, checkout_path=…) reads that checkout's ledger while the main checkout's read returns its own — § routingpass
Calibration inputs capturedAll 20 record fields present on a promoted run; scope_changed defaults false and is settable; the report shows 40.0 min over 2 runs against declared effort M with a 20.0 min spread, and excludes the scope-changed run — § calibrationpass — data-empty for reckon itself, see below
Suite green1,628 tests pass, 28 of them new; lint clean — § suitepass

The shape that makes recovery possible

Promotion is two writes in a fixed order, and the order is the whole recovery story. The ledger is appended first and the pointer deleted second, so the only state an interruption can leave is a pointer whose run has finished — which is recoverable. The reverse order would lose the record outright.

live pointer — config home crew.json — owning repository what an interruption can leave, and what recover calls it pid · worktree · log · phase — never committed roster · commits · gate · measures — committed 1. append 2. delete the pointer process alive → running dead, manifest or terminal event → completed-but-unpromoted, with its manifest path dead, nothing delivered → abandoned; the worktree is left in place, never force-removed
Three classes, because those are the three distinguishable states. Liveness comes from the process table and delivery from the manifest on disk — a stream that stops without a terminal event reads as working forever, and would otherwise hide a dead worker.

Round-trip, run through the CLI

Recorded against a throwaway repository with the shipped in-harness backend, so nothing was spawned. The roster write created the ledger at version 1; the promotion appended to it at version 2.

Live pointersdocs/state/smoke/git status --porcelain
after dispatchr-20260812T093244038067-node-a.jsoncrew.json, index.json?? docs/state/smoke/crew.json
after crew completenonecrew.json, index.json?? docs/state/smoke/crew.json

The ledger is the only state file either step touched. It is untracked in the listing above only because the throwaway repository never committed it; in a real project it is committed beside index.json, which is the point of putting it there.

The promoted record, abridged to the fields the measures name:

run_id        r-20260812T093244038067-node-a
plan/section  plan-a / §2        node  node-a        member  worker-a
agent         backend native, launch in-harness, sandbox worktree-full
dispatched    2026-08-12T09:32:44Z   completed  2026-08-12T09:32:44Z
base_sha      c57aeb3f116a3a5680edc04842320035e2888e6c   commits  b2f9f7d
changed_lines added 1, removed 0, files 1     tests_added  28
gate          passed        scope_changed  false        time_budget  20m

A gate verdict is one of passed, failed or not-run, refused otherwise. Free text would have made the field unqueryable by the calibration loops that will read it, and not-run is a real answer: a gate whose evidence could not be produced is a recorded negative, not a silent pass.

Nothing transient reaches a working tree

Two tests hold the boundary from both sides. The first asserts that the crew home, the live directory, the pointer file and the event log are all outside the repository — the property that would otherwise decay the first time someone moved the config home. The second asserts that a run in flight leaves git status --porcelain empty in the main tree, with the worker's own scoped files confined to its detached worktree, which is itself outside the repository.

An interruption is recoverable, and recovery is conservative

A run whose process was killed after it delivered its manifest classifies as completed_unpromoted, reports the manifest path, and names the exact promotion command; promoting it then succeeds, so nothing was lost by the interruption. A run whose process died having delivered nothing classifies as abandoned and points at its stderr log. A live process classifies as running. One report over three concurrent runs returned exactly one of each.

Recovery repairs the record and nothing else: after a recover pass over an abandoned run, the worktree is still there and the pointer is still there. Removing either would destroy the evidence a human needs to decide, which is why the conservative posture already in the fleet script is matched here rather than reinvented.

Two orchestrators cannot clobber each other

Ledger writes are paired with the version they read, exactly as index.json writes are, and a stale version is refused with an instruction to re-read. Appending retries on that refusal, which is what makes interleaving safe rather than merely detected: with a competing record injected mid-write, the loser re-read the winner's ledger and appended to it, leaving all three records — and the test asserts the retry actually fired rather than inferring it from the result.

A second record for the same run id is refused instead of retried. That is not concurrency, it is a double promotion, and it would double-count every measurement the record carries.

A member's session outlives its first node

A member registered with no session captures one from its first run: observation is where a backend's session id first becomes knowable, so that is where the roster learns it — waiting for completion would leave a second node dispatched in the meantime unable to reach the same session. A second node dispatched to that member launched with resume <the captured id> in its argv.

The first id wins. A later run reporting a different session is reported and not written over the top, because overwriting would silently retire the long-lived session every subsequent node and every escape-hatch resumption is meant to reach. Dispatching to an unregistered member is refused before any worktree exists, naming the command that registers one.

A worker inside a worktree reads its own ledger

Verified against a real second checkout cut with git worktree add, holding a different record from the main one. crew(project, view="ledger", checkout_path=<worktree>) returned the worktree's record and a path under the worktree; the same call against the main checkout returned the main record. The contract is the one read_plan already uses, so nothing new has to be learned to use it.

What makes effort falsifiable later

Effort is an asserted letter with no external referent. The ledger is what can make it falsifiable, but only if the fields are captured while they are still knowable — after the worktree is gone, the wall-clock, the configuration that ran the node and the scoped diff cannot be reconstructed. All twenty record fields are present on a promoted run, including the agent block and a changed_lines count taken from the diff restricted to the node's own write paths: a count over the whole diff would describe the branch, not the node.

scope_changed defaults false and is settable. It is the field easiest to omit and impossible to reconstruct, so the report excludes those runs from every measured column and counts them separately rather than averaging them in — a scope-changed run measures neither the estimate nor the worker. On recorded data, two runs of a plan declared effort M reported measured 40.0 min, mean 20.0, min 10.0, max 30.0, spread 20.0; adding a 100-minute scope-changed run left the mean at 10.0 with one run excluded.

Stated rather than implied: reckon's own ledger begins with this change, and this plan was implemented inline rather than through crew dispatch, so the project has no crew-dispatched runs yet and its measured spread is empty. The mechanism is proven on recorded data; the first real spread appears once nodes are dispatched and promoted through the CLI. Declared effort is read from the plans themselves, so the claim and the measurement cannot drift apart. Weighting and the unit migration remain effort-calibration's.

Suite, lint and the tool surface

uv run pytest -q1,628 passed, up from 1,600, with 28 new tests in tests/test_ledger.py. uv run ruff check reckon tests → all checks passed. The MCP surface is asserted equal to exactly five tools — read_plan, edit_plan, roadmap, audit, crew — so a sixth cannot be added without the assertion failing.

One defect surfaced and was fixed during the work: the ledger reached the store's classes by importing their names, and a test that reloads the store module rebinds those classes in place, so a captured exception class no longer matched the one its own function raised. Two tests passed in isolation and failed in the suite. The module now reaches the store through the module object, which is why the whole-suite run is the one that counts.