shapeup-sdlc 1.7.0 → 3.0.1

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 +14 -9
  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 +321 -0
  42. package/kernel/report/facts.mjs +209 -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 +71 -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 +1327 -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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Board derivation (pure-skill architecture v1.0, plan §8.2).
2
+ // Board derivation the planner's graph math, as code a model never re-derives.
3
3
  //
4
4
  // The mechanical half of the old ba-pitch-analyzer Phase 7b + v3.3 link-field rules — pure
5
5
  // graph math a model should never re-derive (and gets wrong: hand-authored inverses drift asymmetric
@@ -11,25 +11,27 @@
11
11
  // • Appetite Guard arithmetic (--appetite-hours N → overflow flag; the HAMMER *pause* on
12
12
  // overflow is an orchestrator gate, never resolved here)
13
13
  // • board-vs-T0 drift check (a FINISHED scope whose tasks still read `ready`) when scope
14
- // contracts name their tasks — flag, never fix
14
+ // contracts anchor use cases the board's tasks name back — flag, never fix
15
+ // • board-vs-dispatch reconciliation (BOARD-UNDISPATCHED, Phase 3.5 / S6): a scope with a
16
+ // done-marked task but no dispatched-and-answered order anywhere for it — a cut, never
17
+ // dispatched scope cannot read `done` — flag, never fix
15
18
  //
16
19
  // Zero dependencies. Usage:
17
- // node skills/ba-pitch-analyzer/scripts/board-derive.mjs --slug <slug> [--cwd <dir>] [--write]
20
+ // node kernel/harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write]
18
21
  // [--appetite-hours N]
19
22
  // Prints a JSON report; exit 0 (drift/overflow are flags for the caller's gate, not errors).
20
23
 
21
24
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
22
25
  import { resolve, join } from "node:path";
23
- import { isMain } from "../../tech-lead/scripts/lib/is-main.mjs";
24
- import { runArgs } from "../../tech-lead/scripts/lib/argv.mjs";
25
- import { tasksDir, scopesDir, hillDir } from "../../tech-lead/scripts/lib/paths.mjs";
26
- import { readAllContracts, splitFrontmatter, SCOPE_CONTRACT } from "../../tech-lead/scripts/lib/contract-md.mjs";
26
+ import { runArgs } from "../lib/argv.mjs";
27
+ import { tasksDir, scopesDir, hillDir, ordersDir, resultsDir } from "../lib/paths.mjs";
28
+ import { readAllContracts, splitFrontmatter, tasksForScope, SCOPE_CONTRACT } from "../lib/contract.mjs";
27
29
 
28
30
  /**
29
31
  * Read a list field from a frontmatter string, inline `[a, b]` or YAML block sequence alike.
30
32
  *
31
33
  * The SECOND instance of the same defect, in a second parser. This repo had two hand-rolled
32
- * frontmatter readers: `contract-md.mjs` for the committed contracts, and this one for the board.
34
+ * frontmatter readers: `lib/contract.mjs` for the committed contracts, and this one for the board.
33
35
  * It was fixed in the first, and this one still silently dropped a block sequence — so a board
34
36
  * written as
35
37
  * use_case_refs:
@@ -61,7 +63,7 @@ const listField = (fm, key) => {
61
63
  * Parse every TASK-*.md in a board directory into structured task records.
62
64
  * @param {string} tasksDir - Absolute path to the LOCAL tasks directory.
63
65
  * @returns {Array<{file:string, id:string, type:string, status:string, hours:number, pkg:string,
64
- * depends_on:string[], unlocks:string[], use_case_refs:string[], body:string}>} One record per
66
+ * depends_on:string[], unlocks:string[], use_case_refs:string[], scope_id:string, body:string}>} One record per
65
67
  * task file; [] when the directory does not exist.
66
68
  */
67
69
  export function parseBoard(tasksDir) {
@@ -81,6 +83,7 @@ export function parseBoard(tasksDir) {
81
83
  depends_on: listField(fm, "depends_on"),
82
84
  unlocks: listField(fm, "unlocks"),
83
85
  use_case_refs: listField(fm, "use_case_refs"),
86
+ scope_id: (fm.match(/^scope_id:\s*(\S+)/im) || [, ""])[1].trim(),
84
87
  body,
85
88
  };
86
89
  });
@@ -136,24 +139,89 @@ export function criticalPath(tasks) {
136
139
  }
137
140
 
