shapeup-sdlc 3.5.0 → 3.7.0

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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/AGENTS.md +15 -4
  3. package/README.md +1 -1
  4. package/kernel/compile.mjs +55 -7
  5. package/kernel/harness.mjs +11 -4
  6. package/kernel/init/run-args.mjs +206 -0
  7. package/kernel/init/run.mjs +10 -0
  8. package/kernel/lib/paths.mjs +10 -0
  9. package/kernel/probe/attempts.mjs +135 -0
  10. package/kernel/probe/concurrency.mjs +31 -6
  11. package/kernel/probe/digest.mjs +15 -1
  12. package/kernel/probe/owner.mjs +4 -1
  13. package/kernel/probe/resume.mjs +314 -5
  14. package/kernel/probe/rounds.mjs +104 -0
  15. package/kernel/reduce/ingest.mjs +53 -12
  16. package/kernel/reduce/ship.mjs +15 -30
  17. package/kernel/reduce/snapshot.mjs +23 -2
  18. package/kernel/report/export.mjs +54 -2
  19. package/kernel/report/facts.mjs +24 -2
  20. package/{skills/tech-lead → kernel}/schemas/domain.schema.json +15 -12
  21. package/kernel/verify/envelope.mjs +2 -2
  22. package/kernel/verify/skills.mjs +1 -1
  23. package/package.json +1 -1
  24. package/skills/ba-pitch-analyzer/SKILL.md +1 -1
  25. package/skills/ba-pitch-analyzer/references/doc-schemas.md +6 -0
  26. package/skills/coach/SKILL.md +8 -2
  27. package/skills/hill-chart/SKILL.md +3 -4
  28. package/skills/scope-hammer/SKILL.md +11 -3
  29. package/skills/tech-lead/SKILL.md +10 -10
  30. package/skills/tech-lead/references/gates.md +48 -11
  31. package/skills/tech-lead/references/protocol.md +4 -2
  32. package/skills/tech-lead/workflows/shapeup-run.js +164 -40
  33. package/skills/translator/SKILL.md +1 -1
  34. /package/{skills/tech-lead → kernel}/schemas/gate-answers.schema.json +0 -0
  35. /package/{skills/tech-lead → kernel}/schemas/work-order.schema.json +0 -0
  36. /package/{skills/tech-lead → kernel}/schemas/work-result.schema.json +0 -0
@@ -30,13 +30,16 @@
30
30
  //
31
31
  // Usage: node kernel/harness.mjs probe concurrency --slug <slug> [--cwd <dir>] [--run-root <dir>]
32
32
  // [--round N] [--gap-s N] [--format json|table]
33
+ // [--require-run-args]
33
34
  // Exit: 0 = a report was produced with at least one usable leg · 1 = ran, and no leg in scope had
34
35
  // a usable interval (the report still prints, and says why) · 2 = malformed argv.
36
+ // With --require-run-args the report is skipped: 0 = run-args.json exists at this run root ·
37
+ // 6 = it does not (mirrors `probe resume --require`'s "artifact absent" convention).
35
38
 
36
39
  import { existsSync, readFileSync } from "node:fs";
37
40
  import { join, resolve } from "node:path";
38
41
  import { runArgs } from "../lib/argv.mjs";
39
- import { localRoot, runIdFromRoot, RECEIPT_FILE } from "../lib/paths.mjs";
42
+ import { localRoot, runIdFromRoot, RECEIPT_FILE, RUN_ARGS_FILE } from "../lib/paths.mjs";
40
43
 
41
44
  /** The round-addressed order id forms `<scope>-r<N>-a<M>` and `<phase>-r<N>`. */
42
45
  const ROUND_SUFFIX = /^(.*?)-r(\d+)(?:-a(\d+))?$/;
