shapeup-sdlc 3.7.3 → 3.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.7.3",
4
+ "version": "3.7.5",
5
5
  "description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
6
6
  "author": {
7
7
  "name": "Liberty Nguyen",
package/AGENTS.md CHANGED
@@ -32,7 +32,7 @@ Betting Table: PO decides; rejected pitches loop back to raw idea.
32
32
  | Kick-off | ⏸ **L0** — Intake & Config (L0.8 model/budget matrix) + worker roster ✧ | `/translator` if non-English |
33
33
  | Orient (Scout) | ⏸ **L1a** — Orient Review | `/orient` |
34
34
  | Requirements | — (reviewed at L1b) | `/ba-pitch-analyzer` (`coverage`): the pitch's clauses → committed `requirements.md`, one atomic clause per `REQ-<n>` row naming the pitch clause it came from, ids assigned once and frozen; dispatched once, ahead of Analyze because the acceptance criteria are what cite its ids. A registry already on disk is not re-dispatched |
35
- | Analyze | — (reviewed at L1b) | `/ba-pitch-analyzer` (`analyze`): spec tree + board (UC + Invariants + Test Surface ★); before Wire (needs its use cases). An acceptance criterion that grades a requirement carries `(covers: REQ-…)` — that clause is the edge the verdict travels back along |
35
+ | Analyze | — (reviewed at L1b) | `/ba-pitch-analyzer` (`analyze`): spec tree + board (UC + Invariants + Test Surface ★); before Wire (needs its use cases). The tree is committed and the board is per-machine: a run that finds the tree on disk and no board dispatches `board`, which regenerates the board from the tree without re-deriving it — GATE L2 refuses `proceed` over a board with zero tasks. An acceptance criterion that grades a requirement carries `(covers: REQ-…)` — that clause is the edge the verdict travels back along |
36
36
  | Wire | ⏸ **L1a.5** — Wiring Review ✚ | `/solution-architect` (`wire`): sole writer of committed `wiring-map.md` — per-UC engine → seam → entry-point call site → affordance, per `project-profile.md` |
37
37
  | Map Scopes | ⏸ **L1b** — Board Review (+ substrate disjointness lint) | `/scope-architect` (scope contracts ✦ — sole writer); traceability oracle advisory ✚. A registered requirement that no acceptance criterion grades and no scope claims is **red** here, and L1b prints the `REQ → AC` table: the two ways out are an AC carrying `(covers: REQ-…)` or the PO marking the clause `CUT (PO-approved)`. Red only where the plan is still cheap to change — after L1b nobody re-reads the pitch |
38
38
  | Build Vertically | ⏸ **L2** — Board 100% ✅ + T0-green ✦ | per dispatch: compile order → `/task-executor` (--order) → ingest result; T0-verified per attempt (fixtures + DB probe ✦; the seesaw regression arm is declared and not yet wired), substrate-sandboxed ✦. Scopes build **concurrently** ✦ — `--parallel-scopes N` caps it (default 4), a scope is released the moment its own dependencies are green, and a scope green in this round is skipped rather than rebuilt. Then the **round build gate** ⚙: the ledger's run command, then the profile's `build_probe` and `launch_probe`, run once per round before EVAL — a red gate ends the round with no verdict and its failing step is compiled into the next round's orders as bugs; a `mobile` profile with no `launch_probe` is warned about every round, so the install/launch risk has an owner |
@@ -48,7 +48,7 @@ Betting Table: PO decides; rejected pitches loop back to raw idea.
48
48
  **Q0** Preflight → **Q1** Charter (6 lenses − EVAL-covered) → **Hunt** (repro required, findings `~` → ledger) → report (no verdict, no score). Skip with `--no-qa`.
49
49
 
50
50
  ### Ship & Triage
51
- - **SHIP S.0 / GATE H** — `/scope-hammer`: census (QA findings + discovered ledger + attempt-budget proposals ✦; every "no scope owns X" cites `probe owner`, which derives ownership from the contracts) → baseline comparison (never the ideal) → cut list; TL/PO promotes selected items only.
51
+ - **SHIP S.0 / GATE H** — `/scope-hammer`: census (QA findings + discovered ledger + attempt-budget proposals ✦; every "no scope owns X" cites `probe owner`, which derives ownership from the contracts) → baseline comparison (never the ideal) → cut list; TL/PO promotes selected items only. The census is written as data beside the report, and GATE L4 reads that file — and nothing else — before any answer set may say `ship`. A breaker does not end the run: the run itself dispatches the census, crosses GATE H, writes the ship report with the verdict as it is, crosses L4, and only then closes — `shipped` with the cut list when the census clears it, `escalated` naming the census when it does not.
52
52
  - ⏸ **L4** — Ship Sign-off (shows QA status ★).
53
53
  - **Coach retro** — L4 feedback → `/coach`; GATE COACH-1 asks the PO which skill owns each rule (never assumes) → committed `shapeup/knowledge-base/<skill>.md` (team inherits on pull). Coachable: `/task-executor`, `/ba-pitch-analyzer`, `/qa-edge-hunter`, `/orient`, `/scope-architect`, `/solution-architect`, and `/tech-lead` (workflow guidance read at L0, plus suggested L0 values it confirms before pinning); `/spec-evaluator` is not (single judge) and neither is `/scope-hammer` (its census cites `probe owner`). **Guidance never decides a gate**: a rule may add a question or a check to a gate block, never an answer, a skip or a wider substrate. `/retro --scan` seeds the same files from the project on disk before the first run, and `/retro --research <stack>` from the platform's official documentation when there is nothing on disk yet (a source, never a verification: nothing it reads runs until the tech lead pins it at L0), optional, every rule confirmed at COACH-1. Mechanism defects file to `knowledge-base/harness-defects.md` as Betting Table raw ideas, never worker steering.
54
54
  - Post-fix: `eval --single-pass` → remaining `~` + new feedback → new raw idea.
@@ -175,6 +175,9 @@ export function ledgerDecisions(ledgerText, scopeId) {
175
175
  */
176
176
  export const OP_OWNER = {
177
177
  analyze: "ba-pitch-analyzer", reconcile: "ba-pitch-analyzer",
178
+ // `board` regenerates the per-machine board from a committed spec tree without re-deriving the
179
+ // tree — the half of ANALYZE that does not survive a clone. Same worker, narrower write surface.
180
+ board: "ba-pitch-analyzer",
178
181
  "retrofit-surface": "ba-pitch-analyzer", coverage: "ba-pitch-analyzer",
179
182
  "map-scopes": "scope-architect",
180
183
  wire: "solution-architect", evaluate: "spec-evaluator", orient: "orient",
@@ -259,6 +262,14 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
259
262
  case "analyze":
260
263
  return { allowed: [`${spec}/**`, `${local}/**`], frozen: [...FROZEN_INTAKE] };
261
264
 
265
+ case "board":
266
+ // The spec is READ, never written: this operation exists because the tree is already on disk
267
+ // and the board is not. Task ids renumber per machine by design, so nothing here is committed.
268
+ return {
269
+ allowed: [`${local}/tasks/**`, `${working}/**`],
270
+ frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${spec}/usecases/*.md`, `${spec}/scope-summary.md`],
271
+ };
272
+
262
273
  case "reconcile":
263
274
  return {
264
275
  allowed: [`${local}/tasks/**`, `${spec}/scope-summary.md`, `${working}/**`],
@@ -857,12 +868,22 @@ export async function cli(rawArgv) {
857
868
  // receipts ledger EXISTING while carrying no row for the previous attempt: a lane that does not
858
869
  // attest dispatches at all (no ledger on disk) cannot be judged by this rule and is waved
859
870
  // through, so `--tiny`, a prose round loop and a standalone build are untouched.
860
- if (scope?.scope_id && round && attempt > 1) {
871
+ //
872
+ // THE RUN KEY IS PART OF THE QUESTION. `attemptEvidence` matches a receipt to an attempt by
873
+ // `order_id` AND `run_id` — deliberately, so a previous run's receipt over the same slug cannot
874
+ // answer for this one — and this call used to omit the key. With no key nothing matches, so every
875
+ // previous attempt read "unattested" and every attempt 2 was refused as unanswered, on every run,
876
+ // with the receipt, the leg row and the result all on disk. Measured on a live run: one attempt
877
+ // spent of five, the census reading it spent, and the gate refusing to open the next one three
878
+ // times over. The ratchet was one attempt deep for as long as the gate has existed. A run with no
879
+ // readable receipt has no key to ask with and is waved through, like a lane with no ledger.
880
+ const myRunId = (scope?.scope_id && round && attempt > 1) ? readRunId(cwd, slug) : null;
881
+ if (scope?.scope_id && round && attempt > 1 && myRunId) {
861
882
  const receiptsPath = dispatchReceipts(cwd, slug);
862
883
  if (existsSync(receiptsPath)) {
863
884
  const prev = attemptEvidence(
864
885
  cwd, slug, scope.scope_id, round, attempt - 1,
865
- readReceipts(receiptsPath), readLegs(legLedger(cwd, slug)),
886
+ readReceipts(receiptsPath), readLegs(legLedger(cwd, slug)), myRunId,
866
887
  );
867
888
  if (prev.state !== "spent") {
868
889
  const why = prev.state === "unattested"
package/kernel/gate.mjs CHANGED
@@ -51,9 +51,10 @@
51
51
  // 2 usage / validation error
52
52
 
53
53
  import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
54
+ import { parseBoard } from "./reduce/board.mjs";
54
55
  import { join, dirname } from "node:path";
55
56
  import { runArgs } from "./lib/argv.mjs";
56
- import { gateAnswerCandidates, gates as gatesPath, LOCAL, resultsDir } from "./lib/paths.mjs";
57
+ import { gateAnswerCandidates, gates as gatesPath, LOCAL, resultsDir, tasksDir, hammerCensus } from "./lib/paths.mjs";
57
58
 
58
59
  export const GATE_IDS = ["L0", "L1a", "L1a.5", "L1b", "L2", "L3", "QA", "H", "L4", "COACH-1"];
59
60
 
@@ -110,7 +111,7 @@ export const PRESETS = {
110
111
  "L1a": { decision: "proceed", note: "Orient review — advisory read." },
111
112
  "L1a.5": { decision: "proceed", note: "Wiring review — checked by trace-lint." },
112
113
  "L1b": { decision: "ask", note: "Board review is where scope is actually decided. Not pre-approvable." },
113
- "L2": { decision: "proceed", note: "The board facts travel in the gate block itself — green_scopes and hammer_proposals — so a preset answering here is not answering blind. Note there is no board check behind this: the L2 hook was retired into the gate block in v2.0." },
114
+ "L2": { decision: "proceed", note: "The board facts travel in the gate block itself — green_scopes and hammer_proposals — so a preset answering here is not answering blind. A board with zero tasks is refused here regardless of the answer — the resolver narrows `proceed` to `ask` over an empty board." },
114
115
  "L3": { decision: "loop", max_rounds: 3, note: "Loop on FAIL; the breaker ends it." },
115
116
  "QA": { decision: "run" },
116
117
  "H": { decision: "ask", note: "The cut list changes what ships." },
@@ -186,6 +187,17 @@ export const HAMMER_VERDICTS = ["ship-now", "ship-after-fixes", "cannot-ship"];
186
187
  */
187
188
  export function censusVerdict(cwd, slug) {
188
189
  if (!slug) return null;
190
+ // The census artifact — written by the hammer at a path its order's substrate permits. The
191
+ // WorkResult fallback below is kept for a lane that wrote one the old way; a WorkResult may not
192
+ // carry a verdict under the schema, so in practice the artifact is the only source.
193
+ try {
194
+ const c = hammerCensus(cwd, slug);
195
+ if (existsSync(c)) {
196
+ const r = JSON.parse(readFileSync(c, "utf8"));
197
+ const v = r?.verdict ?? null;
198
+ if (HAMMER_VERDICTS.includes(v)) return v;
199
+ }
200
+ } catch { /* unreadable census proves nothing — fall through */ }
189
201
  try {
190
202
  const p = join(resultsDir(cwd, slug), "hammer.json");
191
203
  if (!existsSync(p)) return null;
@@ -218,6 +230,26 @@ export function censusVerdict(cwd, slug) {
218
230
  * @returns {object} The result, or an `ask` carrying why `ship` was not available.
219
231
  */
220
232
  export function narrowToEvidence(r, cwd, slug) {
233
+ // GATE L2's question is "is the board done", and a preset used to answer it over a board with
234
+ // zero tasks — 100% of nothing. Measured on a consumer: a committed spec fast-forwarded ANALYZE,
235
+ // the per-machine board was never regenerated, L2 crossed `proceed` from the `ci` preset and the
236
+ // frozen report printed `Board 0/0 tasks done`. Same rule as L4 below: an answer set chooses
237
+ // among allowed answers and cannot supply the evidence that makes one allowed.
238
+ if (r?.gate === "L2" && r?.status === "ok" && r?.decision === "proceed" && slug) {
239
+ let tasks = 0;
240
+ try { tasks = parseBoard(tasksDir(cwd, slug)).length; } catch { tasks = 0; }
241
+ if (tasks === 0) {
242
+ return {
243
+ gate: r.gate, status: "ask", source: r.source, decision: "ask", note: r.note,
244
+ refused: "proceed", board_tasks: 0,
245
+ reason: `GATE L2 cannot be answered "proceed": the board has no tasks, so "board 100%" is a ` +
246
+ `hundred percent of nothing. A committed spec with no per-machine board needs the board ` +
247
+ `regenerated (operation \`board\`) before this gate means anything. Put the block to ` +
248
+ `the PO, or regenerate the board first.`,
249
+ };
250
+ }
251
+ return r;
252
+ }
221
253
  if (r?.gate !== "L4" || r?.status !== "ok" || r?.decision !== "ship") return r;
222
254
  const verdict = censusVerdict(cwd, slug);
223
255
  if (verdict === "ship-now" || verdict === "ship-after-fixes") return r;
@@ -306,6 +306,16 @@ export const activeOrder = (cwd) => join(localDir(cwd), "active-order");
306
306
  */
307
307
  export const lastRun = (cwd) => join(localDir(cwd), "last-run");
308
308
 
309
+ /**
310
+ * Scope-hammer's census as DATA — `{verdict, cut_list, …}` — the one artifact GATE L4's resolver
311
+ * reads before it lets an answer set say `ship`. The hammer's WorkResult may not carry a verdict
312
+ * (its census is a proposal, never envelope data ingest acts on), and its printed H0/H1/H2 blocks
313
+ * are prose; this file is the same proposal, written by the same hand, in a shape a gate can read.
314
+ * Measured before it existed: a green census returned as the worker's report, and L4 could only
315
+ * record `ask`, because nothing on disk said so.
316
+ */
317
+ export const hammerCensus = (cwd, slug) => join(localRoot(cwd, slug), "reports", "hammer-census.json");
318
+
309
319
  /**
310
320
  * Where the run scripts are staged for launch, inside the project.
311
321
  *
@@ -61,7 +61,8 @@ import { dirname, join, resolve } from "node:path";
61
61
  import { runArgs } from "../lib/argv.mjs";
62
62
  import { splitFrontmatter, uncoerce } from "../lib/contract.mjs";
63
63
  import { globToRegExp } from "../verify/spec.mjs";
64
- import { intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir, orientDir, activeOrder, activeScope, usecasesDir, breadboard, receipt, readReceipt, requirements, exportRunDir, lastRun, readRunId } from "../lib/paths.mjs";
64
+ import { parseBoard } from "../reduce/board.mjs";
65
+ import { intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir, orientDir, activeOrder, activeScope, usecasesDir, breadboard, receipt, readReceipt, requirements, exportRunDir, lastRun, readRunId, tasksDir } from "../lib/paths.mjs";
65
66
  import { evalVerdict } from "./eval.mjs";
66
67
  import { collectRun, writeRun } from "../report/export.mjs";
67
68
 
@@ -371,6 +372,21 @@ export function usecasesPath(cwd, slug, specFolder) {
371
372
  * @param {string|null} specFolder - The ledger's `spec_folder`, if it names one.
372
373
  * @returns {boolean} True when at least one use case (not the index) is on disk.
373
374
  */
375
+ /**
376
+ * Is the per-machine board on disk with at least one task? ANALYZE writes two artifacts in two
377
+ * tiers — the spec tree, committed, and the board, gitignored — and a fast-forward that asked only
378
+ * about the committed half walked every later run on a machine, and every run in a fresh checkout,
379
+ * into a build with a spec and no board: GATE L2 crossed over 0/0 tasks, the evaluator could read no
380
+ * `covers:` clause, and the requirements projection printed "no evidence" on a round whose static
381
+ * criteria all passed. Measured on a live consumer, fourth run of one pitch.
382
+ * @param {string} cwd - Project root.
383
+ * @param {string} slug - Feature slug.
384
+ * @returns {boolean} True when the board directory holds at least one task file.
385
+ */
386
+ export function hasBoard(cwd, slug) {
387
+ try { return parseBoard(tasksDir(cwd, slug)).length > 0; } catch { return false; }
388
+ }
389
+
374
390
  export function hasSpecTree(cwd, slug, specFolder) {
375
391
  const dir = usecasesPath(cwd, slug, specFolder);
376
392
  if (!existsSync(dir)) return false;
@@ -385,7 +401,10 @@ export function hasSpecTree(cwd, slug, specFolder) {
385
401
  */
386
402
  export const PHASE_ARTIFACT = {
387
403
  orient: { fact: "has_orient_artifacts", artifact: "orient/{code-surface,discovered-seed,hill-signal}.md + spike-*.md" },
388
- analyze: { fact: "has_spec_tree", artifact: "spec/usecases/*.md" },
404
+ // BOTH HALVES. The spec tree is committed and survives a clone; the board is per-machine and does
405
+ // not. A phase is complete only when both are on disk — a committed spec with no board resumes AT
406
+ // analyze, where the workflow dispatches the board-only operation rather than the whole phase.
407
+ analyze: { fact: "has_spec_tree", also: "has_board", artifact: "spec/usecases/*.md + tasks/TASK-*.md" },
389
408
  wire: { fact: "has_wiring_map", artifact: "wiring-map.md" },
390
409
  "map-scopes": { fact: "scope_files", artifact: "scopes/*.md" },
391
410
  };
@@ -399,8 +418,9 @@ export const PHASES = Object.keys(PHASE_ARTIFACT);
399
418
  * @returns {boolean} True when the phase's artifact exists.
400
419
  */
401
420
  export function phaseSatisfied(state, phase) {
402
- const v = state[PHASE_ARTIFACT[phase].fact];
403
- return Array.isArray(v) ? v.length > 0 : Boolean(v);
421
+ const present = (k) => { const v = state[k]; return Array.isArray(v) ? v.length > 0 : Boolean(v); };
422
+ const p = PHASE_ARTIFACT[phase];
423
+ return present(p.fact) && (!p.also || present(p.also));
404
424
  }
405
425
 
406
426
  /**
@@ -461,6 +481,7 @@ export function deriveResumeState(cwd, slug) {
461
481
  orient_dir: `.shapeup/${slug}/orient/`,
462
482
  has_orient_artifacts: hasOrientArtifacts(cwd, slug),
463
483
  has_spec_tree: hasSpecTree(cwd, slug, hr.spec_folder || null),
484
+ has_board: hasBoard(cwd, slug),
464
485
  // A PLAIN FACT, DELIBERATELY NOT A PHASE. The requirements registry is dispatched once, before
465
486
  // ANALYZE, and the orchestrator guards that one dispatch on this boolean. It is NOT an entry in
466
487
  // PHASE_ARTIFACT, and adding it there would be a migration hazard rather than a tidier shape:
@@ -494,7 +494,7 @@
494
494
  "execute",
495
495
  "fix",
496
496
  "spike",
497
- "analyze",
497
+ "analyze", "board",
498
498
  "reconcile",
499
499
  "retrofit-surface",
500
500
  "coverage",
@@ -2537,6 +2537,22 @@
2537
2537
  }
2538
2538
  }
2539
2539
  },
2540
+ "HammerCensus": {
2541
+ "description": "Scope-hammer's census as data — the proposal GATE L4's resolver reads before it lets an answer set say `ship`. Written by the hammer at `.shapeup/<slug>/reports/hammer-census.json`, the only path its order's substrate permits besides the committed report. A proposal, never a decision: promotion and shipping stay the PO's call, and ingest never acts on it.",
2542
+ "x-tier": "LOCAL",
2543
+ "x-location": ".shapeup/<slug>/reports/hammer-census.json",
2544
+ "type": "object",
2545
+ "required": ["schema_version", "verdict", "cut_list"],
2546
+ "properties": {
2547
+ "schema_version": { "const": 1 },
2548
+ "order_id": { "type": "string" },
2549
+ "verdict": { "type": "string", "enum": ["ship-now", "ship-after-fixes", "cannot-ship"] },
2550
+ "cut_list": { "type": "array", "items": { "type": "string" } },
2551
+ "ship_blocking": { "type": "array", "items": { "type": "string" }, "description": "Must-have items that failed the baseline comparison — non-empty exactly when the verdict is cannot-ship." },
2552
+ "breaker": { "type": ["string", "null"] },
2553
+ "baseline": { "type": ["string", "null"], "description": "The baseline the comparison ran against, or null when the pitch's problem statement stood in for it." }
2554
+ }
2555
+ },
2540
2556
  "ResumeState": {
2541
2557
  "description": "The fast-forward derivation: which phase a launch resumes at, derived from artifacts on disk and NEVER from stored state or conversation memory. Produced by kernel/probe/resume.mjs on stdout and consumed by shapeup-run.js's preamble on every launch, fresh or relaunch alike. Every phase predicate is an artifact test — `has_orient_artifacts` exists because the ORIENT branch was once gated on the ledger's stored `status` instead, which a silent courier failure left stale, and a completed ORIENT phase was re-dispatched on resume. `next_phase` is a convenience derived from the same booleans, which all travel too: a caller is never forced to trust a summary it cannot re-derive.",
2542
2558
  "x-tier": "EMBEDDED",
@@ -2635,6 +2651,7 @@
2635
2651
  "type": "boolean",
2636
2652
  "description": "ANALYZE finished: the spec folder's usecases/ carries at least one use case that is not _index.md. WIRE reads these — one wiring-map entry per use case — which is why ANALYZE precedes WIRE in the phase chain: dispatched against an empty spec folder, WIRE escalates on every launch."
2637
2653
  },
2654
+ "has_board": { "type": "boolean", "description": "The per-machine board (tasks/TASK-*.md) holds at least one task. ANALYZE is complete only with both the committed spec tree and this; a committed tree with no board resumes at analyze, where the board-only operation regenerates it." },
2638
2655
  "has_requirements": {
2639
2656
  "type": "boolean",
2640
2657
  "description": "The requirements registry is on disk: shapeup/<slug>/requirements.md exists. A PLAIN FACT, not a phase — the orchestrator guards its single `coverage` dispatch on this boolean, and it is deliberately absent from kernel/probe/resume.mjs's PHASE_ARTIFACT map, which doubles as nextPhase()'s ordered list: an entry there would fast-forward every run recorded before the registry existed to the registry instead of to build."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.7.3",
3
+ "version": "3.7.5",
4
4
  "description": "Shape Up for coding agents \u2014 with gates the agent can't talk its way past. Harness for Claude Code.",
5
5
  "bin": {
6
6
  "shapeup-sdlc": "bin/init.mjs"
@@ -24,7 +24,7 @@ Invoked as `--order <path>`. Fields you may rely on (absent = unknown; surface i
24
24
 
25
25
  | Field | What it is |
26
26
  |---|---|
27
- | `operation` | `analyze` (pitch → full spec tree + board) · `reconcile` (fold discovered-ledger items into the board + UC invariants) · `retrofit-surface` (append `## Test Surface` to a pre-surface spec) · `coverage` (extract atomic requirement clauses → the SHARED `requirements.md` registry) |
27
+ | `operation` | `analyze` (pitch → full spec tree + board) · `board` (committed spec tree → the per-machine board only; the tree is frozen) · `reconcile` (fold discovered-ledger items into the board + UC invariants) · `retrofit-surface` (append `## Test Surface` to a pre-surface spec) · `coverage` (extract atomic requirement clauses → the SHARED `requirements.md` registry) |
28
28
  | `payload.pitch` | The pitch/PRD path (analyze) |
29
29
  | `payload.breadboard` | The breadboard (analyze): its Places are your screens; its U# and N# are the affordances you place and cite. Absent = none separate; never inferred |
30
30
  | `payload.requirements` | (coverage) the REQ source to extract atomic clauses from — pitch / a customer-requirements doc / the use-case bodies. Absent → default to the pitch and record the choice in `assumptions[]` |
@@ -115,10 +115,11 @@ covered AC is a requirement the run can be measured against.
115
115
 
116
116
  ---
117
117
 
118
- ## The other three operations — same craft, different payload + whitelist
118
+ ## The other four operations — same craft, different payload + whitelist
119
119
 
120
120
  | Operation | Essence | Never |
121
121
  |---|---|---|
122
+ | `board` | The spec tree is already on disk and committed; the board is per-machine and is not. Regenerate `tasks/` from the use cases as they are — same task derivation as `analyze`, every AC carrying its `(covers: REQ-…)` clause, ids numbered fresh for this machine | touch the spec folder (frozen), re-derive or "improve" a use case, invent a task no UC step or Test Surface row sources |
122
123
  | `reconcile` | Verify `ledger.feature == payload.feature` (mismatch → STOP). Map each `[+]` Keep item → its owning UC; new task continues numbering (never renumber); `~`/Cut → synthesis "Hammered Out" row, no file. A Keep item asserting a new invariant → APPEND `[INV-NN]` + TS-INV row to that UC (append-only sections in your substrate). A new actor/action with no UC → `status: "escalated"` + a `deviations[]` spec-ambiguity entry: spawning a UC mid-cycle is silent re-shaping, the PO decides. Finish with board-derive (appetite overflow → report) + spec-lint | re-run phases 1–5; edit UC Steps; resolve the appetite HAMMER yourself |
123
124
  | `retrofit-surface` | Append `## Test Surface` (derived rows only, after Error Cases) to each UC of a pre-surface spec; an all-sources-empty UC gets the explicit empty-sources line | touch anything else — append-only substrate |
124
125
  | `coverage` | Extract **atomic** customer requirement clauses from `payload.requirements` (default: the pitch) and write the SHARED `shapeup/<slug>/requirements.md` registry: one `\| REQ-id \| clause (verbatim) \| source \| status \| note \|` row per clause. **The `source` cell, and any other prose in this file, cites the committed pitch or shaping doc — never the gitignored run-tier path** (`.shapeup/<slug>/intake.md`, or any other `.shapeup/` path): spec-lint's `TIER-DIRECTION` rule reds a committed file naming that path in ANY form, a bare path in a sentence exactly as much as a `[[tasks/...]]` wikilink, because it dangles on every other clone. When the intake has no committed original to name, describe the run tier without a path (`"the pitch staged for this run"`) rather than citing where it actually lives. Split compound sentences into one testable clause each — a clause lost *inside* a bigger sentence is a requirement nothing can be traced to. **Assign REQ-ids ONCE and freeze them** (they behave like scope_id, never TASK-NNN — every `covers:` link rots otherwise): re-running, append new clauses with fresh ids, mark a removed clause `CUT (PO-approved)`, never renumber or delete. Status starts `covered` (a live requirement); only the PO sets `CUT`. The REQ source itself is frozen — the registry is a separate derived file. **Numbering.** A source clause already carrying an `R<n>` keeps its number — `R12` → `REQ-12` — and its `source` cell records where it came from verbatim (`shaping.md R12`), because that cell is the only thing that survives a re-run. A clause with no R-id takes the next free number ABOVE the highest `R<n>` in the source, so it can never collide with one added later. Splitting a compound clause keeps `REQ-12` for the first atomic part and records `shaping.md R12 (split 2/3)` for the rest — a requirement graded in parts is why splitting matters at all. On a re-run, match an existing id by its frozen `source` cell and clause text, **never** by re-deriving the number from the source's current order | edit the REQ source; renumber existing REQ-ids; delete a dropped clause instead of marking it CUT; invent a requirement not in the source; re-point an existing REQ-id because the source's R-numbers shifted |
@@ -171,6 +171,19 @@ The WorkResult may carry only `files_touched`, `artifacts`, `assumptions`, `devi
171
171
  (`x-result-by-worker`): the census, cut list, and ship verdict live in the report artifact as a
172
172
  **proposal** — promotion and shipping stay a human call, never envelope data ingest acts on.
173
173
 
174
+ **The census is also written as data**, at `.shapeup/<slug>/reports/hammer-census.json` (inside
175
+ this operation's substrate), shape `HammerCensus` in the domain registry:
176
+
177
+ ```json
178
+ { "schema_version": 1, "order_id": "<the order>", "verdict": "ship-now | ship-after-fixes | cannot-ship",
179
+ "cut_list": ["…"], "ship_blocking": ["…"], "breaker": "outer | inner | deadline | null", "baseline": "<path or null>" }
180
+ ```
181
+
182
+ GATE L4's resolver reads this file — and nothing else — before it lets any answer set say `ship`.
183
+ It is the same proposal as the H2 block, written by the same hand, in a shape a gate can read:
184
+ a census that lives only in printed blocks cannot be read by a gate, and a run that reached L4
185
+ with a green census could only be recorded as `ask`. Still a proposal: the file grants nothing.
186
+
174
187
  ---
175
188
 
176
189
  ## Invocation
@@ -560,9 +560,12 @@ and without it the trace holds no record of that decision at all: `node
560
560
  "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" gate --resolve L4 --slug <slug>
561
561
  [--file <path>|--preset <name>]`. Exit 0 (`decision=ship|hold`) — render the block above and close
562
562
  the run: `node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" probe resume --slug <slug> --close shipped
563
- --cause "verdict=<verdict> rounds=<r> decision=<ship|hold>"`. Always issue this call — a `gate_h`
564
- close is the ordinary case where `shapeup-run.js` handed off without closing the run, and the
565
- GATE H → L4 path (scope-hammer's census, then this gate) is the one this instruction exists for.
563
+ --cause "verdict=<verdict> rounds=<r> decision=<ship|hold>"`. **This applies to the prose lane
564
+ only.** A launched run (`shapeup-run.js`) resolves L4 itself, on both the PASS path and a breaker
565
+ path: it dispatches the census, crosses GATE H, writes the ship report with the verdict as it is,
566
+ crosses L4, and only then closes — `shipped` when the census and L4 clear it, `escalated` naming
567
+ the census when they do not. A run that returned `{status: "shipped"}` or `{status: "gate_h"}` is
568
+ already closed; do not close it again, and do not run the census by hand after it.
566
569
  The close itself is a once-only fact IN THE KERNEL (`closeRun`'s own guard reads a `closed_status:`
567
570
  line that only `closeRun` ever writes — never the mutable `status:` line every phase rewrites, this
568
571
  call included), not a conditional this instruction has to get right: if this run_id was NOT already
@@ -42,7 +42,9 @@
42
42
  // { status: "paused", paused_at, block, valid_decisions, context }
43
43
  // { status: "aborted", aborted_at, reason }
44
44
  // { status: "gate_h", breaker: "outer"|"attempt_budget"|"none"|"deadline", hammer_proposals, green_scopes,
45
- // tripped_scopes?, unapplied_results? }
45
+ // tripped_scopes?, unapplied_results?, census?, cut_list?, l4? }
46
+ // — returned only when the census said cannot-ship, L4 said hold, or the ship report was
47
+ // refused; a breaker whose census clears the run ends as `shipped` with `after: "gate_h"`.
46
48
 
47
49
  // meta must be a PURE LITERAL — the runtime parses it statically, before the body ever runs, and
48
50
  // rejects the whole script on anything it has to evaluate. A `+`-joined description is a
@@ -411,6 +413,7 @@ const RESUME = {
411
413
  eval_dimensions: { type: "array", items: { type: "string" } },
412
414
  has_orient_artifacts: { type: "boolean" },
413
415
  has_spec_tree: { type: "boolean" },
416
+ has_board: { type: "boolean" },
414
417
  // The requirements registry — a fact, not a phase. See the COVERAGE block below for why it is
415
418
  // guarded on this bare boolean and never asked about through `probe resume --require`.
416
419
  has_requirements: { type: "boolean" },
@@ -624,6 +627,69 @@ const HAMMER = {
624
627
  required: ["ok", "verdict", "cut_list"],
625
628
  };
626
629
 
630
+ /** What every hammer dispatch is told, on the PASS path and the breaker path alike. */
631
+ const HAMMER_EXTRA =
632
+ "Run the census, compare against the BASELINE and never the ideal, and produce the cut list. " +
633
+ "Write the census as data to `reports/hammer-census.json` under the run's local root — the " +
634
+ "`reports/**` entry in your order's substrate names the directory — with schema_version 1, " +
635
+ "order_id, verdict (ship-now | ship-after-fixes | cannot-ship), cut_list, ship_blocking, breaker, " +
636
+ "baseline — beside the human-readable report. GATE L4's resolver reads that file and nothing " +
637
+ "else before it lets any answer set say `ship`; a census that lives only in your printed blocks " +
638
+ "cannot be read by a gate.";
639
+
640
+ /**
641
+ * A breaker routed the run to GATE H. This used to be a terminal return: the loop handed back
642
+ * `{status: "gate_h"}`, the close-out stamped the ledger `escalated` and retired the pointers, and
643
+ * the census, GATE H, the ship report and GATE L4 all happened AFTER the close, in the tech
644
+ * lead's prose — so the census reached no artifact, `gates.jsonl` held no H and no L4, and a later
645
+ * `--close shipped` was refused over the `escalated` fact already on the ledger. Measured on three
646
+ * consumer runs. AGENTS.md has always said what a breaker means: "ship what's green, never kill the
647
+ * run from outside". So the run does that itself, in this launch: the hammer census (an artifact),
648
+ * GATE H (a row), the ship report (with the verdict as it is, FAIL or not-evaluated included), and
649
+ * GATE L4 (a row) — and only then a close, `shipped` or `escalated`, that names the census.
650
+ *
651
+ * QA never ran on this path (it sits after a PASS), so the census is told so plainly. `qaFindings`
652
+ * is declared after the round loop and must not be read from here.
653
+ *
654
+ * @param {object} ret - The gate_h return the round loop built.
655
+ * @returns {Promise<object>} The RunReturn that actually ends the run.
656
+ */
657
+ async function settleAtGateH(ret) {
658
+ phase("Ship");
659
+ const h = await worker({
660
+ skill: "scope-hammer", operation: "hammer", schema: HAMMER, phase: "Ship", label: "hammer",
661
+ payload: { feature: slug, qa_findings: 0, hammer_proposals: ret.hammer_proposals || [], breaker: ret.breaker ?? null },
662
+ extra: HAMMER_EXTRA,
663
+ });
664
+ if (h.__failed) return await withWarnings(diedAt("H", h));
665
+ {
666
+ const g = await crossGate("H", "Ship", ["accept-cut-list", "ship-all", "ask"],
667
+ { verdict: h.verdict, cut_list: h.cut_list, breaker: ret.breaker ?? null, green_scopes: ret.green_scopes, hammer_proposals: ret.hammer_proposals });
668
+ if (g.stop) return await withWarnings(g.stop);
669
+ }
670
+ if (h.verdict === "cannot-ship") {
671
+ return await withWarnings({ ...ret, census: "cannot-ship", cut_list: h.cut_list });
672
+ }
673
+ const hVerdict = verdict === "pass" ? "PASS" : verdict === "fail" ? "FAIL" : "not-evaluated";
674
+ const ship = await cmd(`reduce ship --slug ${slug} --verdict ${hVerdict} --qa skipped`, "Ship", "ship-report");
675
+ if (!ship.ok) {
676
+ return await withWarnings({ ...ret, census: h.verdict, cut_list: h.cut_list, ship_report: `refused: ${ship.detail || `exit ${ship.exit_code}`}` });
677
+ }
678
+ {
679
+ const g = await crossGate("L4", "Ship", ["ship", "hold", "ask"], { verdict: hVerdict, census: h.verdict, cut_list: h.cut_list, breaker: ret.breaker ?? null });
680
+ if (g.stop) return await withWarnings(g.stop);
681
+ if (g.decision === "hold") return await withWarnings({ ...ret, census: h.verdict, cut_list: h.cut_list, l4: "hold" });
682
+ }
683
+ await advisory(`report export --slug ${slug}`, "Ship", "export-run");
684
+ await setRunStatus("shipped", "Ship");
685
+ return await withWarnings({
686
+ status: "shipped", verdict, rounds_used: round, after: "gate_h", breaker: ret.breaker ?? null,
687
+ census: h.verdict, cut_list: h.cut_list, green_scopes: ret.green_scopes,
688
+ unapplied_results: ret.unapplied_results || [], qa_findings: 0,
689
+ report: ship.detail || `shapeup/${slug}/REPORT.md`,
690
+ });
691
+ }
692
+
627
693
  // ---------------------------------------------------------------------------------------------
628
694
  // DISPATCH — three shapes, and none of them parses text.
629
695
  //
@@ -859,13 +925,15 @@ const attest = (phaseKey, phaseName, label) =>
859
925
  * rather than folding the gap into a phase that "completed".
860
926
  *
861
927
  * @param {string} gate - The gate name to report the abort under.
862
- * @param {string} phaseKey - The phase, which is also its order's file stem.
928
+ * @param {string} phaseKey - The phase.
863
929
  * @param {string} phaseName - Progress group.
930
+ * @param {string} [orderStem=phaseKey] - The order's file stem, when the phase was dispatched
931
+ * under a different operation (a board-only ANALYZE compiles `board.json`).
864
932
  * @returns {Promise<(object|null)>} An aborted RunReturn, or null when the leg closed (or the
865
933
  * question could not be asked — a probe that did not run proves nothing, and is logged as such).
866
934
  */
867
- async function requireLeg(gate, phaseKey, phaseName) {
868
- const ask = () => query(`probe leg --slug ${slug} --order "${phaseKey}"`, ORDERLEG, phaseName, `legcheck:${phaseKey}`);
935
+ async function requireLeg(gate, phaseKey, phaseName, orderStem = phaseKey) {
936
+ const ask = () => query(`probe leg --slug ${slug} --order "${orderStem}"`, ORDERLEG, phaseName, `legcheck:${orderStem}`);
869
937
  let leg = await ask();
870
938
  if (!leg || !leg.found) { log(`${gate} — could not ask the leg ledger about "${phaseKey}" (probe returned ${leg ? "no order" : "nothing"}); proceeding on the artifact alone.`); return null; }
871
939
  if (!leg.has_result || leg.applied) return null;
@@ -891,9 +959,9 @@ async function requireLeg(gate, phaseKey, phaseName) {
891
959
  * @param {string} phaseName - Progress group.
892
960
  * @returns {Promise<(object|null)>} An aborted RunReturn, or null when the artifact is there.
893
961
  */
894
- async function requirePhase(gate, phaseKey, phaseName) {
962
+ async function requirePhase(gate, phaseKey, phaseName, orderStem = phaseKey) {
895
963
  const r = await attest(phaseKey, phaseName, `require:${phaseKey}`);
896
- if (r.exit_code === 0) return await requireLeg(gate, phaseKey, phaseName);
964
+ if (r.exit_code === 0) return await requireLeg(gate, phaseKey, phaseName, orderStem);
897
965
  // Exit 6 is `probe resume --require`'s OWN documented code for "the artifact really is not on
898
966
  // disk" (kernel/probe/resume.mjs banner). Any other value — including -1, the courier's sentinel
899
967
  // for a tool call that never ran — is not that predicate answering "no"; it is the predicate never
@@ -1069,7 +1137,9 @@ async function closeIfTerminal(ret) {
1069
1137
  + (Array.isArray(ret.tripped_scopes) ? ` tripped_scopes=${ret.tripped_scopes.length}` : "")
1070
1138
  // A result the single writer never applied is named at the close, not folded into "not green".
1071
1139
  + (Array.isArray(ret.unapplied_results) && ret.unapplied_results.length ? ` unapplied_results=${ret.unapplied_results.length}` : "")
1072
- : `verdict=${ret.verdict ?? "?"} rounds=${ret.rounds_used ?? "?"} qa_findings=${ret.qa_findings ?? "?"}`;
1140
+ + (ret.census ? ` census=${ret.census}` : "") + (ret.l4 ? ` l4=${ret.l4}` : "") + (ret.ship_report ? ` ship_report=${ret.ship_report}` : "")
1141
+ : `verdict=${ret.verdict ?? "?"} rounds=${ret.rounds_used ?? "?"} qa_findings=${ret.qa_findings ?? "?"}`
1142
+ + (ret.after ? ` after=${ret.after} breaker=${ret.breaker ?? "?"} census=${ret.census ?? "?"} cut_list=${Array.isArray(ret.cut_list) ? ret.cut_list.length : "?"}` : "");
1073
1143
  // `--close-arm` hands the kernel the arm itself (not a status this file decided was terminal) —
1074
1144
  // a non-terminal arm (`paused`, `ok`) still exits 0 with no "decision" key, so the branches below
1075
1145
  // stay silent for it exactly as they did when this file's own guard returned early.
@@ -1254,8 +1324,28 @@ if (!rs.has_spec_tree) {
1254
1324
  const post = await requirePhase("ANALYZE", "analyze", "Analyze");
1255
1325
  if (post) return await withWarnings(post);
1256
1326
  await advisory(`reduce graph --slug ${slug}`, "Analyze", "graph:analyze");
1327
+ } else if (!rs.has_board) {
1328
+ // THE HALF THAT DOES NOT SURVIVE. The spec tree is committed; the board is per-machine and
1329
+ // gitignored, and ANALYZE writes both. A run after the first on a machine — and every run in a
1330
+ // fresh checkout — used to fast-forward on the committed half alone and build over no board:
1331
+ // GATE L2 crossed 0/0 tasks, the evaluator could read no `covers:` clause, and the requirements
1332
+ // projection said "no evidence" for a round whose static criteria all passed. The board-only
1333
+ // operation regenerates it from the tree without re-deriving the tree.
1334
+ log(`ANALYZE — spec tree on disk, no board: dispatching the board-only operation (slug ${slug})`);
1335
+ await setRunStatus("mapping", "Analyze");
1336
+ const b = await worker({
1337
+ skill: "ba-pitch-analyzer", operation: "board", schema: PHASE_OK, phase: "Analyze", label: "board",
1338
+ payload: { spec_folder: specFolder, feature: slug, lens: rs.lens },
1339
+ extra: "The spec tree is committed and FROZEN for this dispatch. Regenerate the per-machine board " +
1340
+ "under the run's tasks/ directory from the use cases on disk — every acceptance criterion " +
1341
+ "carrying its `(covers: REQ-…)` clause — and write nothing under the spec folder.",
1342
+ });
1343
+ if (b.__failed) return await withWarnings(diedAt("ANALYZE", b));
1344
+ const post = await requirePhase("ANALYZE", "analyze", "Analyze", "board");
1345
+ if (post) return await withWarnings(post);
1346
+ await advisory(`reduce graph --slug ${slug}`, "Analyze", "graph:board");
1257
1347
  } else {
1258
- const post = await fastForward("ANALYZE", "analyze", "Analyze", "spec tree already on disk");
1348
+ const post = await fastForward("ANALYZE", "analyze", "Analyze", "spec tree and board already on disk");
1259
1349
  if (post) return await withWarnings(post);
1260
1350
  }
1261
1351
 
@@ -1476,7 +1566,7 @@ while (verdict !== "pass" && round <= maxRounds) {
1476
1566
  const budget = await cmd(`verify budget --slug ${slug} --strict`, "Build", `budget:r${round}`);
1477
1567
  if (budget.exit_code === 6) {
1478
1568
  await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
1479
- return await withWarnings({ status: "gate_h", breaker: "deadline", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
1569
+ return await settleAtGateH({ status: "gate_h", breaker: "deadline", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
1480
1570
  }
1481
1571
 
1482
1572
  log(`BUILD round ${round} — ${scopes.length} scope(s), up to ${maxParallelScopes} at once, attempt budget ${attemptBudget}`);
@@ -1640,7 +1730,7 @@ while (verdict !== "pass" && round <= maxRounds) {
1640
1730
  if (census?.tripped) tripped.push(sid);
1641
1731
  }
1642
1732
  await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
1643
- return await withWarnings({
1733
+ return await settleAtGateH({
1644
1734
  status: "gate_h", breaker: tripped.length ? "attempt_budget" : "none", stalled: "no_green",
1645
1735
  tripped_scopes: tripped, unapplied_results: allUnapplied,
1646
1736
  hammer_proposals: allHammer, green_scopes: allGreen,
@@ -1755,13 +1845,13 @@ while (verdict !== "pass" && round <= maxRounds) {
1755
1845
  // SHIP" once EVAL is skipped, never spends another round waiting on a verdict nobody is producing.
1756
1846
  if (verdict === "pass" || verdict === "not-evaluated") break; // → QA → GATE H → ship
1757
1847
  if (g3.decision === "stop" || round >= maxRounds) {
1758
- return await withWarnings({ status: "gate_h", breaker: "outer", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
1848
+ return await settleAtGateH({ status: "gate_h", breaker: "outer", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
1759
1849
  }
1760
1850
  round += 1;
1761
1851
  }
1762
1852
 
1763
1853
  if (verdict !== "pass" && verdict !== "not-evaluated") {
1764
- return await withWarnings({ status: "gate_h", breaker: "outer", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
1854
+ return await settleAtGateH({ status: "gate_h", breaker: "outer", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
1765
1855
  }
1766
1856
 
1767
1857
  // ---- QA (post-PASS, pre-ship) — a level-up, never a gate. `--no-qa` answers it "skip". --------
@@ -1786,7 +1876,7 @@ phase("Ship");
1786
1876
  const h = await worker({
1787
1877
  skill: "scope-hammer", operation: "hammer", schema: HAMMER, phase: "Ship", label: "hammer",
1788
1878
  payload: { feature: slug, qa_findings: qaFindings, hammer_proposals: allHammer },
1789
- extra: "Run the census, compare against the BASELINE and never the ideal, and produce the cut list.",
1879
+ extra: HAMMER_EXTRA,
1790
1880
  });
1791
1881
  if (h.__failed) return await withWarnings(diedAt("H", h));
1792
1882
  if (h.verdict === "cannot-ship") {
@@ -1804,6 +1894,14 @@ if (h.verdict === "cannot-ship") {
1804
1894
  // default). Hardcoding PASS here is exactly the silent upgrade protocol.md's Rules forbid.
1805
1895
  const shipVerdict = verdict === "not-evaluated" ? "not-evaluated" : "PASS";
1806
1896
  const ship = await cmd(`reduce ship --slug ${slug} --verdict ${shipVerdict} --qa ${qaRan ? "run" : "skipped"}`, "Ship", "ship-report");
1897
+ // GATE L4 HAS A CALL SITE. It used to be a line of prose in the tech lead's references — "resolve
1898
+ // the gate itself before any of the above" — and a run reached its ship decision with no ledger row
1899
+ // for it. The resolver narrows `ship` on the census artifact the hammer just wrote.
1900
+ {
1901
+ const g = await crossGate("L4", "Ship", ["ship", "hold", "ask"], { verdict: shipVerdict, census: h.verdict, cut_list: h.cut_list });
1902
+ if (g.stop) return await withWarnings(g.stop);
1903
+ if (g.decision === "hold") return await withWarnings({ status: "aborted", aborted_at: "L4", reason: `GATE L4 answered "hold" (census ${h.verdict}, verdict ${shipVerdict})` });
1904
+ }
1807
1905
  await advisory(`report export --slug ${slug}`, "Ship", "export-run");
1808
1906
  // The run's own concurrency, printed once where the records are complete and before the next run
1809
1907
  // supersedes the trace. It is a projection over `receipts/dispatch.jsonl` and `legs.jsonl`, so it