138
141
  /**
139
- * Flag board-vs-T0 drift: FINISHED scopes whose named tasks are not yet done (flag only, never fix).
140
- * @param {Array<{id:string, status:string}>} tasks - The parsed board.
141
- * @param {Array<{scope_id:string, tasks?:string[], finished?:boolean}>} scopes - Scope facts.
142
+ * Flag board-vs-T0 drift: FINISHED scopes whose tasks are not yet done (flag only, never fix).
143
+ *
144
+ * The scope's tasks are joined through its committed `use_cases`, not read off a task-id list the
145
+ * contract used to carry — see {@link tasksForScope}. A scope that anchors no use case contributes
146
+ * nothing here rather than a false clean bill: "no anchor" is SCOPE-ANCHOR's finding to report.
147
+ *
148
+ * @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
149
+ * @param {Array<{scope_id:string, use_cases?:string[], finished?:boolean}>} scopes - Scope facts.
142
150
  * @returns {Array<{scope_id:string, task_id:string, status:string}>} One entry per drifting task; [] when none.
143
151
  */
144
152
  export function driftCheck(tasks, scopes) {
145
- const byId = Object.fromEntries(tasks.map((t) => [t.id, t]));
146
153
  const drift = [];
147
154
  for (const s of scopes) {
148
- if (!Array.isArray(s.tasks) || !s.finished) continue;
149
- for (const id of s.tasks) {
150
- const t = byId[id];
151
- if (t && t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: id, status: t.status });
155
+ if (!s.finished) continue;
156
+ for (const t of tasksForScope(tasks, s)) {
157
+ if (t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: t.id, status: t.status });
152
158
  }
153
159
  }
154
160
  return drift;
155
161
  }
156
162
 
