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,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
+ }
@@ -5,10 +5,9 @@
5
5
 
6
6
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
7
7
  import { resolve, join } from "node:path";
8
- import { isMain } from "./lib/is-main.mjs";
9
- import { runArgs } from "./lib/argv.mjs";
10
- import { scopesDir, hillDir, verdictsDir, evaluationDir, discoveryLedger } from "./lib/paths.mjs";
11
- import { readAllContracts, SCOPE_CONTRACT } from "./lib/contract-md.mjs";
8
+ import { runArgs } from "../lib/argv.mjs";
9
+ import { scopesDir, hillDir, verdictsDir, evaluationDir, discoveryLedger } from "../lib/paths.mjs";
10
+ import { readAllContracts, SCOPE_CONTRACT } from "../lib/contract.mjs";
12
11
 
13
12
  /**
14
13
  * Derive and write the hill phase for all scopes mechanically based on T0, T1, and ledger facts.
@@ -64,8 +63,21 @@ export function deriveHill(cwd, slug) {
64
63
  if (!t0Facts[b.scope_id]) t0Facts[b.scope_id] = { hasGreen: false, seesawGreen: false };
65
64
  if (b.overall === "green") {
66
65
  t0Facts[b.scope_id].hasGreen = true;
67
- // If regression is false on a green T0, it means seesaw was also green
68
- if (b.regression === false) {
66
+ // Read the REAL seesaw result off the verdict artifact (`t0.mjs`'s `writeArtifact()`
67
+ // already persists the full `{ran, pass, scopes_checked, failing}` object), rather than
68
+ // inferring it from a false `regression` flag. That inference was vacuously true on every
69
+ // green T0 whether or not a seesaw check ever ran: nothing in this codebase currently
70
+ // passes `--seesaw-registry` to `verify t0`, so `seesaw.ran` is always `false` today and
71
+ // `regression` is always `false` too — "not asked" was being read as "clean," letting a
72
+ // scope reach FINISHED on a regression check that had never executed.
73
+ //
74
+ // Betting Table decision (Phase 3.5 / S4): wiring the seesaw registry for real is a
75
+ // genuine feature with a real running cost (re-running every finished scope's fixtures
76
+ // on every later attempt) and is out of proportion to a certification-gap fix. Deferred,
77
+ // not silently dropped — a scope with no registry wired simply cannot reach FINISHED via
78
+ // this path today, which is the honest state of the system: this check was never really
79
+ // gating FINISHED before either.
80
+ if (b.seesaw?.ran && b.seesaw?.pass) {
69
81
  t0Facts[b.scope_id].seesawGreen = true;
70
82
  }
71
83
  }
@@ -119,14 +131,21 @@ export function deriveHill(cwd, slug) {
119
131
  }
120
132
 
121
133
  export const ARGV_SPEC = {
122
- usage: "hill-derive.mjs --slug <slug> [--cwd <dir>]",
134
+ usage: "harness.mjs reduce hill --slug <slug> [--cwd <dir>]",
123
135
  _: { arity: 0, max: 0, name: "(no positional operands)" },
124
136
  slug: { type: "str", required: true },
125
137
  cwd: { type: "path" },
126
138
  };
127
139
 
128
- if (isMain(import.meta.url)) {
129
- const args = runArgs(ARGV_SPEC);
140
+ /**
141
+ * Derive each scope's hill phase from its T0 and evaluation artifacts.
142
+ *
143
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
144
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
145
+ * call `process.exit()` with the subcommand's documented code rather than returning.
146
+ */
147
+ export async function cli(rawArgv) {
148
+ const args = runArgs(ARGV_SPEC, rawArgv);
130
149
  const cwd = resolve(args.cwd || process.cwd());
131
150
  const report = deriveHill(cwd, args.slug);
132
151
  console.log(JSON.stringify(report, null, 2));