shapeup-sdlc 1.7.0 → 3.0.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 (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -0,0 +1,125 @@
1
+ // probe leg — "did this scope's work actually reach the board?"
2
+ //
3
+ // CONTRACT. A bounded, read-only query over one scope's orders, results and leg-completion rows for
4
+ // one round. Prints `{closed, scope_id, round, orders: [...], unapplied: [...]}` on stdout; exits 0
5
+ // when every order that produced a result has been ingested, 1 when at least one has not, 2 on a bad
6
+ // argv. Writes nothing.
7
+ //
8
+ // WHY IT EXISTS. `probe t0` answers "is the T0 verdict green", and the BUILD round's confirm stage
9
+ // asked only that. A green T0 says the worker's fixtures ran and passed; it says nothing about
10
+ // whether the WorkResult was applied — and this is a state a real run reaches. A build leg can write
11
+ // its code, a green T0 verdict, a kept trial row and its WorkResult to disk, and never run step 3 of
12
+ // its own script, `reduce ingest`. It reports green; a confirm stage that re-verifies only the T0
13
+ // artifact agrees, and the round walks on. That scope's task is left `pending` with zero acceptance
14
+ // criteria ticked while its code sits finished on disk, beside a sibling scope whose task is `done`.
15
+ //
16
+ // Nothing has to be wrong with the order, the receipt or the result for this to happen — ingesting the
17
+ // same file afterwards succeeds and ticks the criteria. The single writer of shared state simply never
18
+ // ran, and the board GATE L2 reads as "100% ✅" silently disagrees with a scope that is genuinely
19
+ // finished. Under fan-out it gets worse rather than better: N legs can each drop the step
20
+ // independently, and the only symptom is a board that lags reality.
21
+ //
22
+ // WHY A LEG ROW IS THE RIGHT EVIDENCE. The row is appended BY `reduce ingest`. Its presence is
23
+ // therefore proof the writer ran, and it is not something the leg can assert about itself — the same
24
+ // reason the dispatch receipt is written by the hook layer rather than by the sub-agent making the
25
+ // call. "The worker says it ingested" is exactly the class of claim this pipeline does not accept.
26
+
27
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
28
+ import { join, resolve } from "node:path";
29
+ import { runArgs } from "../lib/argv.mjs";
30
+ import { ordersDir, resultsDir, legLedger } from "../lib/paths.mjs";
31
+
32
+ /** `<scope>-r<round>-a<attempt>.json` — the only address a build order is written under. */
33
+ const BUILD_ORDER = /^(.+)-r(\d+)-a(\d+)\.json$/;
34
+
35
+ /**
36
+ * Every leg-completion row on disk, tolerant of a torn last line.
37
+ * @param {string} path - The leg ledger.
38
+ * @returns {object[]} Parsed rows; an unparsable line is skipped rather than fatal.
39
+ */
40
+ export function readLegs(path) {
41
+ if (!existsSync(path)) return [];
42
+ return readFileSync(path, "utf8").split("\n").filter(Boolean)
43
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
44
+ .filter(Boolean);
45
+ }
46
+
47
+ /**
48
+ * Which of one scope's orders in one round produced a result, and which of those were applied.
49
+ *
50
+ * An order with no result has not finished and is not a finding — the leg may still be running, or
51
+ * may have died, which the round already treats as a spent attempt. What this reports is the
52
+ * narrower and stranger case: a result on disk that the single writer never applied.
53
+ *
54
+ * @param {string} cwd - Project root.
55
+ * @param {string} slug - Feature slug.
56
+ * @param {string} scopeId - Scope contract id.
57
+ * @param {number} round - Build round.
58
+ * @returns {{closed: boolean, orders: object[], unapplied: object[]}} `closed` is false only when a
59
+ * result exists that was never ingested; a scope with no results at all reports `closed: false`
60
+ * with an empty `orders` list, so a caller can tell "nothing ran" from "nothing was applied".
61
+ */
62
+ export function legState(cwd, slug, scopeId, round) {
63
+ const oDir = ordersDir(cwd, slug);
64
+ const rDir = resultsDir(cwd, slug);
65
+ const legs = readLegs(legLedger(cwd, slug));
66
+ const ingested = new Set(legs
67
+ .filter((r) => Number(r.round) === round && r.scope_id === scopeId && r.ingested_at)
68
+ .map((r) => String(r.order_id)));
69
+
70
+ const orders = [];
71
+ for (const f of (existsSync(oDir) ? readdirSync(oDir) : []).sort()) {
72
+ const m = f.match(BUILD_ORDER);
73
+ if (!m || m[1] !== scopeId || Number(m[2]) !== round) continue;
74
+ // The order id is read off the order rather than rebuilt from the filename: the filename is the
75
+ // path the round knows, and the id is the key every record joins on, and they are allowed to be
76
+ // written by different steps.
77
+ let orderId = null;
78
+ try { orderId = JSON.parse(readFileSync(join(oDir, f), "utf8")).order_id ?? null; } catch { /* unreadable — reported below */ }
79
+ orders.push({
80
+ order: join(oDir, f),
81
+ order_id: orderId,
82
+ attempt: Number(m[3]),
83
+ has_result: existsSync(join(rDir, f)),
84
+ applied: orderId !== null && ingested.has(orderId),
85
+ });
86
+ }
87
+ const unapplied = orders.filter((o) => o.has_result && !o.applied);
88
+ return { closed: orders.some((o) => o.applied) && unapplied.length === 0, orders, unapplied };
89
+ }
90
+
91
+ export const ARGV_SPEC = {
92
+ usage: "harness.mjs probe leg --slug <slug> --scope <scope-id> --round N [--cwd <dir>]",
93
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
94
+ slug: { type: "str", required: true },
95
+ scope: { type: "str", required: true },
96
+ round: { type: "int", min: 1, required: true },
97
+ cwd: { type: "path" },
98
+ };
99
+
100
+ /**
101
+ * Report whether one scope's results for one round reached the board.
102
+ *
103
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
104
+ * @returns {void} Exits 0 when closed, 1 when a result was never applied — the shape a caller can
105
+ * branch on without parsing prose.
106
+ */
107
+ export function cli(rawArgv) {
108
+ const args = runArgs(ARGV_SPEC, rawArgv);
109
+ const cwd = resolve(args.cwd || process.cwd());
110
+ const s = legState(cwd, args.slug, args.scope, args.round);
111
+ console.log(JSON.stringify({
112
+ closed: s.closed,
113
+ scope_id: args.scope,
114
+ round: args.round,
115
+ // The counts travel with the verdict: `closed: false` over zero orders means the leg has not run,
116
+ // and over three orders means its work is on disk and unapplied. Those need different answers
117
+ // from the caller, so they must not read the same.
118
+ orders_total: s.orders.length,
119
+ results_total: s.orders.filter((o) => o.has_result).length,
120
+ applied_total: s.orders.filter((o) => o.applied).length,
121
+ orders: s.orders,
122
+ unapplied: s.unapplied.map((o) => o.order),
123
+ }));
124
+ process.exit(s.closed ? 0 : 1);
125
+ }
@@ -16,11 +16,10 @@
16
16
  // artifacts rewritten, a spike added, the discovery ledger and two task files mutated.
17
17
  // The cause was one branch reading stored `status` instead of ORIENT's own artifacts,
18
18
  // while WIRE and MAP SCOPES read artifacts and fast-forwarded correctly.
19
- // 2. IT MATCHED NO PERMISSION GRANT. `permissions.allow` carries
20
- // `Bash(node ${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/:*)`; an inline `node -e` matches
21
- // no entry and passes only at the safety classifier's discretion. As a script it is covered
22
- // by the grant the installer already writes.
23
- // 3. TWO WRITES HAD NO READER. `setRunStatus` and `writeActiveScope` were the only `mech()`
19
+ // 2. IT MATCHED NO PERMISSION GRANT. An inline `node -e` matches no rule in `permissions.allow`
20
+ // and passes only at the safety classifier's discretion. As a named script it is covered by
21
+ // the per-script rules the installer writes (`bin/lib/grant.mjs`).
22
+ // 3. TWO WRITES HAD NO READER. `setRunStatus` and the substrate pointer were the only
24
23
  // call sites in the workflow whose return value was discarded — and they are the only two
25
24
  // whose failure went unnoticed for two entire runs. `status` never left `orienting` across
26
25
  // 46 dispatched agents, and `.shapeup/active-scope` still named scope 1 while scope 2 was
@@ -50,25 +49,24 @@
50
49
  // disagree about "is this phase done" is the defect class itself.
51
50
  //
52
51
  // USAGE
53
- // node resume-state.mjs --slug <slug> [--cwd <dir>] # derive, print ResumeState
54
- // node resume-state.mjs --slug <slug> --require <phase> # post-condition: exit 6 if unmet
55
- // node resume-state.mjs --slug <slug> --set-status <status> # write harness-run.md status
56
- // node resume-state.mjs --slug <slug> --set-active-scope <id> # write the substrate pointer
52
+ // node harness probe resume --slug <slug> [--cwd <dir>] # derive, print ResumeState
53
+ // node harness probe resume --slug <slug> --require <phase> # post-condition: exit 6 if unmet
54
+ // node harness probe resume --slug <slug> --set-status <status> # write harness-run.md status
57
55
  //
58
56
  // Exit: 0 ok · 2 malformed argv (nothing ran) · 3 the target the operation needs is not on disk ·
59
57
  // 6 the required phase's artifact is NOT on disk (the phase did not complete).
60
58
 
61
59
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
62
60
  import { dirname, join, resolve } from "node:path";
63
- import { isMain } from "./lib/is-main.mjs";
64
- import { runArgs } from "./lib/argv.mjs";
65
- import { splitFrontmatter } from "./lib/contract-md.mjs";
61
+ import { runArgs } from "../lib/argv.mjs";
62
+ import { splitFrontmatter } from "../lib/contract.mjs";
63
+ import { globToRegExp } from "../verify/spec.mjs";
66
64
  import {
67
65
  intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
68
- orientDir, activeScope, activeOrder, usecasesDir,
69
- } from "./lib/paths.mjs";
66
+ orientDir, activeOrder, usecasesDir,
67
+ } from "../lib/paths.mjs";
70
68
 