163
+ /**
164
+ * Read every dispatched-and-answered order's scope id (Phase 3.5 / S6).
165
+ *
166
+ * "Dispatched and answered" is filename presence in both directories, the same completion signal
167
+ * `sandbox-guard`'s `liveOrders()` already uses — a result join on content would let a malformed
168
+ * result count as an answer. The scope an order belongs to comes from `payload.scope_contract.
169
+ * scope_id`, not the filename: a non-BUILD order's suffix puts the operation before the scope id
170
+ * (`compileOrder`'s `scopedRoundSuffix`), so filename-prefix matching would miss it.
171
+ *
172
+ * @param {string} orders - Absolute path to the run's `orders/` directory.
173
+ * @param {string} results - Absolute path to the run's `results/` directory.
174
+ * @returns {Set<string>} scope_ids with at least one dispatched order answered by a same-named
175
+ * result file; empty when neither directory has anything to read.
176
+ */
177
+ function answeredScopeIds(orders, results) {
178
+ const answered = new Set();
179
+ if (!existsSync(orders)) return answered;
180
+ const done = new Set(existsSync(results) ? readdirSync(results) : []);
181
+ for (const f of readdirSync(orders)) {
182
+ if (!f.endsWith(".json") || !done.has(f)) continue;
183
+ try {
184
+ const sid = JSON.parse(readFileSync(join(orders, f), "utf8"))?.payload?.scope_contract?.scope_id;
185
+ if (sid) answered.add(sid);
186
+ } catch { /* an unreadable order answers nothing — spec-lint reports unreadable contracts, not this */ }
187
+ }
188
+ return answered;
189
+ }
190
+
191
+ /**
192
+ * Flag a scope whose board tasks read done but has no dispatched-and-answered order on disk
193
+ * (Phase 3.5 / S6) — the defect that motivated this phase: a scope cut before it was ever
194
+ * dispatched can still read `done` on the board because nothing compared the board's rows to what
195
+ * was actually dispatched, though both the scope contract and the order/result trail already exist
196
+ * on disk. A sibling to {@link driftCheck} (a FINISHED scope whose tasks are NOT done) — this is
197
+ * the opposite direction (done tasks, no dispatch trail) and both must keep running.
198
+ *
199
+ * @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
200
+ * @param {Array<{scope_id:string, use_cases?:string[]}>} scopes - Scope contracts.
201
+ * @param {string} orders - Absolute path to the run's `orders/` directory.
202
+ * @param {string} results - Absolute path to the run's `results/` directory.
203
+ * @returns {Array<{rule:string, level:string, scope_id:string, detail:string}>} One finding per
204
+ * undispatched-but-done scope, in this codebase's `{rule, level, detail}` finding shape
205
+ * (`kernel/verify/spec.mjs`); [] when every done-marked scope has a dispatch trail.
206
+ */
207
+ export function undispatchedCheck(tasks, scopes, orders, results) {
208
+ const answered = answeredScopeIds(orders, results);
209
+ const findings = [];
210
+ for (const s of scopes) {
211
+ // Joined through the contract's committed `use_cases`, same as driftCheck — the two read the
212
+ // scope↔task relation from one function so they cannot disagree about which tasks a scope owns.
213
+ const doneTaskId = tasksForScope(tasks, s).find((t) => t.status === "done")?.id;
214
+ if (doneTaskId && !answered.has(s.scope_id)) {
215
+ findings.push({
216
+ rule: "BOARD-UNDISPATCHED", level: "red", scope_id: s.scope_id,
217
+ detail: `${doneTaskId} reads done for scope ${s.scope_id}, but no order under orders/ has a ` +
218
+ `matching result under results/ for this scope — a cut, never-dispatched scope cannot read done`,
219
+ });
220
+ }
221
+ }
222
+ return findings;
223
+ }
224
+
157
225
  /**
158
226
  * Derive the full board report (unlocks, hours, packages, critical path, appetite overflow, drift).
159
227
  * @param {{cwd:string, slug:string, appetiteHours?:(number|null)}} opts - Working root, feature
@@ -170,7 +238,8 @@ export function derive({ cwd, slug, appetiteHours = null }) {
170
238
  const packages = {};
171
239
  for (const t of tasks) packages[t.pkg || "(none)"] = (packages[t.pkg || "(none)"] || 0) + 1;
172
240
 
173
- // Scope facts for the drift check: contract `tasks` list + committed hill shard phase.
241
+ // Scope facts for the drift check: the contract (its `use_cases` carry the task join)
242
+ // + the committed hill shard's phase.
174
243
  const hillRoot = hillDir(cwd, slug);
175
244
  const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map(({ contract }) => {
176
245
  const shard = join(hillRoot, `${contract.scope_id}.yml`);
@@ -194,6 +263,7 @@ export function derive({ cwd, slug, appetiteHours = null }) {
194
263
  unlocks,
195
264
  unlocks_stale: tasks.filter((t) => JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(unlocks[t.id])).map((t) => t.id),
196
265
  drift: driftCheck(tasks, scopes),
266
+ undispatched: undispatchedCheck(tasks, scopes, ordersDir(cwd, slug), resultsDir(cwd, slug)),
197
267
  _tasks: tasks,
198
268
  };
199
269
  }
@@ -220,9 +290,9 @@ export function writeUnlocks(report) {
220
290
  return written;
221
291
  }
222
292
 
223
- /** The typed argv contract (see `skills/tech-lead/scripts/lib/argv.mjs`). */
293
+ /** The typed argv contract (see `kernel/lib/argv.mjs`). */
224
294
  export const ARGV_SPEC = {
225
- usage: "board-derive.mjs --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
295
+ usage: "harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
226
296
  _: { arity: 0, max: 0, name: "(no positional operands)" },
227
297
  slug: { type: "str", required: true },
228
298
  cwd: { type: "path" },
@@ -230,9 +300,15 @@ export const ARGV_SPEC = {
230
300
  "appetite-hours": { type: "num", min: 0 },
231
301
  };
232
302
 
233
- const isMainModule = isMain(import.meta.url);
234
- if (isMainModule) {
235
- const args = runArgs(ARGV_SPEC);
303
+ /**
304
+ * Derive the task board from the scope contracts, and optionally write it.
305
+ *
306
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
307
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
308
+ * call `process.exit()` with the subcommand's documented code rather than returning.
309
+ */
310
+ export async function cli(rawArgv) {
311
+ const args = runArgs(ARGV_SPEC, rawArgv);
236
312
  const cwd = resolve(args.cwd || process.cwd());
237
313
  const report = derive({ cwd, slug: args.slug, appetiteHours: args.appetiteHours ?? null });
238
314
  if (args.write) report.unlocks_written = writeUnlocks(report);
@@ -0,0 +1,437 @@
1
+ // graph — the run graph: one typed, append-only edge list per feature.
2
+ //
3
+ // WHAT IT IS FOR. The harness has always been graph-shaped and never a graph: orders produce
4
+ // results, results produce verdicts, verdicts cite T0 artifacts, scopes cover requirements, trials
5
+ // supersede trials. All of it was real and all of it was scattered across markdown and JSON, so
6
+ // "trace this verdict back to the objective" meant grepping four files, and a relaunch re-derived
7
+ // the whole run state by walking directories. This file makes the same facts one queryable thing.
8
+ //
9
+ // TWO FAMILIES, DELIBERATELY NOT COLLAPSED (they answer different questions and change at different
10
+ // rates):
11
+ // WORK LINEAGE — Run, Order, Result, Verdict, Trial. "What happened, and what produced it."
12
+ // DOMAIN — Scope, UseCase, Requirement, Seam. "What exists, and how it relates."
13
+ // An edge may cross the families (a Scope COVERS a Requirement; an Order is DERIVED_FROM a Scope);
14
+ // a node never belongs to both.
15
+ //
16
+ // THE GRAPH IS DERIVED, NEVER AUTHORED. Every node here is projected from an artifact already on
17
+ // disk, so the graph can be deleted and rebuilt byte-identically, and a run that predates it is
18
+ // backfilled by the same code path that maintains it. That is what makes it safe to treat as the
19
+ // read model: it cannot drift from the artifacts, because it has no independent existence.
20
+ //
21
+ // SINGLE WRITER. `harness reduce graph` is the only thing that appends. It lives under `reduce/`
22
+ // rather than `lib/` to say so in the file layout — `probe` imports it read-only.
23
+ //
24
+ // APPEND-ONLY, and idempotent. Re-running over an unchanged tree appends nothing; re-running after
25
+ // new artifacts land appends only what is new. A node that reappears with new attributes is
26
+ // appended again and the LAST line wins on read, so the file is a log and the projection is a fold.
27
+
28
+ import { existsSync, readdirSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
29
+ import { join, dirname, basename, resolve } from "node:path";
30
+ import { runArgs } from "../lib/argv.mjs";
31
+ import {
32
+ localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath,
33
+ gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath,
34
+ } from "../lib/paths.mjs";
35
+ import { readAllContracts, readContract, ucId, SCOPE_CONTRACT, WIRING_MAP } from "../lib/contract.mjs";
36
+ import { runIdFromReceipt } from "../lib/paths.mjs";
37
+
38
+ /** The graph's home — one file per feature, beside the run trace it projects. */
39
+ export const graphPath = (cwd, slug) => join(localRoot(cwd, slug), "graph.jsonl");
40
+
41
+ /** Node types, by family. A type outside these sets is a bug, not an extension point. */
42
+ export const WORK_NODES = ["Run", "Order", "Result", "Verdict", "Trial", "GateDecision"];
43
+ export const DOMAIN_NODES = ["Scope", "UseCase", "Requirement", "Seam"];
44
+
45
+ /** Edge types. Each names a direction that is meaningful to read backwards. */
46
+ export const EDGES = ["PRODUCED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM"];
47
+
48
+ /**
49
+ * Read the graph as a log and fold it into nodes and edges.
50
+ *
51
+ * @param {string} cwd - Project root.
52
+ * @param {string} slug - Feature slug.
53
+ * @returns {{nodes: Map<string,object>, edges: Map<string,object>, lines: number}} The folded graph;
54
+ * `lines` counts what was read, so a caller can tell an empty graph from an absent one.
55
+ */
56
+ export function readGraph(cwd, slug) {
57
+ const nodes = new Map(), edges = new Map();
58
+ const p = graphPath(cwd, slug);
59
+ let lines = 0;
60
+ if (!existsSync(p)) return { nodes, edges, lines };
61
+ for (const line of readFileSync(p, "utf8").split("\n")) {
62
+ if (!line.trim()) continue;
63
+ let row;
64
+ try { row = JSON.parse(line); } catch { continue; } // a torn line proves nothing; skip it
65
+ lines++;
66
+ if (row.k === "node" && row.id) nodes.set(row.id, { ...(nodes.get(row.id) || {}), ...row });
67
+ else if (row.k === "edge" && row.from && row.to && row.t) edges.set(`${row.from}|${row.t}|${row.to}`, row);
68
+ }
69
+ return { nodes, edges, lines };
70
+ }
71
+
72
+ /** Read a JSON file without throwing. @param {string} p Path. @returns {*} Parsed value or null. */
73
+ function readJson(p) {
74
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
75
+ }
76
+
77
+ /** Every `## Heading`-anchored id in a requirements or use-case document. */
78
+ function idsIn(text, re) {
79
+ return [...new Set([...String(text || "").matchAll(re)].map((m) => m[1]))];
80
+ }
81
+
82
+ /**
83
+ * Project every artifact on disk for one run into nodes and edges.
84
+ *
85
+ * Pure: reads the tree, returns the graph it implies, writes nothing. That is what lets the same
86
+ * function serve both the incremental append and the backfill of a run recorded before this file
87
+ * existed — there is no second code path to keep in step.
88
+ *
89
+ * @param {string} cwd - Project root.
90
+ * @param {string} slug - Feature slug.
91
+ * @returns {{nodes: object[], edges: object[]}} Everything the artifacts imply, in a stable order.
92
+ */
93
+ export function project(cwd, slug) {
94
+ const nodes = [], edges = [];
95
+ const node = (id, t, attrs = {}) => nodes.push({ k: "node", id, t, ...attrs });
96
+ const edge = (from, t, to) => edges.push({ k: "edge", from, t, to });
97
+
98
+ const root = localRoot(cwd, slug);
99
+ const receipt = readJson(receiptPath(cwd, slug));
100
+ const runId = runIdFromReceipt(receipt);
101
+ const runNode = runId ? `run:${runId}` : null;
102
+
103
+ // ---- work lineage --------------------------------------------------------------------------
104
+ if (runNode) {
105
+ node(runNode, "Run", {
106
+ run_id: runId, slug,
107
+ started_at: receipt?.started_at ?? null,
108
+ intake_sha256: receipt?.intake_sha256 ?? null,
109
+ auto_level: receipt?.config?.auto_level ?? null,
110
+ });
111
+ }
112
+
113
+ const oDir = ordersDir(cwd, slug);
114
+ const rDir = resultsDir(cwd, slug);
115
+ const orderByFile = new Map();
116
+ if (existsSync(oDir)) {
117
+ for (const f of readdirSync(oDir).filter((x) => x.endsWith(".json")).sort()) {
118
+ const o = readJson(join(oDir, f));
119
+ if (!o?.order_id) continue;
120
+ const id = `order:${o.order_id}`;
121
+ orderByFile.set(f, { id, order: o });
122
+ node(id, "Order", {
123
+ order_id: o.order_id, worker: o.worker ?? null, operation: o.operation ?? null,
124
+ round: o.round ?? null, attempt: o.attempt ?? null,
125
+ scope_id: o.payload?.scope_contract?.scope_id ?? o.payload?.scope_id ?? null,
126
+ run_id: o.run_id ?? runId ?? null,
127
+ });
128
+ if (runNode) edge(runNode, "PRODUCED", id);
129
+ const scopeId = o.payload?.scope_contract?.scope_id ?? o.payload?.scope_id ?? null;
130
+ if (scopeId) edge(id, "DERIVED_FROM", `scope:${slug}:${scopeId}`);
131
+ }
132
+ }
133
+
134
+ if (existsSync(rDir)) {
135
+ for (const f of readdirSync(rDir).filter((x) => x.endsWith(".json")).sort()) {
136
+ const r = readJson(join(rDir, f));
137
+ if (!r?.order_id) continue;
138
+ const id = `result:${r.order_id}`;
139
+ node(id, "Result", {
140
+ order_id: r.order_id, status: r.status ?? null,
141
+ artifacts: Array.isArray(r.artifacts) ? r.artifacts.length : 0,
142
+ discoveries: Array.isArray(r.discoveries) ? r.discoveries.length : 0,
143
+ });
144
+ edge(`order:${r.order_id}`, "PRODUCED", id);
145
+ }
146
+ }
147
+
148
+ // T0 verdicts — the artifact the evaluator is required to cite, and the reason the lineage half
149
+ // of this graph is worth having: a verdict node is the anchor of every "show me the evidence".
150
+ const vDir = verdictsDir(cwd, slug);
151
+ const verdictIdsByRound = new Map(); // round -> [verdict node id, ...], for the gate edge below
152
+ if (existsSync(vDir)) {
153
+ for (const f of readdirSync(vDir).filter((x) => x.endsWith(".json")).sort()) {
154
+ const v = readJson(join(vDir, f));
155
+ if (!v) continue;
156
+ const id = `verdict:${slug}:${basename(f, ".json")}`;
157
+ node(id, "Verdict", {
158
+ artifact: f, overall: v.overall ?? null, regression: !!v.regression,
159
+ round: v.round ?? null, attempt: v.attempt ?? null, scope_id: v.scope_id ?? null,
160
+ run_id: v.run_id ?? runId ?? null,
161
+ });
162
+ if (v.scope_id) edge(id, "EVALUATES", `scope:${slug}:${v.scope_id}`);
163
+ if (v.round != null && v.attempt != null && v.scope_id) {
164
+ edge(`order:${slug}/${v.scope_id}-r${v.round}-a${v.attempt}`, "PRODUCED", id);
165
+ }
166
+ if (v.round != null) {
167
+ if (!verdictIdsByRound.has(v.round)) verdictIdsByRound.set(v.round, []);
168
+ verdictIdsByRound.get(v.round).push(id);
169
+ }
170
+ }
171
+ }
172
+
173
+ // Gate crossings — `kernel/gate.mjs` is the sole writer of `gates.jsonl`; this reads the same
174
+ // ledger `verify/t0.mjs` writes for `trials.jsonl`, defensive per-line JSON.parse-or-skip.
175
+ //
176
+ // ID KEYED ON GATE ID + ORDINAL, not gate id alone, for the exact reason the trial key above is
177
+ // not the ordinal alone: L2 and L3 are crossed once per round, so a bare `gate:<slug>:L2` id would
178
+ // collapse every round's crossing onto one node — a repeat of the trial-key bug this file already
179
+ // learned from once. The ordinal counts a gate id's occurrences in ledger order, which also
180
+ // disambiguates round-independent gates (L1a, …) re-crossed across relaunches, where `round` alone
181
+ // is `null` on every row.
182
+ const gPath = gatesPath(cwd, slug);
183
+ if (existsSync(gPath)) {
184
+ const seenByGate = new Map();
185
+ for (const line of readFileSync(gPath, "utf8").split("\n")) {
186
+ if (!line.trim()) continue;
187
+ const g = readJson0(line);
188
+ if (!g?.gate) continue;
189
+ const n = (seenByGate.get(g.gate) || 0) + 1;
190
+ seenByGate.set(g.gate, n);
191
+ const id = `gate:${slug}:${g.gate}:${n}`;
192
+ node(id, "GateDecision", {
193
+ gate: g.gate, decision: g.decision ?? null, status: g.status ?? null,
194
+ source: g.source ?? null, note: g.note ?? null, round: g.round ?? null,
195
+ run_id: g.run_id ?? runId ?? null,
196
+ });
197
+ // A round-scoped gate's decision depends on that round's T0 verdict(s) — the most honest
198
+ // shape, since that is the evidence the decision was made against. Every other gate (and a
199
+ // round-scoped one with no verdict yet on disk) depends on the Run instead, so no
200
+ // `GateDecision` node is ever orphaned.
201
+ const roundVerdicts = (g.gate === "L2" || g.gate === "L3") ? verdictIdsByRound.get(g.round) : null;
202
+ if (roundVerdicts?.length) {
203
+ for (const vid of roundVerdicts) edge(id, "DEPENDS_ON", vid);
204
+ } else if (runNode) {
205
+ edge(id, "DEPENDS_ON", runNode);
206
+ }
207
+ }
208
+ }
209
+
210
+ // Trials — the ratchet's own history, with a real parent link already in the row.
211
+ const tPath = trialsPath(cwd, slug);
212
+ if (existsSync(tPath)) {
213
+ for (const line of readFileSync(tPath, "utf8").split("\n")) {
214
+ if (!line.trim()) continue;
215
+ const t = readJson0(line);
216
+ if (!t?.trial) continue;
217
+ // THE SCOPE IS PART OF THE KEY, because the ordinal alone is not unique. A trial ordinal
218
+ // counts within its scope, so `trial:<slug>:1` names one row per scope and every one of them
219
+ // collapsed onto a single node — the Map this graph is read back into keeps the last writer,
220
+ // so a scope's whole execution record vanished silently. Measured: four scopes' trials
221
+ // projected to two nodes. `--trace` is supposed to reach "the execution record"; it reached
222
+ // whichever scope happened to be written last. `baseline_trial` is chosen from the same
223
+ // scope's prior rows, so the SUPERSEDES edge resolves inside the same partition.
224
+ const trialKey = (n) => `trial:${slug}:${t.scope_id ? `${t.scope_id}:` : ""}${n}`;
225
+ const id = trialKey(t.trial);
226
+ node(id, "Trial", {
227
+ trial: t.trial, round: t.round ?? null, attempt: t.attempt ?? null,
228
+ scope_id: t.scope_id ?? null, status: t.status ?? null, artifact: t.artifact ?? null,
229
+ sha256: t.sha256 ?? null, run_id: t.run_id ?? runId ?? null,
230
+ });
231
+ if (t.baseline_trial) edge(id, "SUPERSEDES", trialKey(t.baseline_trial));
232
+ if (t.artifact) edge(id, "EVALUATES", `verdict:${slug}:${basename(String(t.artifact), ".json")}`);
233
+ }
234
+ }
235
+
236
+ // ---- domain --------------------------------------------------------------------------------
237
+ const sDir = scopesDir(cwd, slug);
238
+ if (existsSync(sDir)) {
239
+ for (const { contract } of readAllContracts(sDir, SCOPE_CONTRACT)) {
240
+ if (!contract?.scope_id) continue;
241
+ const id = `scope:${slug}:${contract.scope_id}`;
242
+ node(id, "Scope", {
243
+ scope_id: contract.scope_id, title: contract.title ?? null,
244
+ substrate: (contract.allowed_file_substrate || []).length,
245
+ });
246
+ // The domain family's entry edges. Before scope contracts anchored into the committed spec
247
+ // these could not be drawn: `covers` had this reader and no writer, and there was no
248
+ // Scope→UseCase relation at all, so a Scope sat in the graph as an isolated node beside the
249
+ // UseCase nodes it was built from and `--trace` stopped there instead of reaching the
250
+ // objective. The contract now names both, so both are projectable.
251
+ for (const uc of contract.use_cases || []) edge(id, "IMPLEMENTS", `uc:${slug}:${ucId(uc)}`);
252
+ for (const req of contract.covers || []) edge(id, "COVERS", `req:${slug}:${req}`);
253
+ for (const dep of contract.depends_on || []) edge(id, "DEPENDS_ON", `scope:${slug}:${dep}`);
254
+ }
255
+ }
256
+
257
+ const uDir = usecasesDir(cwd, slug);
258
+ if (existsSync(uDir)) {
259
+ for (const f of readdirSync(uDir).filter((x) => x.endsWith(".md") && x !== "_index.md").sort()) {
260
+ const ucId = basename(f, ".md");
261
+ node(`uc:${slug}:${ucId}`, "UseCase", { use_case: ucId, file: f });
262
+ }
263
+ }
264
+
265
+ const reqPath = requirementsPath(cwd, slug);
266
+ if (existsSync(reqPath)) {
267
+ for (const r of idsIn(readFileSync(reqPath, "utf8"), /^\s*[-*|]?\s*\**\s*(REQ-[A-Z0-9-]+)/gm)) {
268
+ node(`req:${slug}:${r}`, "Requirement", { req_id: r });
269
+ }
270
+ }
271
+
272
+ // The wiring map is literally a graph written as a table: per use case, engine → seam →
273
+ // entry-point call site → affordance. Reading it back as edges is the whole reason it is a table.
274
+ const wPath = wiringMapPath(cwd, slug);
275
+ if (existsSync(wPath)) {
276
+ // READ THE FIELDS THE SPEC ACTUALLY PRODUCES. This loop asked for `contract.wiring` while
277
+ // WIRING_MAP names the field `entries`, and for `row.seam`/`row.entry_point` while WiringEntry
278
+ // declares `wiring_seam`/`entry_call_site` — three name mismatches stacked on a parser that was
279
+ // returning nothing anyway. Every one of them independently yields an empty domain half, and
280
+ // none of them can fail loudly: an absent field and an absent wiring map look identical here.
281
+ // The legacy spellings stay as fallbacks so a map written either way still projects.
282
+ const found = readContract(wPath, WIRING_MAP);
283
+ for (const row of found?.contract?.entries || found?.contract?.wiring || found?.contract?.rows || []) {
284
+ const uc = row.use_case || row.uc;
285
+ const seam = row.wiring_seam || row.seam;
286
+ const entryPoint = row.entry_call_site ?? row.entry_point ?? null;
287
+ if (!uc || !seam) continue;
288
+ const seamId = `seam:${slug}:${seam}`;
289
+ node(seamId, "Seam", { seam, engine: row.engine ?? null, entry_point: entryPoint });
290
+ edge(`uc:${slug}:${ucId(uc)}`, "DEPENDS_ON", seamId);
291
+ }
292
+ }
293
+
294
+ return { nodes, edges };
295
+ }
296
+
297
+ /** JSON.parse for one line, without throwing. @param {string} l Line. @returns {*} Parsed or null. */
298
+ function readJson0(l) {
299
+ try { return JSON.parse(l); } catch { return null; }
300
+ }
301
+
302
+ /**
303
+ * Append everything the artifacts imply that the graph does not already carry.
304
+ *
305
+ * @param {string} cwd - Project root.
306
+ * @param {string} slug - Feature slug.
307
+ * @returns {{path: string, nodes_added: number, edges_added: number, nodes_total: number, edges_total: number, backfilled: boolean}}
308
+ * `backfilled` is true when the graph did not exist before this call — the migration case, which
309
+ * is the same code path as the steady-state one.
310
+ */
311
+ export function appendGraph(cwd, slug) {
312
+ const p = graphPath(cwd, slug);
313
+ const before = readGraph(cwd, slug);
314
+ const backfilled = before.lines === 0;
315
+ const { nodes, edges } = project(cwd, slug);
316
+
317
+ const out = [];
318
+ for (const n of nodes) {
319
+ const prev = before.nodes.get(n.id);
320
+ // Append only when something actually changed — the file is a log, and a log that repeats
321
+ // itself on every run stops being readable long before it stops being correct.
322
+ if (!prev || JSON.stringify({ ...prev, k: undefined }) !== JSON.stringify({ ...n, k: undefined })) out.push(n);
323
+ }
324
+ for (const e of edges) {
325
+ if (!before.edges.has(`${e.from}|${e.t}|${e.to}`)) out.push(e);
326
+ }
327
+
328
+ if (out.length) {
329
+ mkdirSync(dirname(p), { recursive: true });
330
+ appendFileSync(p, out.map((r) => JSON.stringify(r)).join("\n") + "\n");
331
+ }
332
+ const after = readGraph(cwd, slug);
333
+ return {
334
+ path: p,
335
+ nodes_added: out.filter((r) => r.k === "node").length,
336
+ edges_added: out.filter((r) => r.k === "edge").length,
337
+ nodes_total: after.nodes.size,
338
+ edges_total: after.edges.size,
339
+ backfilled,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * The bounded subgraph a relaunch needs — the fast-forward as a query rather than a directory walk.
345
+ *
346
+ * @param {string} cwd - Project root.
347
+ * @param {string} slug - Feature slug.
348
+ * @returns {object} Counts and ids only. Deliberately small: the point of the graph is that the
349
+ * orchestrator's working set is a subgraph, not a re-read of all state.
350
+ */
351
+ export function runSubgraph(cwd, slug) {
352
+ const { nodes, edges, lines } = readGraph(cwd, slug);
353
+ const of = (t) => [...nodes.values()].filter((n) => n.t === t);
354
+ const orders = of("Order"), results = of("Result"), verdicts = of("Verdict");
355
+ const resultIds = new Set(results.map((r) => r.order_id));
356
+ const greenByRound = {};
357
+ for (const v of verdicts) {
358
+ if (v.overall !== "green" || v.round == null || !v.scope_id) continue;
359
+ (greenByRound[v.round] ||= new Set()).add(v.scope_id);
360
+ }
361
+ return {
362
+ graph_lines: lines,
363
+ run: of("Run")[0]?.run_id ?? null,
364
+ scopes: of("Scope").map((s) => s.scope_id).sort(),
365
+ use_cases: of("UseCase").map((u) => u.use_case).sort(),
366
+ requirements: of("Requirement").map((r) => r.req_id).sort(),
367
+ seams: of("Seam").map((s) => s.seam).sort(),
368
+ orders: orders.length,
369
+ pending_orders: orders.filter((o) => !resultIds.has(o.order_id)).map((o) => o.order_id).sort(),
370
+ rounds_with_green: Object.keys(greenByRound).map(Number).sort((a, b) => a - b),
371
+ green_scopes_by_round: Object.fromEntries(Object.entries(greenByRound).map(([r, s]) => [r, [...s].sort()])),
372
+ trials: of("Trial").length,
373
+ edges: edges.size,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Walk backwards from one node, following every edge that points at it or away from it.
379
+ *
380
+ * This is the reliability invariant made executable: pick a verdict, walk to the order that
381
+ * produced it, the scope it evaluates, the requirements that scope covers, and the trials that
382
+ * superseded it — in one query, over one file, instead of a grep across four.
383
+ *
384
+ * @param {string} cwd - Project root.
385
+ * @param {string} slug - Feature slug.
386
+ * @param {string} startId - The node to trace from.
387
+ * @param {number} [depth=4] - How many hops to follow.
388
+ * @returns {{start: string, found: boolean, path: object[]}} Each hop as `{from, t, to, node}`. A
389
+ * hop whose far node carries `missing: true` is a dangling edge — the artifact it names is not on
390
+ * disk. That is reported rather than dropped: an edge to nothing is a fact about the run.
391
+ */
392
+ export function trace(cwd, slug, startId, depth = 4) {
393
+ const { nodes, edges } = readGraph(cwd, slug);
394
+ if (!nodes.has(startId)) return { start: startId, found: false, path: [] };
395
+ const seen = new Set([startId]);
396
+ let frontier = [startId];
397
+ const path = [];
398
+ for (let d = 0; d < depth && frontier.length; d++) {
399
+ const next = [];
400
+ for (const e of edges.values()) {
401
+ for (const [near, far] of [[e.from, e.to], [e.to, e.from]]) {
402
+ if (!frontier.includes(near) || seen.has(far)) continue;
403
+ seen.add(far);
404
+ next.push(far);
405
+ path.push({ from: e.from, t: e.t, to: e.to, node: nodes.get(far) ?? { id: far, t: "?", missing: true } });
406
+ }
407
+ }
408
+ frontier = next;
409
+ }
410
+ return { start: startId, found: true, path };
411
+ }
412
+
413
+ export const ARGV_SPEC = {
414
+ usage: "harness.mjs reduce graph --slug <slug> [--cwd <dir>] [--subgraph run] [--trace <node-id>] [--depth N]",
415
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
416
+ slug: { type: "str", required: true },
417
+ cwd: { type: "path" },
418
+ subgraph: { type: "enum", values: ["run"] },
419
+ trace: { type: "str" },
420
+ depth: { type: "int", min: 1, max: 12, default: 4 },
421
+ };
422
+
423
+ /**
424
+ * Append everything the artifacts imply, then optionally answer a query over the result.
425
+ *
426
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
427
+ * @returns {void} Exits 0; prints the append report, or the requested query, as JSON.
428
+ */
429
+ export function cli(rawArgv) {
430
+ const args = runArgs(ARGV_SPEC, rawArgv);
431
+ const cwd = resolve(args.cwd || process.cwd());
432
+ const report = appendGraph(cwd, args.slug);
433
+ if (args.trace) { console.log(JSON.stringify(trace(cwd, args.slug, args.trace, args.depth), null, 2)); process.exit(0); }
434
+ if (args.subgraph) { console.log(JSON.stringify(runSubgraph(cwd, args.slug), null, 2)); process.exit(0); }
435
+ console.log(JSON.stringify(report, null, 2));
436
+ process.exit(0);
437
+ }