shapeup-sdlc 1.6.3 → 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 (118) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +54 -107
  4. package/README.md +165 -151
  5. package/SECURITY.md +49 -27
  6. package/bin/init.mjs +93 -108
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +20 -0
  9. package/commands/eval.md +5 -4
  10. package/commands/scopes.md +5 -4
  11. package/commands/shape.md +1 -1
  12. package/commands/ship.md +53 -7
  13. package/commands/wire.md +1 -1
  14. package/hooks/dispatch-receipt.mjs +195 -0
  15. package/hooks/gate-intake.mjs +16 -17
  16. package/hooks/gate-zerowork.mjs +107 -25
  17. package/hooks/hooks.json +9 -48
  18. package/hooks/lib/decision.mjs +38 -19
  19. package/hooks/safety-spine.mjs +4 -4
  20. package/hooks/sandbox-guard.mjs +130 -50
  21. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
  22. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
  23. package/kernel/harness.mjs +134 -0
  24. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
  25. package/kernel/init/run.mjs +489 -0
  26. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  27. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
  28. package/kernel/lib/paths.mjs +491 -0
  29. package/kernel/probe/concurrency.mjs +510 -0
  30. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +10 -11
  31. package/kernel/probe/eval.mjs +77 -0
  32. package/kernel/probe/leg.mjs +125 -0
  33. package/kernel/probe/resume.mjs +528 -0
  34. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
  35. package/kernel/probe/t0.mjs +66 -0
  36. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
  37. package/kernel/reduce/graph.mjs +437 -0
  38. package/kernel/reduce/hill.mjs +152 -0
  39. package/kernel/reduce/ingest.mjs +633 -0
  40. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  41. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +93 -14
  42. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
  43. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
  44. package/kernel/report/export.mjs +325 -0
  45. package/kernel/report/facts.mjs +347 -0
  46. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
  47. package/kernel/verify/dispatch.mjs +114 -0
  48. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  49. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  50. package/kernel/verify/skills.mjs +125 -0
  51. package/kernel/verify/spec.mjs +559 -0
  52. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +138 -28
  53. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
  54. package/oracles/_shared.mjs +1 -1
  55. package/oracles/http-oracle.mjs +2 -2
  56. package/oracles/index.mjs +1 -1
  57. package/oracles/process-oracle.mjs +2 -2
  58. package/oracles/snapshot-oracle.mjs +2 -2
  59. package/oracles/test-oracle.mjs +2 -2
  60. package/package.json +11 -13
  61. package/skills/ba-pitch-analyzer/SKILL.md +24 -19
  62. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  63. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  64. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  65. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  66. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  67. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  68. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  69. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  70. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  71. package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -1
  72. package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
  73. package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
  74. package/skills/coach/SKILL.md +3 -3
  75. package/skills/orient/SKILL.md +2 -1
  76. package/skills/qa-edge-hunter/SKILL.md +15 -41
  77. package/skills/scope-architect/SKILL.md +57 -18
  78. package/skills/scope-hammer/SKILL.md +5 -5
  79. package/skills/shapeup/SKILL.md +3 -3
  80. package/skills/shapeup/resources/context-compaction.md +4 -3
  81. package/skills/solution-architect/SKILL.md +37 -15
  82. package/skills/spec-evaluator/SKILL.md +24 -7
  83. package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
  84. package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
  85. package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
  86. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
  87. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
  88. package/skills/spec-evaluator/references/probing.md +42 -23
  89. package/skills/spec-evaluator/references/report-schema.md +2 -2
  90. package/skills/task-executor/SKILL.md +32 -18
  91. package/skills/tech-lead/SKILL.md +127 -438
  92. package/skills/tech-lead/references/gates.md +140 -49
  93. package/skills/tech-lead/references/protocol.md +832 -0
  94. package/skills/tech-lead/schemas/domain.schema.json +645 -198
  95. package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
  96. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  97. package/skills/tech-lead/schemas/work-result.schema.json +56 -18
  98. package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
  99. package/skills/translator/SKILL.md +2 -2
  100. package/hooks/anti-rationalization.mjs +0 -244
  101. package/hooks/compact-snapshot.mjs +0 -47
  102. package/hooks/gate-deadline.mjs +0 -151
  103. package/hooks/gate-l2.mjs +0 -161
  104. package/hooks/session-rehydrate.mjs +0 -109
  105. package/skills/advisor-protocol/SKILL.md +0 -171
  106. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  107. package/skills/spec-evaluator/README.md +0 -93
  108. package/skills/tech-lead/README.md +0 -71
  109. package/skills/tech-lead/references/delegation.md +0 -254
  110. package/skills/tech-lead/references/invocation.md +0 -45
  111. package/skills/tech-lead/references/ledger-schema.md +0 -214
  112. package/skills/tech-lead/references/round-protocol.md +0 -184
  113. package/skills/tech-lead/references/state-model.md +0 -66
  114. package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
  115. package/skills/tech-lead/scripts/init-run.mjs +0 -326
  116. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
  117. package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
  118. 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
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Hill Phase Derivation — phases are derived from facts, never authored
3
+ // Mechanical derivation of a scope's hill position based on facts (T0 verdicts, T1 evaluation, and discovery ledger).
4
+ // Writes to shapeup/<slug>/hill/<scope-id>.yml
5
+
6
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
7
+ import { resolve, join } from "node:path";
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";
11
+
12
+ /**
13
+ * Derive and write the hill phase for all scopes mechanically based on T0, T1, and ledger facts.
14
+ *
15
+ * The derived phase follows these progression rules (facts move dots, not authors):
16
+ * - UPHILL_UNKNOWN: open unknowns > 0 in the ledger for this scope
17
+ * - UPHILL_SOLVED: unknowns 0, no T0-green yet
18
+ * - DOWNHILL_EXECUTION: ≥1 T0-green; T1/seesaw pending
19
+ * - FINISHED: T1 PASS ∧ seesaw green
20
+ *
21
+ * @param {string} cwd - The project root directory.
22
+ * @param {string} slug - The feature slug being built.
23
+ * @returns {Array<{scope_id: string, phase: string, changed: boolean}>} A report of all scopes processed, their derived phase, and whether the hill shard on disk was modified.
24
+ * Side effects: writes to `shapeup/<slug>/hill/<scope-id>.yml` for each scope.
25
+ */
26
+ export function deriveHill(cwd, slug) {
27
+ const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map((x) => x.contract);
28
+ const vDir = verdictsDir(cwd, slug);
29
+ const evalDir = evaluationDir(cwd, slug);
30
+ const ledgerPath = discoveryLedger(cwd, slug);
31
+ const hDir = hillDir(cwd, slug);
32
+
33
+ if (!existsSync(hDir)) mkdirSync(hDir, { recursive: true });
34
+
35
+ // 1. Check if T1 Evaluation passed (spec-conformance === PASS for the most recent run)
36
+ let t1Pass = false;
37
+ const evalFile = join(evalDir, ".verdicts-run.jsonl");
38
+ if (existsSync(evalFile)) {
39
+ const lines = readFileSync(evalFile, "utf8").trim().split(/\n/).filter(Boolean);
40
+ let maxRun = 0;
41
+ for (const line of lines) {
42
+ try {
43
+ const parsed = JSON.parse(line);
44
+ if (parsed.run >= maxRun) {
45
+ maxRun = parsed.run;
46
+ if (parsed.dimension === "spec-conformance") {
47
+ t1Pass = (parsed.verdict === "PASS");
48
+ }
49
+ }
50
+ } catch (e) {
51
+ // ignore parse errors
52
+ }
53
+ }
54
+ }
55
+
56
+ // 2. T0 facts per scope: has it achieved a green overall verdict? was seesaw also green?
57
+ const t0Facts = {};
58
+ if (existsSync(vDir)) {
59
+ for (const f of readdirSync(vDir)) {
60
+ if (!f.endsWith(".json")) continue;
61
+ try {
62
+ const b = JSON.parse(readFileSync(join(vDir, f), "utf8"));
63
+ if (!t0Facts[b.scope_id]) t0Facts[b.scope_id] = { hasGreen: false, seesawGreen: false };
64
+ if (b.overall === "green") {
65
+ t0Facts[b.scope_id].hasGreen = true;
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) {
81
+ t0Facts[b.scope_id].seesawGreen = true;
82
+ }
83
+ }
84
+ } catch (e) {
85
+ // ignore parse errors
86
+ }
87
+ }
88
+ }
89
+
90
+ // 3. Ledger unknowns per scope
91
+ const scopeUnknowns = {};
92
+ if (existsSync(ledgerPath)) {
93
+ const lines = readFileSync(ledgerPath, "utf8").split("\n");
94
+ let currentScope = null;
95
+ for (const line of lines) {
96
+ const m = line.match(/^## Discovered — .*?:([\w.-]+)-a\d+/);
97
+ if (m) {
98
+ currentScope = m[1];
99
+ }
100
+ if (currentScope && line.startsWith("~ ")) {
101
+ scopeUnknowns[currentScope] = (scopeUnknowns[currentScope] || 0) + 1;
102
+ }
103
+ }
104
+ }
105
+
106
+ const report = [];
107
+ for (const s of scopes) {
108
+ const id = s.scope_id;
109
+ const t0 = t0Facts[id] || { hasGreen: false, seesawGreen: false };
110
+ const unknowns = scopeUnknowns[id] || 0;
111
+
112
+ let phase = "UPHILL_UNKNOWN";
113
+ if (t1Pass && t0.hasGreen && t0.seesawGreen) {
114
+ phase = "FINISHED";
115
+ } else if (t0.hasGreen) {
116
+ phase = "DOWNHILL_EXECUTION";
117
+ } else if (unknowns === 0) {
118
+ phase = "UPHILL_SOLVED";
119
+ }
120
+
121
+ const yaml = `scope_id: ${id}\nphase: ${phase}\n`;
122
+ const out = join(hDir, `${id}.yml`);
123
+ let changed = false;
124
+ if (!existsSync(out) || readFileSync(out, "utf8") !== yaml) {
125
+ writeFileSync(out, yaml);
126
+ changed = true;
127
+ }
128
+ report.push({ scope_id: id, phase, changed });
129
+ }
130
+ return report;
131
+ }
132
+
133
+ export const ARGV_SPEC = {
134
+ usage: "harness.mjs reduce hill --slug <slug> [--cwd <dir>]",
135
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
136
+ slug: { type: "str", required: true },
137
+ cwd: { type: "path" },
138
+ };
139
+
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);
149
+ const cwd = resolve(args.cwd || process.cwd());
150
+ const report = deriveHill(cwd, args.slug);
151
+ console.log(JSON.stringify(report, null, 2));
152
+ }