71
- /** The run-state values `references/ledger-schema.md` defines. A typo'd status is a rejection,
69
+ /** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
72
70
  * not a write — the whole point of this file is that a write nobody validates is a write nobody
73
71
  * can trust. */
74
72
  export const RUN_STATUSES = ["orienting", "mapping", "building", "evaluating", "shipped", "escalated"];
@@ -81,7 +79,7 @@ export const ORIENT_SPIKE = /^spike-.+\.md$/;
81
79
 
82
80
  /**
83
81
  * Parse a leading `---` frontmatter block into a flat object, through the ONE library that reads
84
- * this file form (`lib/contract-md.mjs`).
82
+ * this file form (`lib/contract.mjs`).
85
83
  *
86
84
  * It used to be a private scalar-only regex — every value came back a string, including a
87
85
  * `key: [a, b]` list. That is why `eval_dimensions` was pinned: the ledger could carry the set the
@@ -107,6 +105,168 @@ export function parseFrontmatter(text) {
107
105
  * @param {string} slug - Feature slug.
108
106
  * @returns {boolean} True when all three named artifacts and at least one spike file exist.
109
107
  */
108
+ /**
109
+ * Group the scopes into dependency WAVES: every scope in a wave can be built at the same time,
110
+ * and no wave contains a scope that depends on one still in flight.
111
+ *
112
+ * WHY THIS EXISTS, measured rather than reasoned. BUILD chunked the scopes by a fixed width over
113
+ * whatever order the directory listing gave — alphabetical. On the criterion-1 run that put
114
+ * `cli-integration` second, in the first wave, alongside the `foundation` and command scopes its own
115
+ * contract says it consumes ("replaces foundation's bin/todo.js placeholder", "routes to each command
116
+ * scope's module"). It burned all three attempts at 0/2 fixtures in round 1 and its leg died in round
117
+ * 2, so `bin/todo.js` stayed a two-line placeholder and four individually T0-green command modules
118
+ * were unreachable from the entry point. Five of six scopes went green; the sixth was the one that
119
+ * had to go last, and it was scheduled second by alphabet.
120
+ *
121
+ * THE ORDER LIVES IN THE SAME TIER AS THE THING IT ORDERS. Each scope contract declares
122
+ * `depends_on: [scope_id, …]`, and scope A follows scope B when A names B. This used to be derived
123
+ * instead — contract `tasks` → the board's task `depends_on` → back to the owning scope — which
124
+ * read an ordering off the LOCAL board while the contracts it ordered were COMMITTED. On a fresh
125
+ * clone the board is absent, every scope's task list resolved to nothing, and the whole relation
126
+ * collapsed to "no edges": the scheduler degraded to the unscheduled fan-out this exists to
127
+ * replace, and reported nothing, because an empty relation is the same value as no relation.
128
+ * `scope_id` is the stable cross-machine key, so an edge between two of them survives the clone
129
+ * that the two-hop join through renumbering task ids never could.
130
+ *
131
+ * NON-REGRESSION IS THE DEFAULT. Any missing or unreadable input — no contract declaring
132
+ * `depends_on`, an id naming a scope that is not here, a dependency cycle — falls back to one wave
133
+ * containing everything, which is exactly the pre-scheduler behavior. A scheduler that refuses to
134
+ * run is worse than one that runs unscheduled.
135
+ *
136
+ * @param {string} cwd - Project root.
137
+ * @param {string} slug - Feature slug.
138
+ * @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
139
+ * @returns {string[][]} Waves of resolved scope paths, dependencies first. Order within a wave is
140
+ * the input order, so the result is deterministic.
141
+ */
142
+ export function scopeWaves(cwd, slug, scopes) {
143
+ const all = scopes.map((s) => s.path);
144
+ if (scopes.length < 2) return all.length ? [all] : [];
145
+ const deps = scopeDepGraph(scopes);
146
+ if (!deps) return [all];
147
+
148
+ // Kahn, one wave per level. A cycle cannot stall the build: the remainder ships as one wave.
149
+ const done = new Set();
150
+ const waves = [];
151
+ while (done.size < scopes.length) {
152
+ const ready = scopes.filter((s) => !done.has(s.scope_id)
153
+ && [...deps.get(s.scope_id)].every((d) => done.has(d)));
154
+ if (!ready.length) {
155
+ waves.push(scopes.filter((s) => !done.has(s.scope_id)).map((s) => s.path));
156
+ break;
157
+ }
158
+ waves.push(ready.map((s) => s.path));
159
+ for (const s of ready) done.add(s.scope_id);
160
+ }
161
+ return waves;
162
+ }
163
+
164
+ /**
165
+ * The dependency relation `scopeWaves` levels — parsed once, so the two answers cannot disagree.
166
+ *
167
+ * @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
168
+ * @returns {(Map<string,Set<string>>|null)} scope_id → the scope_ids it waits for, or null when the
169
+ * contracts carry no usable relation at all (none declares `depends_on`, or every id it names is
170
+ * a scope that is not in this run).
171
+ */
172
+ function scopeDepGraph(scopes) {
173
+ const present = new Set(scopes.map((s) => s.scope_id));
174
+ const deps = new Map(scopes.map((s) => [s.scope_id, new Set()]));
175
+ let sawEdge = false;
176
+ for (const s of scopes) {
177
+ let declared;
178
+ try { declared = parseFrontmatter(readFileSync(s.path, "utf8")).depends_on; } catch { continue; }
179
+ if (!Array.isArray(declared)) continue;
180
+ for (const d of declared) {
181
+ const id = String(d).trim();
182
+ // An id naming a scope that is not in this run is DROPPED, not an error: a contract may
183
+ // legitimately name a scope superseded or cut since it was written, and a scheduler that
184
+ // stalls on a stale edge is worse than one that ignores it. spec-lint reports the dangling
185
+ // id — this is the scheduling path, and it fails open.
186
+ if (id && id !== s.scope_id && present.has(id)) { deps.get(s.scope_id).add(id); sawEdge = true; }
187
+ }
188
+ }
189
+ return sawEdge ? deps : null;
190
+ }
191
+
192
+ /**
193
+ * The SAME relation as {@link scopeWaves}, as edges rather than levels.
194
+ *
195
+ * WHY BOTH SHAPES SHIP. A level says when it is definitely safe to start a scope; an edge says when
196
+ * it BECAME safe, and the two differ by however long the slowest sibling in the previous level takes.
197
+ * A build that releases per level holds `cli-integration` until the last command scope lands even
198
+ * when the only scope it consumes went green first. Levels remain the ORDER the fan-out considers
199
+ * scopes in; edges are what release them.
200
+ *
201
+ * ADDITIVE, in the same sense `scope_waves` is: a consumer that ignores this field can rebuild the
202
+ * level-release points from `scope_waves` alone, which is exactly the previous behaviour.
203
+ *
204
+ * @param {string} cwd - Project root.
205
+ * @param {string} slug - Feature slug.
206
+ * @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
207
+ * @returns {string[][]} `[dependant_path, dependency_path]` pairs, resolved paths on both ends so a
208
+ * consumer resolves ids the one way it already resolves `scope_files`. Empty when no relation is
209
+ * derivable — never a partial or invented one.
210
+ */
211
+ /**
212
+ * Pairs of scopes that must not BUILD AT THE SAME TIME, because both may write the same path.
213
+ *
214
+ * WHY THIS IS A SCHEDULING FACT AND NOT A LINT. `shared_substrate` is the declared escape hatch from
215
+ * the disjointness rule: the spec lint passes an overlap both contracts declare, and the sandbox
216
+ * guard permits that path to every live order that names it. Every layer is individually correct and
217
+ * the join is wrong — concurrent writers to one declared-shared entry point lose each other's work,
218
+ * with every check green. An overlap that is NOT declared shared never reaches BUILD (the lint reds
219
+ * it and the run stops at the board review), so what this returns is precisely the set the escape
220
+ * hatch created.
221
+ *
222
+ * It is an EXCLUSION, not a dependency: neither scope has to go first, they only have to not
223
+ * overlap. The consumer orients each pair by build position, which is what keeps the constraint from
224
+ * ever forming a cycle with a real dependency edge.
225
+ *
226
+ * ADDITIVE, like the two fields above it: a consumer that ignores it schedules exactly as before.
227
+ *
228
+ * @param {string} cwd - Project root.
229
+ * @param {string} slug - Feature slug.
230
+ * @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
231
+ * @returns {string[][]} Unordered `[pathA, pathB]` pairs. Empty when nothing can collide.
232
+ */
233
+ export function scopeExclusions(cwd, slug, scopes) {
234
+ if (scopes.length < 2) return [];
235
+ const globsOf = (s) => {
236
+ try {
237
+ const fm = parseFrontmatter(readFileSync(s.path, "utf8"));
238
+ return [...(fm.allowed_file_substrate || []), ...(fm.shared_substrate || [])].map((g) => String(g).trim()).filter(Boolean);
239
+ } catch { return []; }
240
+ };
241
+ const globs = new Map(scopes.map((s) => [s.scope_id, globsOf(s)]));
242
+ // Two globs MEET when either matches the other read as a literal path. It is an approximation of
243
+ // "these two patterns can name the same file", and it is deliberately the generous one: a false
244
+ // meet costs two scopes their overlap in time, a missed meet costs one of them its work.
245
+ const meet = (a, b) => a === b || globToRegExp(a).test(b) || globToRegExp(b).test(a);
246
+ const out = [];
247
+ for (let i = 0; i < scopes.length; i++) {
248
+ for (let j = i + 1; j < scopes.length; j++) {
249
+ const A = globs.get(scopes[i].scope_id) || [], B = globs.get(scopes[j].scope_id) || [];
250
+ if (A.some((a) => B.some((b) => meet(a, b)))) out.push([scopes[i].path, scopes[j].path]);
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ export function scopeDeps(cwd, slug, scopes) {
257
+ if (scopes.length < 2) return [];
258
+ const deps = scopeDepGraph(scopes);
259
+ if (!deps) return [];
260
+ const pathOf = new Map(scopes.map((s) => [s.scope_id, s.path]));
261
+ const out = [];
262
+ for (const s of scopes) {
263
+ for (const d of deps.get(s.scope_id) || []) {
264
+ if (pathOf.has(d)) out.push([s.path, pathOf.get(d)]);
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+
110
270
  export function hasOrientArtifacts(cwd, slug) {
111
271
  const dir = orientDir(cwd, slug);
112
272
  if (!existsSync(dir)) return false;
@@ -118,7 +278,7 @@ export function hasOrientArtifacts(cwd, slug) {
118
278
 
119
279
  /**
120
280
  * The use-case directory this run's spec tree lands in. The ledger may name a non-default
121
- * `spec_folder` (`init-run.mjs --spec-folder`), so honour it when present and fall back to the
281
+ * `spec_folder` (`harness init run --spec-folder`), so honour it when present and fall back to the
122
282
  * registry path otherwise — never a spelled-out root (test #45).
123
283
  *
124
284
  * @param {string} cwd - Project root.
@@ -202,7 +362,7 @@ export function deriveResumeState(cwd, slug) {
202
362
  const hrPath = harnessRun(cwd, slug);
203
363
  const hr = existsSync(hrPath) ? parseFrontmatter(readFileSync(hrPath, "utf8")) : {};
204
364
 
205
- // Resolved contract PATHS, not bare filenames: compile-order.mjs and t0-verify.mjs both resolve
365
+ // Resolved contract PATHS, not bare filenames: harness compile and harness verify t0 both resolve
206
366
  // `--scope` against cwd, so a bare "SC-x.md" names a file that does not exist, compile-order
207
367
  // exits 2, and the attempt loop reads that non-zero exit as the stagnation breaker — a resumed
208
368
  // run would falsely trip the inner breaker and hammer-propose every scope instead of continuing.
@@ -231,6 +391,19 @@ export function deriveResumeState(cwd, slug) {
231
391
  project_profile_path: projectProfile(cwd, slug),
232
392
  has_project_profile: existsSync(projectProfile(cwd, slug)),
233
393
  scope_files,
394
+ // The same scopes, grouped so a wave never contains a scope depending on one still in flight.
395
+ // Additive: a caller that ignores it gets exactly today's behavior.
396
+ scope_waves: scopeWaves(cwd, slug, scope_files),
397
+ // The same relation as edges. A wave says when a scope is definitely safe to start; an edge says
398
+ // when it BECAME safe. A fan-out that releases per wave holds a scope until the slowest member of
399
+ // the previous wave lands, even when the one scope it consumes finished first. Also additive: the
400
+ // wave list alone rebuilds the per-wave release points, which is the previous behaviour.
401
+ scope_deps: scopeDeps(cwd, slug, scope_files),
402
+ // Pairs that may write the same declared-shared path. Not an ordering — an exclusion: they may
403
+ // build in either order, and they may not build at the same time. Concurrent writers to one
404
+ // shared entry point lose each other's work while every check stays green, and the escape hatch
405
+ // that permits the overlap is the same one that makes it invisible to the disjointness lint.
406
+ scope_exclusions: scopeExclusions(cwd, slug, scope_files),
234
407
  pending_orders: orderFiles.filter((f) => f.endsWith(".json") && !resultFiles.includes(f)),
235
408
  eval_rounds_done: resultFiles
236
409
  .filter((f) => /^evaluate-r\d+\.json$/.test(f))
@@ -252,11 +425,11 @@ export function deriveResumeState(cwd, slug) {
252
425
  export function setRunStatus(cwd, slug, status) {
253
426
  const p = harnessRun(cwd, slug);
254
427
  if (!existsSync(p)) {
255
- return { ok: false, path: p, status, reason: `no harness-run.md for slug "${slug}" — open the run with init-run.mjs (GATE L0.1) before setting its status` };
428
+ 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 setting its status` };
256
429
  }
257
430
  const body = readFileSync(p, "utf8");
258
431
  if (!/^status:.*$/m.test(body)) {
259
- return { ok: false, path: p, status, reason: `harness-run.md carries no "status:" line to replace — the ledger's frontmatter is malformed (references/ledger-schema.md)` };
432
+ return { ok: false, path: p, status, reason: `harness-run.md carries no "status:" line to replace — the ledger's frontmatter is malformed (references/protocol.md)` };
260
433
  }
261
434
  try {
262
435
  writeFileSync(p, body.replace(/^status:.*$/m, `status: ${status}`));
@@ -270,35 +443,6 @@ export function setRunStatus(cwd, slug, status) {
270
443
  return { ok: true, path: p, status };
271
444
  }
272
445
 
273
- /**
274
- * Point the substrate pointer at the scope about to be built. `hooks/sandbox-guard.mjs` reads
275
- * this to decide which write-whitelist a worker is held to, so a failed write here does not
276
- * degrade gracefully — it silently enforces the WRONG scope's substrate.
277
- *
278
- * @param {string} cwd - Project root.
279
- * @param {string} slug - Feature slug.
280
- * @param {string} scopeId - Scope contract id.
281
- * @returns {{ok: boolean, path: string, slug: string, scope_id: string, reason?: string}} Outcome.
282
- */
283
- export function writeActiveScope(cwd, slug, scopeId) {
284
- const p = activeScope(cwd);
285
- // A throw here would exit 1 with a stack trace and an empty stdout — readable enough to the
286
- // workflow (any non-zero aborts the scope), but the caller learns nothing it can log. Report the
287
- // failure as the same outcome record every other operation returns.
288
- try {
289
- mkdirSync(dirname(p), { recursive: true });
290
- writeFileSync(p, `${JSON.stringify({ slug, scope_id: scopeId }, null, 2)}\n`);
291
- } catch (e) {
292
- return { ok: false, path: p, slug, scope_id: scopeId, reason: `could not write the substrate pointer: ${e.message}` };
293
- }
294
- let readBack;
295
- try { readBack = JSON.parse(readFileSync(p, "utf8")); } catch { readBack = null; }
296
- if (readBack?.scope_id !== scopeId || readBack?.slug !== slug) {
297
- return { ok: false, path: p, slug, scope_id: scopeId, reason: `pointer read back as ${JSON.stringify(readBack)} — sandbox-guard would hold the next worker to the wrong substrate` };
298
- }
299
- return { ok: true, path: p, slug, scope_id: scopeId };
300
- }
301
-
302
446
  /**
303
447
  * Point the substrate pointer at the order about to be executed.
304
448
  *
@@ -325,21 +469,27 @@ export function writeActiveOrder(cwd, slug, orderPath) {
325
469
 
326
470
  /** The typed argv contract (see `./lib/argv.mjs`). */
327
471
  export const ARGV_SPEC = {
328
- usage: "resume-state.mjs --slug <slug> [--cwd <dir>] [--require <phase> | --set-status <status> | --set-active-scope <scope-id> | --set-active-order <path>]",
472
+ usage: "harness.mjs probe resume --slug <slug> [--cwd <dir>] [--require <phase> | --set-status <status> | --set-active-order <path>]",
329
473
  _: { arity: 0, max: 0, name: "(no positional operands)" },
330
474
  slug: { type: "str", required: true },
331
475
  cwd: { type: "path" },
332
476
  require: { type: "enum", values: PHASES },
333
477
  "set-status": { type: "enum", values: RUN_STATUSES },
334
- "set-active-scope": { type: "str" },
335
478
  "set-active-order": { type: "str" },
336
479
  };
337
480
 
338
- export function main() {
339
- const args = runArgs(ARGV_SPEC);
481
+ /**
482
+ * Derive the run's resume state, or set one of the run pointers.
483
+ *
484
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
485
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
486
+ * call `process.exit()` with the subcommand's documented code rather than returning.
487
+ */
488
+ export function cli(rawArgv) {
489
+ const args = runArgs(ARGV_SPEC, rawArgv);
340
490
  const cwd = args.cwd || process.cwd();
341
491
 
342
- const ops = [args.require && "--require", args.setStatus && "--set-status", args.setActiveScope && "--set-active-scope", args.setActiveOrder && "--set-active-order"].filter(Boolean);
492
+ const ops = [args.require && "--require", args.setStatus && "--set-status", args.setActiveOrder && "--set-active-order"].filter(Boolean);
343
493
  if (ops.length > 1) {
344
494
  process.stderr.write(JSON.stringify({ error: "conflicting_flags", flags: ops, expected: "one operation per invocation" }) + "\n");
345
495
  process.exit(2);
@@ -366,12 +516,6 @@ export function main() {
366
516
  process.exit(r.ok ? 0 : 3);
367
517
  }
368
518
 
369
- if (args.setActiveScope) {
370
- const r = writeActiveScope(cwd, args.slug, args.setActiveScope);
371
- console.log(JSON.stringify(r));
372
- process.exit(r.ok ? 0 : 3);
373
- }
374
-
375
519
  if (args.setActiveOrder) {
376
520
  const r = writeActiveOrder(cwd, args.slug, args.setActiveOrder);
377
521
  console.log(JSON.stringify(r));
@@ -382,6 +526,3 @@ export function main() {
382
526
  process.exit(0);
383
527
  }
384
528
 
385
- if (isMain(import.meta.url)) {
386
- main();
387
- }
@@ -17,14 +17,14 @@
17
17
  // Output is a StatsReport (domain.schema.json#/$defs/StatsReport), self-validated before it
18
18
  // is emitted; --format table renders the human view from the already-validated object.
19
19
  //
20
- // Usage: node stats.mjs [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table]
20
+ // Usage: node `harness probe stats` [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table]
21
21
 
22
22
  import { readFileSync, readdirSync, existsSync } from "node:fs";
23
23
  import { resolve, join } from "node:path";
24
- import { validate } from "./validate-envelope.mjs";
25
- import { isMain } from "./lib/is-main.mjs";
26
- import { runArgs } from "./lib/argv.mjs";
27
- import { localDir, decisions as decisionsPath, metricsDir as metricsDirPath, SHARED } from "./lib/paths.mjs";
24
+ import { validate } from "../verify/envelope.mjs";
25
+ import { runArgs } from "../lib/argv.mjs";
26
+ import { collectRun } from "../report/export.mjs";
27
+ import { localDir, decisions as decisionsPath, metricsDir as metricsDirPath, SHARED } from "../lib/paths.mjs";
28
28
 
29
29
  /**
30
30
  * Read every metrics shard, partitioning valid rows, pathology rows, and malformed lines.
@@ -284,6 +284,79 @@ export function readDecisions(cwd) {
284
284
  } catch { return []; }
285
285
  }
286
286
 
287
+ /**
288
+ * Project run economics for every run in the checkout — measurement-table row 4.
289
+ *
290
+ * WHY IT LIVES HERE and reads the run trace rather than the metrics shards: the same reason
291
+ * `--ratchet` reads `trials.jsonl` and `--hooks` reads `decisions.jsonl`. A harvest row is written
292
+ * once at SHIP S.6 and carries counts, never durations or cost — so a run that never shipped, which
293
+ * is exactly the run whose cost you want to see, has no harvest row at all. The journal has one row
294
+ * per agent call from the first dispatch onwards.
295
+ *
296
+ * FACTS ONLY, unchanged: sums, counts and durations over rows that already exist. Nothing here is
297
+ * divided by an expectation or compared to a target, because that would be a grade.
298
+ *
299
+ * @param {string} cwd - Project root.
300
+ * @param {(string|null)} [slug=null] - Restrict to one feature slug.
301
+ * @returns {object} `{runs, per_run[]}` — one economics block per run, newest last, each tagged
302
+ * with its run_id and slug.
303
+ */
304
+ export function economicsReport(cwd, slug = null) {
305
+ const root = localDir(cwd);
306
+ if (!existsSync(root)) return { runs: 0, per_run: [] };
307
+ let slugs;
308
+ try { slugs = slug ? [slug] : readdirSync(root); } catch { return { runs: 0, per_run: [] }; }
309
+ const per_run = [];
310
+ for (const s of slugs.sort()) {
311
+ let collected = null;
312
+ try { collected = collectRun(cwd, s); } catch { collected = null; }
313
+ if (!collected) continue; // no receipt ⇒ not a run, which is not an error
314
+ per_run.push({ run_id: collected.run_id, slug: s, ...collected.economics });
315
+ }
316
+ return { runs: per_run.length, per_run };
317
+ }
318
+
319
+ /**
320
+ * Format a dollar figure, keeping "no cost row was recorded" visibly different from "$0.0000".
321
+ * @param {(number|null|undefined)} v - A cost in USD, or null when nothing recorded one.
322
+ * @returns {string} e.g. `$1.2000`, or `—` when the value is absent.
323
+ */
324
+ function money(v) {
325
+ return v === null || v === undefined ? "—" : `$${v.toFixed(4)}`;
326
+ }
327
+
328
+ /**
329
+ * Render the economics report as text.
330
+ * @param {object} r - Output of {@link economicsReport}.
331
+ * @returns {string} The multi-line report.
332
+ */
333
+ function renderEconomics(r) {
334
+ const lines = [`economics: ${r.runs} run(s) with a receipt`];
335
+ if (!r.runs) {
336
+ lines.push("", "(no run trace in this checkout — this reads the run's own records, so it is empty");
337
+ lines.push(" until a run opens, and stays readable after one ends until the trace is cleaned.)");
338
+ return lines.join("\n");
339
+ }
340
+ for (const e of r.per_run) {
341
+ lines.push("", ` ${e.run_id ?? e.slug}`,
342
+ ` agent calls ${e.agent_calls} (${e.retried_calls} retried, ${e.failed_calls} failed, ${e.killed_calls} killed)`,
343
+ ` cost ${money(e.cost_usd)} attributed ${money(e.cost_attributed_usd)} · unattributed ${money(e.cost_unattributed_usd)}`,
344
+ ` wall clock ${e.wall_ms_total === null ? "—" : `${Math.round(e.wall_ms_total / 1000)}s`}`,
345
+ ` to first write ${e.calls_to_first_write ?? "—"} call(s)` +
346
+ `${e.seconds_to_first_write === null ? "" : ` · ${e.seconds_to_first_write}s`}`,
347
+ ` dispatches ${e.dispatches} (${e.dispatches_answered} answered, ${e.dispatches_costed} costed)`);
348
+ for (const m of e.by_model) {
349
+ lines.push(` ${String(m.model).padEnd(22)}${String(m.calls).padStart(3)} call(s) ${money(m.cost_usd)}`);
350
+ }
351
+ // The gap is named, never left as a quiet shortfall in the total.
352
+ if (e.dispatches_costed < e.dispatches) {
353
+ lines.push(` ⓘ ${e.dispatches - e.dispatches_costed} dispatch(es) carry no cost row — the journal exists only on the`,
354
+ " workflow lane, so a prose-lane or --tiny dispatch has no agent call to join to.");
355
+ }
356
+ }
357
+ return lines.join("\n");
358
+ }
359
+
287
360
  /**
288
361
  * Render a StatsReport as a human-readable fixed-width table.
289
362
  * @param {object} report - A validated StatsReport (see {@link aggregate}).
@@ -321,8 +394,8 @@ function renderTable(report) {
321
394
 
322
395
  /** The typed argv contract (see `./lib/argv.mjs`). */
323
396
  export const ARGV_SPEC = {
324
- usage: "stats.mjs [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table] " +
325
- "[--ratchet] [--hooks]",
397
+ usage: "harness.mjs probe stats [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table] " +
398
+ "[--ratchet] [--hooks] [--economics]",
326
399
  _: { arity: 0, max: 0, name: "(no positional operands)" },
327
400
  cwd: { type: "path" },
328
401
  "metrics-dir": { type: "path" },
@@ -330,6 +403,7 @@ export const ARGV_SPEC = {
330
403
  format: { type: "enum", values: ["json", "table"], default: "json" },
331
404
  ratchet: { type: "flag" },
332
405
  hooks: { type: "flag" },
406
+ economics: { type: "flag" },
333
407
  };
334
408
 
335
409
  /**
@@ -386,23 +460,31 @@ function renderHooks(r) {
386
460
  return lines.join("\n");
387
461
  }
388
462
 
389
- const isMainModule = isMain(import.meta.url);
390
- if (isMainModule) {
391
- const args = runArgs(ARGV_SPEC);
463
+ /**
464
+ * Aggregate the metric shards, or report the ratchet, hook and economics ledgers.
465
+ *
466
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
467
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
468
+ * call `process.exit()` with the subcommand's documented code rather than returning.
469
+ */
470
+ export async function cli(rawArgv) {
471
+ const args = runArgs(ARGV_SPEC, rawArgv);
392
472
  const cwd = resolve(args.cwd || process.cwd());
393
473
  const metricsDir = args.metricsDir ? resolve(cwd, args.metricsDir) : metricsDirPath(cwd);
394
474
  const format = args.format;
395
475
 
396
476
  // The two exit measurements are separate modes: each reads a different ledger, and neither is a
397
477
  // StatsReport (which is schema-locked to the harvest shards).
398
- if (args.ratchet || args.hooks) {
478
+ if (args.ratchet || args.hooks || args.economics) {
399
479
  const out = {};
400
480
  if (args.ratchet) out.ratchet = ratchetReport(readAllTrials(cwd, args.slug ?? null));
401
481
  if (args.hooks) out.hooks = hooksReport(readDecisions(cwd));
482
+ if (args.economics) out.economics = economicsReport(cwd, args.slug ?? null);
402
483
  if (format === "table") {
403
484
  const parts = [];
404
485
  if (out.ratchet) parts.push(renderRatchet(out.ratchet));
405
486
  if (out.hooks) parts.push(renderHooks(out.hooks));
487
+ if (out.economics) parts.push(renderEconomics(out.economics));
406
488
  console.log(parts.join("\n\n"));
407
489
  } else {
408
490
  console.log(JSON.stringify(out, null, 2));