@@ -301,16 +304,18 @@ export function summarise(index, legs) {
301
304
  /**
302
305
  * Which fan-out width the run was launched with — read, never assumed.
303
306
  *
304
- * The dial is written into `run-args.json` by the launcher. It is absent from every run recorded so
305
- * far, so the honest answer is the effective default WITH the fact that it is a default: reporting
306
- * `4` unqualified would assert an operator choice nobody made.
307
+ * The dial is written into `run-args.json` by `harness init run-args` (GATE L0.9b), the kernel
308
+ * writer tech-lead invokes right before the launch. A run opened before that writer existed, or one
309
+ * whose launcher never passed `--parallel-scopes`, still has no file or no key — so the honest
310
+ * answer stays the effective default WITH the fact that it is a default: reporting `4` unqualified
311
+ * would assert an operator choice nobody made.
307
312
  *
308
313
  * @param {string} runRoot - The run's LOCAL root.
309
314
  * @returns {{max_parallel_scopes:number, source:string}} The value and where it came from.
310
315
  */
311
316
  export function dialFrom(runRoot) {
312
317
  try {
313
- const a = JSON.parse(readFileSync(join(runRoot, "run-args.json"), "utf8"));
318
+ const a = JSON.parse(readFileSync(join(runRoot, RUN_ARGS_FILE), "utf8"));
314
319
  const n = Number(a?.maxParallelScopes);
315
320
  if (Number.isFinite(n) && n >= 1) return { max_parallel_scopes: n, source: "run-args" };
316
321
  return { max_parallel_scopes: DEFAULT_MAX_PARALLEL_SCOPES, source: "default (run-args.json declares none)" };
@@ -474,7 +479,7 @@ export function table(r) {
474
479
  /** The typed argv contract (see `./lib/argv.mjs`). */
475
480
  export const ARGV_SPEC = {
476
481
  usage: "harness.mjs probe concurrency (--slug <slug> | --run-root <dir>) [--cwd <dir>] [--round N] " +
477
- "[--gap-s N] [--format json|table]",
482
+ "[--gap-s N] [--format json|table] [--require-run-args]",
478
483
  _: { arity: 0, max: 0, name: "(no positional operands)" },
479
484
  slug: { type: "str" },
480
485
  // The archived-trace and post-export cases, and the same escape `verify t0 --out` has: a caller
@@ -484,6 +489,10 @@ export const ARGV_SPEC = {
484
489
  round: { type: "int", min: 1 },
485
490
  "gap-s": { type: "int", min: 1, default: DEFAULT_GAP_S },
486
491
  format: { type: "enum", values: ["json", "table"], default: "json" },
492
+ // The positive enforcer for the launch record (see the banner below). Skips the concurrency
493
+ // report entirely — this is a presence check, not a measurement, and the two must not be
494
+ // confused by sharing an exit code.
495
+ "require-run-args": { type: "flag" },
487
496
  };
488
497
 
489
498
  /**
@@ -492,6 +501,14 @@ export const ARGV_SPEC = {
492
501
  * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
493
502
  * @returns {void} Exits 0 when at least one leg had a usable interval, 1 when none did — and the
494
503
  * report prints either way, because "nothing was measurable" is the answer, not an error.
504
+ *
505
+ * `--require-run-args` is a different question with its own exit convention, mirroring
506
+ * `probe resume --require`'s 0 (satisfied) / 6 (artifact absent): it never reaches the report at
507
+ * all. The launch record had exactly one reader (`dialFrom()`, below) and zero enforcers — a
508
+ * run missing it proceeded green with a silently substituted default, "indistinguishable from an
509
+ * operator choice" (`gates.md` L0.9b). This flag is what `shapeup-run.js` calls at Preflight, before
510
+ * ORIENT, so that state stops being invisible: this module already owns run-root resolution and the
511
+ * file's path, so the check is a few lines here rather than a new kernel entry point.
495
512
  */
496
513
  export function cli(rawArgv) {
497
514
  const args = runArgs(ARGV_SPEC, rawArgv);
@@ -504,6 +521,14 @@ export function cli(rawArgv) {
504
521
  ? resolve(args.runRoot)
505
522
  : localRoot(resolve(args.cwd || process.cwd()), args.slug);
506
523
 
524
+ if (args.requireRunArgs) {
525
+ const path = join(runRoot, RUN_ARGS_FILE);
526
+ const present = existsSync(path);
527
+ console.log(JSON.stringify({ ok: present, run_root: runRoot, run_args_path: path,
528
+ reason: present ? null : "no run-args.json at this run root — GATE L0.9b's launch record was never written before this launch" }));
529
+ process.exit(present ? 0 : 6);
530
+ }
531
+
507
532
  const r = report(runRoot, { round: args.round ?? null, gapS: args.gapS });
508
533
  console.log(args.format === "table" ? table(r) : JSON.stringify(r));
509
534
  process.exit(r.launches.length ? 0 : 1);
@@ -6,7 +6,9 @@
6
6
  // Script-first by design: regex over known log formats is free (no model tokens); an
7
7
  // unrecognized line becomes a "raw" triple (file/line unknown) rather than being silently
8
8
  // dropped, so a Sonnet fallback (or a human) still has something to look at — this module never
9
- // invents a file:line it didn't find in the text.
9
+ // invents a file:line it didn't find in the text. `file` and `line` are independent: a
10
+ // diagnostic that names a file but no line number (a resource-compiler error, for example)
11
+ // still yields its file — `line` stays null rather than being guessed at.
10
12
  //
11
13
  // Zero dependencies, zero network — same discipline as oracles/*.
12
14
 
@@ -19,6 +21,18 @@ const PATTERNS = [
19
21
  { re: /^(?:✗|not ok\b.*?)[^()]*\((.+?):(\d+)\)\s*$/, kind: "test-failure" },
20
22
  // ESLint/tsc style: "path/to/file.ts:12:34 - error TS2345: message"
21
23
  { re: /^(.+?):(\d+):\d+\s*[-–]\s*(?:error|warning)\b.*$/, kind: "compiler-diagnostic" },
24
+ // File-level diagnostic with NO line number: "resource.xml: error: message" or
25
+ // "resource.xml - fatal error: message" (resource compilers and linkers report this way —
26
+ // the failure is the whole file, so there is no line to cite). The file must look like a
27
+ // path (ends in a dotted extension, no embedded whitespace/colon) so this stays anchored to
28
+ // real diagnostics rather than matching arbitrary prose that happens to contain "error:".
29
+ { re: /^([^\s:]+?\.[A-Za-z0-9]{1,10})\s*[:\-–—]\s*(?:fatal\s+error|error|warning)\b.*$/i, kind: "compiler-diagnostic" },
30
+ // Bundler style: "ERROR in ./src/components/Foo.tsx" (webpack et al.) — file, no line. The
31
+ // captured token must look like a path — leads with "./"/"../", or ends in a dotted extension
32
+ // of 1-10 alnum chars (same anchor the sibling pattern above uses) — so prose after "ERROR in"
33
+ // ("ERROR in the build pipeline", "ERROR in test suite failed to run") is left unmatched
34
+ // instead of handing back a fabricated file.
35
+ { re: /^(?:ERROR|WARNING)\s+in\s+(\.{1,2}\/[^\s:]*|[^\s:]+\.[A-Za-z0-9]{1,10})\b/i, kind: "compiler-diagnostic" },
22
36
  // Generic "Error: message" line followed later by a stack — capture the message alone.
23
37
  { re: /^\s*(?:Error|TypeError|ReferenceError|AssertionError)\s*:\s*(.+)$/, kind: "error-message" },
24
38
  ];
@@ -46,8 +46,11 @@ import { matchesAny } from "../../hooks/sandbox-guard.mjs";
46
46
  */
47
47
  export function ownership(path, scopes, cwd = null) {
48
48
  const rel = String(path).replace(/^\.\//, "");
49
- const writers = (scopes || []).filter((s) => matchesAny(rel, s.allowed)).map((s) => s.scope_id).sort();
50
49
  const shared = (scopes || []).filter((s) => matchesAny(rel, s.shared || [])).map((s) => s.scope_id).sort();
50
+ // `writers` is admits-the-path, the same union the sandbox fence composes (allowed ++ shared) —
51
+ // a path declared only in a contract's `shared` list is still a scope this path may write, and
52
+ // must read as owned rather than UNOWNED. `shared_with` (below) stays the narrower subset.
53
+ const writers = (scopes || []).filter((s) => matchesAny(rel, s.allowed) || matchesAny(rel, s.shared || [])).map((s) => s.scope_id).sort();
51
54
  return { path: rel, owner: electOwner(rel, scopes), writers, shared_with: shared, exists: cwd ? existsSync(join(cwd, rel)) : null };
52
55
  }
53
56
 
@@ -59,18 +59,89 @@
59
59
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
60
60
  import { dirname, join, resolve } from "node:path";
61
61
  import { runArgs } from "../lib/argv.mjs";
62
- import { splitFrontmatter } from "../lib/contract.mjs";
62
+ import { splitFrontmatter, uncoerce } from "../lib/contract.mjs";
63
63
  import { globToRegExp } from "../verify/spec.mjs";
64
64
  import {
65
65
  intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
66
66
  orientDir, activeOrder, usecasesDir, breadboard, receipt, readReceipt, requirements,
67
+ exportRunDir,
67
68
  } from "../lib/paths.mjs";
68
69
  import { evalVerdict } from "./eval.mjs";
70
+ import { collectRun, writeRun } from "../report/export.mjs";
69
71
 
70
72
  /** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
71
73
  * not a write — the whole point of this file is that a write nobody validates is a write nobody
72
- * can trust. */
73
- export const RUN_STATUSES = ["orienting", "mapping", "building", "evaluating", "shipped", "escalated"];
74
+ * can trust. `aborted` is the terminal counterpart `escalated` already was: a gate
75
+ * resolving "abort", or a hard stop, ends the run the same way an operator-declared escalation
76
+ * does — neither resumes on relaunch — so both are TERMINAL_STATUSES below. */
77
+ export const RUN_STATUSES = ["orienting", "mapping", "building", "evaluating", "shipped", "escalated", "aborted"];
78
+
79
+ /**
80
+ * The statuses a run does not come back from. `closeRun` refuses every other member of
81
+ * {@link RUN_STATUSES} — `orienting`/`mapping`/`building`/`evaluating` are mid-flight, and writing
82
+ * a close over one of those would stamp `closed_at` on a run a relaunch is still meant to resume.
83
+ */
84
+ export const TERMINAL_STATUSES = ["shipped", "aborted", "escalated"];
85
+
86
+ /**
87
+ * The RunReturn union (`kernel/schemas/domain.schema.json` `$defs/RunReturn.properties.status.enum`)
88
+ * mapped to what closing the run means for each arm — the derivation `closeIfTerminal`
89
+ * (`skills/tech-lead/workflows/shapeup-run.js`) now reads instead of a hand-typed
90
+ * `status !== "aborted" && status !== "shipped"` pair that referenced {@link TERMINAL_STATUSES}
91
+ * zero times and so could not see when a new arm went unhandled.
92
+ *
93
+ * A lookup answers one of three ways, and the distinction is load-bearing for what this map must
94
+ * catch: an arm ABSENT from this object (never listed as a key) returns `undefined` — an arm the
95
+ * schema carries that nobody has mapped, which a caller must treat as a defect, never as "fine to
96
+ * skip". An arm mapped to a terminal status closes the run as that status. An arm mapped to `null`
97
+ * is EXPLICITLY non-terminal — `paused` resumes on relaunch and `ok` is one inner round finishing,
98
+ * not the run — so it is a key with a falsy value, not an omission a reader could mistake for "not
99
+ * decided yet".
100
+ *
101
+ * `gate_h` → `escalated`: the breaker that tripped (`outer`/`inner`/`deadline`) travels in
102
+ * `close_cause`, never as a new member of {@link TERMINAL_STATUSES} or {@link RUN_STATUSES} — a
103
+ * circuit breaker tripping is not a new way a run ends, it is the reason an existing one
104
+ * (`escalated`) fires this time.
105
+ *
106
+ * @type {Object<string, (string|null)>}
107
+ */
108
+ export const RUN_RETURN_CLOSE = {
109
+ shipped: "shipped",
110
+ aborted: "aborted",
111
+ gate_h: "escalated",
112
+ paused: null,
113
+ ok: null,
114
+ };
115
+
116
+ /**
117
+ * Resolve one RunReturn arm to a close outcome and, when the arm is terminal, perform the close —
118
+ * the single call `closeIfTerminal` makes instead of deciding locally which arms are terminal.
119
+ *
120
+ * @param {string} cwd - Project root.
121
+ * @param {string} slug - Feature slug.
122
+ * @param {string} arm - A `RunReturn.status` value.
123
+ * @param {(string|null)} [cause] - Why the run ended there; only used when `arm` is terminal.
124
+ * @param {boolean} [withExport] - Forwarded to {@link closeRun} — defaults true. The one caller
125
+ * that ever passes `false` is a test fixture proving the export assertion is real — a check that
126
+ * cannot fail did not happen; production call sites never set this.
127
+ * @returns {({ok:true, arm:string, terminal:false, reason:string} |
128
+ * {ok:false, arm:string, reason:string} |
129
+ * ({ok:boolean, arm:string, terminal:true} & ReturnType<typeof closeRun>))} `terminal:false` when
130
+ * `arm` maps to `null` (nothing closed, not an error). `ok:false` with no `terminal` field when
131
+ * `arm` is not a key of {@link RUN_RETURN_CLOSE} at all — a schema arm this map has not been
132
+ * taught, which must never be silently treated as non-terminal. Otherwise the {@link closeRun}
133
+ * outcome, tagged with the arm that produced it.
134
+ */
135
+ export function closeArm(cwd, slug, arm, cause = null, withExport = true) {
136
+ if (!Object.hasOwn(RUN_RETURN_CLOSE, arm)) {
137
+ return { ok: false, arm, reason: `closeArm: "${arm}" is not a RunReturn arm this kernel maps — known arms: ${Object.keys(RUN_RETURN_CLOSE).join(", ")}` };
138
+ }
139
+ const status = RUN_RETURN_CLOSE[arm];
140
+ if (!status) {
141
+ return { ok: true, arm, terminal: false, reason: `"${arm}" is explicitly non-terminal — no close` };
142
+ }
143
+ return { ...closeRun(cwd, slug, { status, cause, withExport }), arm, terminal: true };
144
+ }
74
145
 
75
146
  /** ORIENT's four artifacts (skills/orient/SKILL.md §Outputs): three by exact name, plus a spike
76
147
  * whose filename carries the area it spiked (`spike-<area>.md`, or `spike-not-needed.md` when
@@ -462,6 +533,215 @@ export function setRunStatus(cwd, slug, status) {
462
533
  return { ok: true, path: p, status };
463
534
  }
464
535
 
536
+ /**
537
+ * Rewrite `status:`, `closed_at:`, `closed_status:` and `close_cause:` together, in one pass,
538
+ * appending any of the last three that a pre-migration ledger carries no line for yet (the same
539
+ * tolerant-of-old-ledgers discipline `close_cause` itself shipped under).
540
+ *
541
+ * @param {string} body - The ledger's current text.
542
+ * @param {{status:string, closedAt:string, cause:(string|null)}} o - What to write. `cause` is
543
+ * already normalized prose (newlines collapsed, truncated) — this function only `uncoerce`s it.
544
+ * @returns {string} The rewritten text.
545
+ */
546
+ function writeCloseLines(body, { status, closedAt, cause }) {
547
+ const causeLine = `close_cause: ${uncoerce(cause || null)}`;
548
+ const closedStatusLine = `closed_status: ${status}`;
549
+ let out = body
550
+ .replace(/^status:.*$/m, `status: ${status}`)
551
+ .replace(/^closed_at:.*$/m, `closed_at: ${closedAt}`);
552
+ out = /^closed_status:.*$/m.test(out)
553
+ ? out.replace(/^closed_status:.*$/m, closedStatusLine)
554
+ // A ledger written before this field existed carries no line to replace — appended right after
555
+ // `closed_at:`, the one line every TERMINAL_STATUSES write also touches.
556
+ : out.replace(/^closed_at:.*$/m, (m) => `${m}\n${closedStatusLine}`);
557
+ out = /^close_cause:.*$/m.test(out)
558
+ ? out.replace(/^close_cause:.*$/m, causeLine)
559
+ : out.replace(/^closed_at:.*$/m, (m) => `${m}\n${causeLine}`);
560
+ return out;
561
+ }
562
+
563
+ /**
564
+ * Export a just-closed run's own records into fact tables, best-effort
565
+ * (`report export`, `kernel/report/export.mjs`). {@link closeRun} calls this for every terminal
566
+ * status except `"shipped"`: the Ship phase (`skills/tech-lead/workflows/shapeup-run.js`) already
567
+ * calls `report export` itself, several lines before this file's own close-out runs, and that call
568
+ * site is left untouched on purpose — the shipped path's export stays byte-comparable to what it
569
+ * wrote before Stage 2. `aborted`, `escalated` and `gate_h` (which closes as `escalated`, see
570
+ * {@link RUN_RETURN_CLOSE}) never reached that call site at all, because it sits inside a phase
571
+ * those endings never enter — so a run ending any of them left its whole trace (orders, results,
572
+ * T0 verdicts, hook decisions, `graph.jsonl`) in the gitignored LOCAL tier with nothing durable
573
+ * surviving the next `init run`'s wipe. This is the read that was missing for those endings, run at
574
+ * the one point every one of them passes through: the close itself.
575
+ *
576
+ * FAIL-OPEN, the hook discipline (CLAUDE.md) extended to a write that is not a hook: an export that
577
+ * cannot write — a blocked or missing exports directory, a full disk — must never turn a close that
578
+ * DID take into one that looks like it did not. The three facts {@link closeRun} just wrote
579
+ * (`closed_status`/`close_cause`/`closed_at`) are never touched by this function; a failure here is
580
+ * handed back to the caller as a warning string, never thrown.
581
+ *
582
+ * @param {string} cwd - Project root.
583
+ * @param {string} slug - Feature slug.
584
+ * @returns {(string|null)} A one-line warning when the export did not complete; `null` on success.
585
+ */
586
+ function exportOnClose(cwd, slug) {
587
+ try {
588
+ const collected = collectRun(cwd, slug);
589
+ if (!collected) return `export on close: no readable receipt for "${slug}" — nothing to export`;
590
+ writeRun(collected, exportRunDir(cwd, collected.run_id ?? slug));
591
+ return null;
592
+ } catch (e) {
593
+ return `export on close: ${e.message}`;
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Close the run: a terminal status, its cause, and a close timestamp, written together in ONE
599
+ * pass.
600
+ *
601
+ * Measured: after an EVAL worker escalated and the run aborted, `harness-run.md` still read
602
+ * `status: evaluating`, `closed_at: ~`, with no cause recorded anywhere — a live EVAL and a dead
603
+ * one were indistinguishable from the trace alone. Two writers made that possible: `setRunStatus`
604
+ * above replaces the `status:` line and NOTHING ELSE, and `closed_at` was written exactly once, as
605
+ * the literal `~`, by `init run` — nothing ever replaced it. This function is the one call site
606
+ * that closes a run, so a terminal RunReturn cannot leave one of the three facts behind.
607
+ *
608
+ * Refuses rather than silently no-ops, the same discipline as `setRunStatus`: an absent ledger, one
609
+ * missing the lines this writes, or a non-terminal `status` (closing a run still `building` would
610
+ * stamp a live run as done) is a fact to act on, not a write to skip quietly.
611
+ *
612
+ * THE ONCE-ONLY GUARD READS `closed_status`, NEVER `status`. REWORK (round 2): the guard used to key
613
+ * on `before.status`, and `status:` is a LIVE field every phase rewrites via `setRunStatus` —
614
+ * including the product's own ship path, which stamps `status: shipped`
615
+ * (`skills/tech-lead/workflows/shapeup-run.js`'s Ship phase) immediately before this call runs. So a
616
+ * run closed `aborted` at Preflight on one launch, relaunched, and carried through to a `shipped`
617
+ * RunReturn on a later one had its `status:` line rewritten to `shipped` by that ordinary phase
618
+ * traffic BEFORE `closeIfTerminal` ever called this function — the guard read `before.status ===
619
+ * "shipped"`, matched the very close it was about to perform, and treated a run that was actually
620
+ * closed `aborted` as already closed `shipped`: `ok:true`, an "idempotent no-op" that silently kept
621
+ * the abort's own `closed_at`/`close_cause` under a `status:` line now reading `shipped`. `closed_status`
622
+ * is written ONLY here, exactly once per distinct close-writing call, so nothing between two calls to
623
+ * this function can move it — it is the one field that actually answers "has this run been closed,
624
+ * and to what" regardless of how many times `status:` has been rewritten since.
625
+ *
626
+ * WHAT A SECOND CLOSE MEANS, decided explicitly rather than left implicit. `run_id` is reused across
627
+ * relaunches by design (AGENTS.md), so "the run's close" and "this launch's own close" are two
628
+ * different facts a single `closed_at`/`close_cause` pair cannot both hold:
629
+ * - The IDENTICAL status and the IDENTICAL cause is the ordinary case a retried or duplicated call
630
+ * produces (the same `withWarnings` call, or a relaunch that re-executes an already-applied
631
+ * close) — a true no-op, `ok:true`, nothing rewritten.
632
+ * - The SAME terminal status but a DIFFERENT cause is a SECOND, real close — most often a later
633
+ * relaunch aborting again for its own reason, or shipping again after an earlier ship's close
634
+ * record was never superseded. Discarding it (the pre-rework behavior) silently drops the later
635
+ * launch's own reason with no trace of the loss. It is recorded instead: this call's cause
636
+ * becomes the ledger's `close_cause`, folded together with the prior cause it is superseding —
637
+ * the earlier fact survives inside the new line rather than the ledger simply losing it — and the
638
+ * return carries `superseded:true` so a caller (`closeIfTerminal`) can flag the trace as degraded
639
+ * rather than reporting a clean success.
640
+ * - A DIFFERENT terminal status altogether (aborted vs. shipped) is refused outright — flipping the
641
+ * actual OUTCOME of a run after the fact is not a fact a later launch gets to silently overwrite,
642
+ * so the original `closed_status`/`closed_at`/`close_cause` are left completely untouched and
643
+ * handed back to the caller.
644
+ *
645
+ * @param {string} cwd - Project root.
646
+ * @param {string} slug - Feature slug.
647
+ * @param {{status:string, cause:(string|null), withExport?:boolean}} o - The terminal status (one
648
+ * of {@link TERMINAL_STATUSES}) and why the run ended there. `cause` travels through `uncoerce`
649
+ * (the one dialect `harness-run.md`'s frontmatter is read and written in), so free prose — quotes
650
+ * and colons included — round-trips as one frontmatter line; an embedded newline is collapsed to
651
+ * a space first, because this dialect is line-based and could not carry one either way.
652
+ * `withExport` (default true) gates {@link exportOnClose} — every status except `"shipped"` runs
653
+ * it on a successful close; `false` exists only for a test fixture proving the export assertion
654
+ * is real, never for a production call site.
655
+ * @returns {{ok:boolean, path:string, status:string, closed_at?:string, cause?:(string|null),
656
+ * reason?:string, closed_status?:string, superseded?:boolean, decision?:string,
657
+ * prior_cause?:(string|null), prior_closed_at?:string, export_warning?:string}} Outcome. A refused
658
+ * overwrite (already closed with a DIFFERENT terminal status) carries
659
+ * `closed_status`/`closed_at`/`cause` naming what is actually on disk. A successful supersede
660
+ * (same status, different cause) carries `superseded:true`, `decision:"superseded"` (the
661
+ * one-token signal the courier boundary in `shapeup-run.js` relays verbatim — see its own
662
+ * `cmd()` banner) and the prior close it folded in. Any successful, non-`"shipped"` close carries
663
+ * `export_warning` when {@link exportOnClose} could not write the run's fact tables — the close
664
+ * itself still stands; this is advisory only.
665
+ */
666
+ export function closeRun(cwd, slug, { status, cause = null, withExport = true } = {}) {
667
+ const p = harnessRun(cwd, slug);
668
+ if (!TERMINAL_STATUSES.includes(status)) {
669
+ return { ok: false, path: p, status, reason: `closeRun: "${status}" is not terminal — expected one of ${TERMINAL_STATUSES.join(" | ")}` };
670
+ }
671
+ if (!existsSync(p)) {
672
+ return { ok: false, path: p, status, reason: `no harness-run.md for slug "${slug}" — open the run with harness init run (GATE L0.1) before closing it` };
673
+ }
674
+ let body = readFileSync(p, "utf8");
675
+ if (!/^status:.*$/m.test(body) || !/^closed_at:.*$/m.test(body)) {
676
+ return { ok: false, path: p, status, reason: `harness-run.md carries no "status:"/"closed_at:" line to replace — the ledger's frontmatter is malformed (references/protocol.md)` };
677
+ }
678
+
679
+ // The Ship phase (`shapeup-run.js`) already exports a shipped run itself, before this call ever
680
+ // runs — Stage 2 adds the endings that wrote nothing, and leaves that path untouched.
681
+ const shouldExport = withExport && status !== "shipped";
682
+ const withExportWarning = (result) => {
683
+ if (!shouldExport) return result;
684
+ const warning = exportOnClose(cwd, slug);
685
+ return warning ? { ...result, export_warning: warning } : result;
686
+ };
687
+
688
+ // Truncated, not elided: a cause this long has already done its job in the run's own log — the
689
+ // ledger line is a pointer back to it, not the full transcript. Newlines are collapsed to spaces
690
+ // FIRST — this dialect is line-based, so a raw embedded newline would split one field into a value
691
+ // line and a stray, unparsed one.
692
+ const normCause = String(cause ?? "").replace(/\r?\n/g, " ").trim().slice(0, 4000) || null;
693
+
694
+ const before = parseFrontmatter(body);
695
+ const priorClosedStatus = before.closed_status && before.closed_status !== "~" ? before.closed_status : null;
696
+ const priorClosedAt = before.closed_at && before.closed_at !== "~" ? before.closed_at : null;
697
+ const priorCause = before.close_cause && before.close_cause !== "~" ? before.close_cause : null;
698
+
699
+ if (priorClosedStatus && priorClosedAt) {
700
+ if (priorClosedStatus === status && normCause === priorCause) {
701
+ // The identical fact, restated — a retried or duplicated call costs nothing.
702
+ return withExportWarning({ ok: true, path: p, status, closed_at: priorClosedAt, cause: priorCause, decision: "idempotent", reason: `already closed as "${status}" at ${priorClosedAt} — idempotent no-op` });
703
+ }
704
+ if (priorClosedStatus !== status) {
705
+ // A DIFFERENT terminal status over an already-closed run — refused outright, the original
706
+ // close left completely untouched so the caller can see what it was refused permission to
707
+ // destroy, rather than losing it silently.
708
+ return {
709
+ ok: false, path: p, status,
710
+ reason: `closeRun: this run is already closed as "${priorClosedStatus}" at ${priorClosedAt} (cause: ${JSON.stringify(priorCause)}) — refusing to overwrite it with "${status}". A terminal close is a once-only fact; the first cause is not destroyed.`,
711
+ closed_status: priorClosedStatus, closed_at: priorClosedAt, cause: priorCause,
712
+ };
713
+ }
714
+ // SAME terminal status, a DIFFERENT cause — a second, real close (see the function banner's
715
+ // "what a second close means"). Superseded, not discarded: the prior cause is folded into the
716
+ // new line rather than lost, and the return says so explicitly.
717
+ const closedAt = new Date().toISOString();
718
+ const foldedCause = `${normCause || "no reason recorded"} — supersedes an earlier close recorded ${priorClosedAt} (cause: ${JSON.stringify(priorCause)})`.slice(0, 4000);
719
+ body = writeCloseLines(body, { status, closedAt, cause: foldedCause });
720
+ try { writeFileSync(p, body); } catch (e) {
721
+ return { ok: false, path: p, status, reason: `could not write the ledger: ${e.message}` };
722
+ }
723
+ const afterSup = parseFrontmatter(readFileSync(p, "utf8"));
724
+ if (afterSup.status !== status || !afterSup.closed_at || afterSup.closed_at === "~") {
725
+ return { ok: false, path: p, status, reason: `wrote the superseding close but the ledger reads back status="${afterSup.status}" closed_at="${afterSup.closed_at}" — the write did not take` };
726
+ }
727
+ return withExportWarning({
728
+ ok: true, path: p, status, closed_at: afterSup.closed_at, cause: afterSup.close_cause ?? null,
729
+ superseded: true, decision: "superseded", prior_cause: priorCause, prior_closed_at: priorClosedAt,
730
+ });
731
+ }
732
+
733
+ const closedAt = new Date().toISOString();
734
+ body = writeCloseLines(body, { status, closedAt, cause: normCause });
735
+ try { writeFileSync(p, body); } catch (e) {
736
+ return { ok: false, path: p, status, reason: `could not write the ledger: ${e.message}` };
737
+ }
738
+ const after = parseFrontmatter(readFileSync(p, "utf8"));
739
+ if (after.status !== status || !after.closed_at || after.closed_at === "~") {
740
+ return { ok: false, path: p, status, reason: `wrote the close but the ledger reads back status="${after.status}" closed_at="${after.closed_at}" — the write did not take` };
741
+ }
742
+ return withExportWarning({ ok: true, path: p, status, closed_at: after.closed_at, cause: after.close_cause ?? null, decision: "closed" });
743
+ }
744
+
465
745
  /**
466
746
  * Point the substrate pointer at the order about to be executed.
467
747
  *
@@ -488,13 +768,21 @@ export function writeActiveOrder(cwd, slug, orderPath) {
488
768
 
489
769
  /** The typed argv contract (see `./lib/argv.mjs`). */
490
770
  export const ARGV_SPEC = {
491
- usage: "harness.mjs probe resume --slug <slug> [--cwd <dir>] [--require <phase> | --set-status <status> | --set-active-order <path>]",
771
+ usage: "harness.mjs probe resume --slug <slug> [--cwd <dir>] " +
772
+ "[--require <phase> | --set-status <status> | --set-active-order <path> | " +
773
+ "--close <status> | --close-arm <RunReturn.status> [--cause <text>]]",
492
774
  _: { arity: 0, max: 0, name: "(no positional operands)" },
493
775
  slug: { type: "str", required: true },
494
776
  cwd: { type: "path" },
495
777
  require: { type: "enum", values: PHASES },
496
778
  "set-status": { type: "enum", values: RUN_STATUSES },
497
779
  "set-active-order": { type: "str" },
780
+ // The one call site that stamps a terminal status, its cause and closed_at together.
781
+ close: { type: "enum", values: TERMINAL_STATUSES },
782
+ // Not `--close`: the caller (shapeup-run.js's closeIfTerminal) hands over a RunReturn arm, never
783
+ // a status it decided was terminal itself — RUN_RETURN_CLOSE/closeArm above make that call.
784
+ "close-arm": { type: "str" },
785
+ cause: { type: "str" },
498
786
  };
499
787
 
500
788
  /**
@@ -508,11 +796,15 @@ export function cli(rawArgv) {
508
796
  const args = runArgs(ARGV_SPEC, rawArgv);
509
797
  const cwd = args.cwd || process.cwd();
510
798
 
511
- const ops = [args.require && "--require", args.setStatus && "--set-status", args.setActiveOrder && "--set-active-order"].filter(Boolean);
799
+ const ops = [args.require && "--require", args.setStatus && "--set-status", args.setActiveOrder && "--set-active-order", args.close && "--close", args.closeArm && "--close-arm"].filter(Boolean);
512
800
  if (ops.length > 1) {
513
801
  process.stderr.write(JSON.stringify({ error: "conflicting_flags", flags: ops, expected: "one operation per invocation" }) + "\n");
514
802
  process.exit(2);
515
803
  }
804
+ if (args.cause !== undefined && !args.close && !args.closeArm) {
805
+ process.stderr.write(JSON.stringify({ error: "conflicting_flags", flags: ["--cause"], expected: "--cause is only meaningful with --close or --close-arm" }) + "\n");
806
+ process.exit(2);
807
+ }
516
808
 
517
809
  // The post-condition. It prints the SAME ResumeState the derivation prints — plus which phase was
518
810
  // asked about and whether its artifact is there — so a caller that wants to act on the facts
@@ -541,6 +833,23 @@ export function cli(rawArgv) {
541
833
  process.exit(r.ok ? 0 : 3);
542
834
  }
543
835
 
836
+ if (args.close) {
837
+ const r = closeRun(cwd, args.slug, { status: args.close, cause: args.cause ?? null });
838
+ console.log(JSON.stringify(r));
839
+ process.exit(r.ok ? 0 : 3);
840
+ }
841
+
842
+ // The arm-derived close: the caller hands a RunReturn arm and this kernel decides — via
843
+ // RUN_RETURN_CLOSE/closeArm above — whether it is terminal and, if so, what status it closes as.
844
+ // Exit 0 for both a real close AND a correctly-declined non-terminal arm (`terminal:false`) —
845
+ // neither is an error the caller (shapeup-run.js's closeIfTerminal) should treat as failed; only
846
+ // an arm this map does not recognize at all, or a close `closeRun` itself refuses, exits non-zero.
847
+ if (args.closeArm) {
848
+ const r = closeArm(cwd, args.slug, args.closeArm, args.cause ?? null);
849
+ console.log(JSON.stringify(r));
850
+ process.exit(r.ok ? 0 : 3);
851
+ }
852
+
544
853
  console.log(JSON.stringify(deriveResumeState(cwd, args.slug)));
545
854
  process.exit(0);
546
855
  }
@@ -0,0 +1,104 @@
1
+ // rounds — how many BUILD/EVAL rounds a run actually reached, derived from disk.
2
+ //
3
+ // WHY THIS EXISTS (measured, not theorized).
4
+ //
5
+ // `harness-run.md`'s `rounds_used` frontmatter is written ONCE, at GATE L0.1 (`init run`), as `0`,
6
+ // and nothing in the round loop ever rewrites it — the orchestrator's own RunReturn carries the
7
+ // real count, but that value lives in a JS variable a relaunch loses, and it never reached the
8
+ // ledger. `reduce ship`'s own derivation partly compensated by counting the highest
9
+ // `evaluate-r<N>.json` result on disk, which is right for "rounds the judge saw" and wrong for
10
+ // "rounds the run built": measured after two full BUILD rounds with neither reaching EVAL,
11
+ // `round_count: 0` and `rounds_used: 0` both — a run that did real work reported having done none.
12
+ //
13
+ // TWO NUMBERS, NOT ONE. A round can be built and die before EVAL ever sees it (a circuit breaker,
14
+ // a kill mid-round), so "rounds built" and "rounds judged" are different facts and collapsing them
15
+ // into a single field is exactly what made the fallback silently wrong. Both are returned here, and
16
+ // both are meant to survive to wherever a run's numbers are read — the ship report and the export.
17
+ //
18
+ // PURE. Reads the run's own trace, writes nothing — the same discipline as `reduce ship`'s other
19
+ // derivations (`t0Summary`, `boardCensus`, …). A run before any of these artifacts existed, or a
20
+ // lane with no round concept at all (`--tiny`), falls back to the caller-supplied ledger value,
21
+ // non-regression with the earlier, EVAL-only behaviour.
22
+
23
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { ordersDir, verdictsDir, roundBuildDir, resultsDir } from "../lib/paths.mjs";
26
+
27
+ /** Parse a JSON file, returning null rather than throwing — every reader here is best-effort. */
28
+ function readJson(p) {
29
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
30
+ }
31
+
32
+ /**
33
+ * The round a build-addressed order id encodes, e.g. `checkout/sc-01-r2-a1` → 2.
34
+ * @param {*} orderId - The order's own id, whatever shape it happens to be.
35
+ * @returns {(number|null)} The round, or null when the id carries none (`orient`, `wire`, `hammer`, …).
36
+ */
37
+ export function orderRound(orderId) {
38
+ const suffix = String(orderId ?? "").split("/").slice(1).join("/");
39
+ const m = suffix.match(/-r(\d+)(?:-a\d+)?$/);
40
+ return m ? Number(m[1]) : null;
41
+ }
42
+
43
+ const maxOf = (nums) => (nums.length ? Math.max(...nums) : null);
44
+
45
+ /**
46
+ * How many rounds this run actually reached — built, and separately, judged.
47
+ *
48
+ * `rounds_used` is the highest round carrying ANY build evidence: a compiled order, a T0 verdict
49
+ * artifact, a round build-gate artifact, or an EVAL result — the brief's own list, plus EVAL results
50
+ * because a judged round is, by construction, a round that was also built. `rounds_judged` is the
51
+ * highest round EVAL actually returned a verdict for (`evaluate-r<N>.json` on disk), kept as its own
52
+ * field rather than folded into the only number a reader can see.
53
+ *
54
+ * @param {string} cwd - Project root.
55
+ * @param {string} slug - Feature slug.
56
+ * @param {*} [fallback] - What to report for `rounds_used` when nothing on disk is derivable — the
57
+ * caller's own ledger value, passed through untouched (some callers hand this on already coerced
58
+ * to a number, some as the raw frontmatter string; this function does not care which).
59
+ * @returns {{rounds_used:*, rounds_judged:(number|null)}} Both counts.
60
+ */
61
+ export function deriveRounds(cwd, slug, fallback) {
62
+ const orderRounds = [];
63
+ const oDir = ordersDir(cwd, slug);
64
+ if (existsSync(oDir)) {
65
+ for (const f of readdirSync(oDir)) {
66
+ if (!f.endsWith(".json")) continue;
67
+ const r = orderRound(readJson(join(oDir, f))?.order_id);
68
+ if (r !== null) orderRounds.push(r);
69
+ }
70
+ }
71
+
72
+ const verdictRounds = [];
73
+ const vDir = verdictsDir(cwd, slug);
74
+ if (existsSync(vDir)) {
75
+ for (const f of readdirSync(vDir).filter((x) => x.endsWith(".json"))) {
76
+ const v = readJson(join(vDir, f));
77
+ if (typeof v?.round === "number") verdictRounds.push(v.round);
78
+ }
79
+ }
80
+
81
+ const buildGateRounds = [];
82
+ const bDir = roundBuildDir(cwd, slug);
83
+ if (existsSync(bDir)) {
84
+ for (const f of readdirSync(bDir)) {
85
+ const m = f.match(/^r(\d+)-t\d+\.json$/);
86
+ if (m) buildGateRounds.push(Number(m[1]));
87
+ }
88
+ }
89
+
90
+ const evalRounds = [];
91
+ const rDir = resultsDir(cwd, slug);
92
+ if (existsSync(rDir)) {
93
+ for (const f of readdirSync(rDir)) {
94
+ const m = f.match(/^evaluate-r(\d+)\.json$/);
95
+ if (m) evalRounds.push(Number(m[1]));
96
+ }
97
+ }
98
+
99
+ const built = maxOf([...orderRounds, ...verdictRounds, ...buildGateRounds, ...evalRounds]);
100
+ return {
101
+ rounds_used: built !== null ? built : fallback,
102
+ rounds_judged: maxOf(evalRounds),
103
+ };
104
+ }