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
@@ -0,0 +1,633 @@
1
+ #!/usr/bin/env node
2
+ // WorkResult ingester (pure-skill architecture v1.0, plan P1).
3
+ //
4
+ // The other half of the orchestrator's pipeline sub-layer — and the mechanism that finally
5
+ // closes D6: workers no longer write shared state; they RETURN data (a WorkResult envelope)
6
+ // and this script performs every shared-state write, deterministically, in one place:
7
+ //
8
+ // task_results[] → tick AC boxes, flip task frontmatter status, append Execution Log,
9
+ // update tasks/_index.md row, propagate unblocks (old P3.1–P3.6)
10
+ // discoveries[] → append to .shapeup/<slug>/discovery/ledger.md (old P3.7 / QA H.3)
11
+ // verdict.criteria[] → append evaluation/.verdicts-<target>.jsonl (old evaluator B.0)
12
+ // verdict.refuted[] → un-tick refuted AC boxes + set eval_verdict frontmatter (old B.2/B.2b)
13
+ // (the leg itself) → append one leg-completion row to .shapeup/<slug>/legs.jsonl
14
+ //
15
+ // That last one is the LEG'S END, and it is here because this is the step that closes a leg. The
16
+ // dispatch receipt gives a start; nothing gave an end, so how long a leg took and how many ran at
17
+ // once were unanswerable from the record set. See legRow() for why every field is a snapshot.
18
+ //
19
+ // Zero dependencies, zero network, schema-validated input (a malformed result never mutates
20
+ // the board). Single-writer becomes mechanically true, not aspirational.
21
+ //
22
+ // Usage: node kernel/harness.mjs reduce ingest <result.json> [--cwd <dir>]
23
+ // Exit: 0 = ingested, 1 = result rejected (schema) or a write failed.
24
+
25
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, rmSync, statSync, existsSync, readdirSync } from "node:fs";
26
+ import { resolve, join, dirname, basename } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { validate } from "../verify/envelope.mjs";
29
+ import { runArgs } from "../lib/argv.mjs";
30
+ import { tasksDir, localRoot, dispatchReceipts, legLedger, readRunId } from "../lib/paths.mjs";
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+ const RESULT_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "../../skills/tech-lead/schemas/work-result.schema.json"), "utf8"));
34
+
35
+ /**
36
+ * @returns {string} Today's date as an ISO `YYYY-MM-DD` string (UTC), for log/frontmatter stamps.
37
+ */
38
+ const today = () => new Date().toISOString().slice(0, 10);
39
+
40
+ /**
41
+ * Hold an exclusive lock on one run's shared state for the duration of `fn`.
42
+ *
43
+ * WHY THIS EXISTS (measured, not theorized). Since scopes fan out, several legs call this reducer
44
+ * AT THE SAME TIME, each with its own result. Most of what it writes is per-task and cannot
45
+ * collide — but `tasks/_index.md`, the discovery ledger and the verdict record are one file each,
46
+ * updated read-modify-write. Three concurrent ingests over three different tasks left all three
47
+ * task files `done` and the BOARD showing two: the middle write read the index before the first
48
+ * had written it and then overwrote it. A board that disagrees with its own task files is the
49
+ * exact "parallel work corrupts shared state" failure the single-writer rule exists to prevent,
50
+ * and the rule was true of the CODE and false of the PROCESS the moment there were two of them.
51
+ *
52
+ * `mkdir` is the primitive: it is atomic on POSIX and Windows alike, needs no dependency, and
53
+ * leaves a directory a human can delete. A stale lock older than the timeout is broken rather than
54
+ * waited on forever — a crashed reducer must not wedge every later one.
55
+ *
56
+ * @param {string} cwd - Project root.
57
+ * @param {string} slug - Feature slug; the lock is per run, never global.
58
+ * @param {Function} fn - The critical section.
59
+ * @returns {*} Whatever `fn` returns.
60
+ */
61
+ function withLock(cwd, slug, fn) {
62
+ const lock = join(localRoot(cwd, slug), ".ingest.lock");
63
+ const owner = join(lock, "owner.json");
64
+ const STALE_MS = 30_000, WAIT_MS = 20;
65
+ const startedAt = Date.now();
66
+ mkdirSync(dirname(lock), { recursive: true });
67
+
68
+ /**
69
+ * Is the process that took this lock still running?
70
+ *
71
+ * AGE ALONE CANNOT ANSWER IT, and that was the hole. `mkdir` stamps the lock's mtime once and the
72
+ * critical section is synchronous, so a holder cannot refresh it while it works — which makes "has
73
+ * been working for 31 seconds" and "died 31 seconds ago" the same observation. A waiter then broke
74
+ * a live holder's lock and entered the section beside it, so the reducer that exists to serialise
75
+ * shared-state writes had two writers inside it. Reproduced directly: a 45-second-old lock over a
76
+ * working holder was broken and the second ingest wrote.
77
+ *
78
+ * `kill(pid, 0)` sends no signal and only asks whether the process exists. A lock with no readable
79
+ * owner is treated as ABANDONED once it is also stale — that is the pre-existing behaviour, kept
80
+ * for locks written before this file recorded an owner.
81
+ *
82
+ * @returns {boolean} True when a live process holds the lock.
83
+ */
84
+ const heldByLiveProcess = () => {
85
+ try {
86
+ const o = JSON.parse(readFileSync(owner, "utf8"));
87
+ if (typeof o?.pid !== "number") return false;
88
+ process.kill(o.pid, 0); // throws ESRCH when the process is gone
89
+ return true;
90
+ } catch { return false; }
91
+ };
92
+
93
+ for (;;) {
94
+ try {
95
+ mkdirSync(lock);
96
+ // Best-effort: the lock is already held by us, so failing to name the owner costs a later
97
+ // waiter its liveness check, never correctness of this section.
98
+ try { writeFileSync(owner, JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); } catch { /* ignore */ }
99
+ break;
100
+ } catch { /* held — wait, or break an abandoned one */ }
101
+ let heldFor = 0;
102
+ try { heldFor = Date.now() - statSync(lock).mtimeMs; } catch { continue; } // released mid-check
103
+ // Break it only when it is BOTH stale AND ownerless-or-dead. A slow holder keeps its lock.
104
+ if (heldFor > STALE_MS && !heldByLiveProcess()) {
105
+ try { rmSync(lock, { recursive: true, force: true }); } catch { /* someone else broke it */ }
106
+ continue;
107
+ }
108
+ if (Date.now() - startedAt > STALE_MS) {
109
+ // Refusing is the safe direction: proceeding unlocked is how the lost update happened.
110
+ throw new Error(`ingest could not take the ${slug} lock within ${STALE_MS} ms (${lock}) — another reducer is holding it`);
111
+ }
112
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, WAIT_MS); // sleep, no busy spin
113
+ }
114
+ try { return fn(); } finally { try { rmSync(lock, { recursive: true, force: true }); } catch { /* already gone */ } }
115
+ }
116
+
117
+ /**
118
+ * Locate a task file on the LOCAL board by id.
119
+ * @param {string} cwd - Working-directory root.
120
+ * @param {string} slug - Feature slug.
121
+ * @param {string} taskId - Task id prefix to match (e.g. "TASK-001").
122
+ * @returns {string|null} Absolute path of the first `<taskId>*.md` file, or null when the tasks
123
+ * directory or a matching file does not exist.
124
+ */
125
+ export function findTaskFile(cwd, slug, taskId) {
126
+ const dir = tasksDir(cwd, slug);
127
+ if (!existsSync(dir)) return null;
128
+ const f = readdirSync(dir).find((n) => n.startsWith(taskId) && n.endsWith(".md"));
129
+ return f ? join(dir, f) : null;
130
+ }
131
+
132
+ /**
133
+ * Set a scalar frontmatter field in a task-file body, adding it when absent.
134
+ * @param {string} body - Full task-file text.
135
+ * @param {string} key - Frontmatter key to set.
136
+ * @param {(string|number)} value - Value to write (stringified inline).
137
+ * @returns {string} The body with `key: value` set; returned unchanged when the body has no
138
+ * frontmatter block.
139
+ */
140
+ export function setFrontmatter(body, key, value) {
141
+ const m = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
142
+ if (!m) return body;
143
+ const re = new RegExp(`^${key}:.*$`, "m");
144
+ const fm = re.test(m[1]) ? m[1].replace(re, `${key}: ${value}`) : `${m[1]}\n${key}: ${value}`;
145
+ return body.replace(m[1], fm);
146
+ }
147
+
148
+ /**
149
+ * Tick or un-tick the first acceptance-criterion checkbox whose text matches `ac`.
150
+ * @param {string} body - Full task-file text.
151
+ * @param {string} ac - Criterion text to match (case/space-insensitive substring, either direction).
152
+ * @param {boolean} checked - true to write `[x]`, false to write `[ ]`.
153
+ * @returns {{body:string, hit:boolean}} The updated body and whether a checkbox matched (only the
154
+ * first match is changed).
155
+ */
156
+ export function setCheckbox(body, ac, checked) {
157
+ const needle = ac.toLowerCase().replace(/\s+/g, " ").trim();
158
+ const lines = body.split(/\r?\n/);
159
+ let hit = false;
160
+ const out = lines.map((line) => {
161
+ const m = line.match(/^(\s*- \[)([ x])(\]\s+)(.*)$/);
162
+ if (!m || hit) return line;
163
+ const text = m[4].toLowerCase().replace(/\s+/g, " ").trim();
164
+ if (text.includes(needle) || needle.includes(text)) {
165
+ hit = true;
166
+ return `${m[1]}${checked ? "x" : " "}${m[3]}${m[4]}`;
167
+ }
168
+ return line;
169
+ });
170
+ return { body: out.join("\n"), hit };
171
+ }
172
+
173
+ /**
174
+ * Flip a task's row in `tasks/_index.md` to a done state.
175
+ * @param {string} indexBody - Full board-index text.
176
+ * @param {string} taskId - Task id whose row to update.
177
+ * @param {boolean} done - When true, rewrite the row's status emoji/word to done; false is a no-op.
178
+ * @returns {string} The board text with the matching row updated (unchanged when no row matches).
179
+ */
180
+ export function updateBoardRow(indexBody, taskId, done) {
181
+ return indexBody.split(/\r?\n/).map((line) => {
182
+ if (!line.includes(taskId) || !line.includes("|")) return line;
183
+ if (done) return line.replace(/⬜|🔄|⏳|🚫/g, "✅").replace(/\b(ready|in-progress|blocked)\b/gi, "done");
184
+ return line;
185
+ }).join("\n");
186
+ }
187
+
188
+ /**
189
+ * Apply one validated WorkResult to the working tree — the single-writer step (D6): ticks AC
190
+ * boxes, flips task status, appends the Execution Log, propagates unblocks, appends discoveries,
191
+ * writes the verdict ledger + un-ticks refuted boxes.
192
+ * verdict{criteria[],refuted[]}).
193
+ * @param {{cwd:string}} opts - cwd: working-directory root every LOCAL path resolves against.
194
+ * @returns {{slug:string, tasks_updated:string[], acs_ticked:number, unblocked:string[],
195
+ * discoveries_appended:number, refuted_unticked:number, verdict_lines:number}} A summary of every write performed.
196
+ * @throws {Error} If a task/board/ledger file it must write is not writable (fs error propagates).
197
+ * `evaluation/.verdicts-*.jsonl` under `.shapeup/<slug>/`.
198
+ */
199
+ export function applyResult(result, { cwd }) {
200
+ const slug = result.order_id.split("/")[0];
201
+ return withLock(cwd, slug, () => applyResultLocked(result, { cwd, slug }));
202
+ }
203
+
204
+ /**
205
+ * The reducer proper. Runs inside the run's ingest lock — see {@link withLock}.
206
+ *
207
+ * @param {object} result - A schema-valid WorkResult.
208
+ * @param {object} ctx - `{cwd, slug}`.
209
+ * @returns {object} The summary {@link applyResult} returns.
210
+ */
211
+ function applyResultLocked(result, { cwd, slug }) {
212
+ const local = localRoot(cwd, slug);
213
+ const summary = { slug, tasks_updated: [], acs_ticked: 0, unblocked: [], discoveries_appended: 0, refuted_unticked: 0, verdict_lines: 0 };
214
+
215
+ // 1. Task results → task files + board (old task-executor P3.1/P3.2/P3.6).
216
+ const boardIndex = join(local, "tasks", "_index.md");
217
+ for (const tr of result.task_results || []) {
218
+ const path = findTaskFile(cwd, slug, tr.task_id);
219
+ if (!path) continue;
220
+ let body = readFileSync(path, "utf8");
221
+ for (const acr of tr.ac_results || []) {
222
+ if (acr.result === "pass") {
223
+ const r = setCheckbox(body, acr.ac, true);
224
+ body = r.body;
225
+ if (r.hit) summary.acs_ticked++;
226
+ }
227
+ }
228
+ if (tr.status === "done") {
229
+ body = setFrontmatter(body, "status", "done");
230
+ body = setFrontmatter(body, "completed_at", today());
231
+ } else if (tr.status === "partial" || tr.status === "failed") {
232
+ body = setFrontmatter(body, "status", "in-progress");
233
+ }
234
+ // Execution Log (append; the checkbox list must never disagree with it).
235
+ const logLines = (tr.ac_results || []).map((a) => `- ${a.ac}: ${a.result}${a.evidence ? ` (${a.evidence})` : ""}`).join("\n");
236
+ body += `\n\n## Execution Log — ${today()} (${result.order_id})\n- executor: ${result.worker || "task-executor"} via ingest-result\n- status: ${tr.status}\n${logLines}${tr.notes ? `\n- notes: ${tr.notes}` : ""}\n`;
237
+ writeFileSync(path, body);
238
+ summary.tasks_updated.push(tr.task_id);
239
+ if (tr.status === "done" && existsSync(boardIndex)) {
240
+ writeFileSync(boardIndex, updateBoardRow(readFileSync(boardIndex, "utf8"), tr.task_id, true));
241
+ }
242
+ }
243
+
244
+ // 2. Unblock propagation (old P3.4): any blocked task whose dependencies are all done → ready.
245
+ const tasksDir = join(local, "tasks");
246
+ if (existsSync(tasksDir)) {
247
+ const files = readdirSync(tasksDir).filter((f) => /^TASK-[\w.-]+\.md$/i.test(f));
248
+ const statusOf = {};
249
+ const parsed = files.map((f) => {
250
+ const body = readFileSync(join(tasksDir, f), "utf8");
251
+ const id = (body.match(/^id:\s*(TASK-[\w.-]+)/im) || [])[1] || f.replace(/\.md$/, "");
252
+ const status = (body.match(/^status:\s*(\S+)/im) || [])[1] || "unknown";
253
+ const deps = (body.match(/^depends_on:\s*\[([^\]]*)\]/im) || [, ""])[1]
254
+ .split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
255
+ statusOf[id] = status;
256
+ return { f, id, status, deps, body };
257
+ });
258
+ for (const t of parsed) {
259
+ if (t.status === "blocked" && t.deps.length && t.deps.every((d) => statusOf[d] === "done")) {
260
+ writeFileSync(join(tasksDir, t.f), setFrontmatter(t.body, "status", "ready"));
261
+ summary.unblocked.push(t.id);
262
+ if (existsSync(boardIndex)) {
263
+ const idx = readFileSync(boardIndex, "utf8").split(/\r?\n/).map((line) =>
264
+ line.includes(t.id) && line.includes("|")
265
+ ? line.replace(/🚫|⏳/g, "⬜").replace(/\bblocked\b/gi, "ready")
266
+ : line).join("\n");
267
+ writeFileSync(boardIndex, idx);
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ // 3. Discoveries → the ledger (old P3.7 / QA H.3). Single writer: this script.
274
+ if (result.discoveries?.length) {
275
+ const ledgerDir = join(local, "discovery");
276
+ mkdirSync(ledgerDir, { recursive: true });
277
+ const ledger = join(ledgerDir, "ledger.md");
278
+ if (!existsSync(ledger)) writeFileSync(ledger, `---\nfeature: ${slug}\n---\n# Discovery Ledger — ${slug}\n`);
279
+ const lines = result.discoveries.map((d) => {
280
+ const tags = [d.lens ? `[lens:${d.lens}]` : "", d.severity_hint ? `severity-hint: ${d.severity_hint}` : "", d.test_gap ? `test-gap: ${d.test_gap}` : "", d.contradicts ? `contradicts: ${d.contradicts}` : "", d.traces_to?.length ? `traces_to: ${d.traces_to.join(", ")}` : ""].filter(Boolean);
281
+ return `${d.marker} ${d.lens ? tags[0] + " " : ""}${d.line}${d.repro ? `\n repro: ${d.repro}` : ""}${tags.slice(d.lens ? 1 : 0).map((t) => `\n ${t}`).join("")}`;
282
+ }).join("\n");
283
+ appendFileSync(ledger, `\n## Discovered — ${result.order_id} (${today()})\n${lines}\n`);
284
+ summary.discoveries_appended = result.discoveries.length;
285
+ }
286
+
287
+ // 4. Verdict bookkeeping (old evaluator B.0/B.2/B.2b) — judge returns data, ingest writes.
288
+ if (result.verdict) {
289
+ const evalDir = join(local, "evaluation");
290
+ mkdirSync(evalDir, { recursive: true });
291
+ if (result.verdict.criteria?.length) {
292
+ const target = result.order_id.split("/")[1] || "run";
293
+ const ledger = join(evalDir, `.verdicts-${target}.jsonl`);
294
+ let run = 1;
295
+ if (existsSync(ledger)) {
296
+ const prior = readFileSync(ledger, "utf8").trim().split(/\n/).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
297
+ run = prior.reduce((mx, r) => Math.max(mx, r.run || 0), 0) + 1;
298
+ }
299
+ const lines = result.verdict.criteria.map((c) => JSON.stringify({
300
+ run, dimension: c.dimension || "spec-conformance", criterion: c.criterion,
301
+ verdict: c.verdict, confidence: c.confidence, reprobed: !!c.reprobed,
302
+ evidence: c.evidence || "", at: new Date().toISOString(),
303
+ })).join("\n");
304
+ appendFileSync(ledger, lines + "\n");
305
+ summary.verdict_lines = result.verdict.criteria.length;
306
+ }
307
+ for (const ref of result.verdict.refuted || []) {
308
+ const path = findTaskFile(cwd, slug, ref.task_id);
309
+ if (!path) continue;
310
+ let body = readFileSync(path, "utf8");
311
+ const r = setCheckbox(body, ref.ac, false);
312
+ if (r.hit) summary.refuted_unticked++;
313
+ body = setFrontmatter(r.body, "eval_verdict", "fail");
314
+ body = setFrontmatter(body, "eval_at", today());
315
+ writeFileSync(path, body);
316
+ }
317
+ }
318
+
319
+
320
+
321
+ return summary;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ /** The typed argv contract (see `./lib/argv.mjs`). */
326
+ export const ARGV_SPEC = {
327
+ usage: "harness.mjs reduce ingest (<result.json> | --order <order.json>) [--cwd <dir>] [--no-receipt-check]",
328
+ _: { arity: 0, max: 1, name: "result.json" },
329
+ order: { type: "path" },
330
+ cwd: { type: "path" },
331
+ // The documented way through. The receipt channel is best-effort by design — a hook that could
332
+ // fail a tool call would get the layer disabled — so a gate built on it needs an escape for the
333
+ // environmental case, and one that appears in the run's own output rather than in folklore.
334
+ "no-receipt-check": { type: "flag" },
335
+ };
336
+
337
+ /**
338
+ * The result a given order is answered by.
339
+ *
340
+ * THE PAIRING IS A FACT OF THE ENVELOPE PORT, not a claim: `compile` writes `orders/<suffix>.json`
341
+ * and every worker writes `results/<suffix>.json`. Deriving it here is what stops a dispatch from
342
+ * being aimed by a worker's own report of where it put the work — measured once, when a worker that
343
+ * had done its whole job reported a DIRECTORY as its result path, ingest read it, got EISDIR, and
344
+ * the phase was thrown away. The caller naming the ORDER cannot make that mistake, because the
345
+ * order is the thing it already holds.
346
+ *
347
+ * @param {string} orderPath - Path to the order this result answers.
348
+ * @returns {string} The result path implied by that order.
349
+ */
350
+ export function resultFor(orderPath) {
351
+ const abs = resolve(orderPath);
352
+ const dir = dirname(abs);
353
+ if (basename(dir) !== "orders") {
354
+ console.error(` ✗ --order must name a file inside an orders/ directory (got ${abs})`);
355
+ process.exit(2);
356
+ }
357
+ return join(dirname(dir), "results", basename(abs));
358
+ }
359
+
360
+ /**
361
+ * The order a given result answers — the inverse of {@link resultFor}.
362
+ *
363
+ * Used only when a result was passed positionally, so an ingest aimed the documented old way is
364
+ * still held to the same attestation as one aimed by `--order`.
365
+ *
366
+ * @param {string} resultPath - Path to the result.
367
+ * @returns {string|null} The mirrored order path, or null when the result does not live in a
368
+ * `results/` directory (a fixture tree, typically — which is exactly when there is nothing to
369
+ * attest).
370
+ */
371
+ export function orderFor(resultPath) {
372
+ const abs = resolve(resultPath);
373
+ const dir = dirname(abs);
374
+ if (basename(dir) !== "results") return null;
375
+ return join(dirname(dir), "orders", basename(abs));
376
+ }
377
+
378
+ /**
379
+ * Did the SHIPPED skill actually run for this order?
380
+ *
381
+ * WHY AN INGEST ASKS THIS AT ALL. A dispatch that fails — plugin absent, disabled, or the wrong
382
+ * version — returns `<tool_use_error>Unknown skill</tool_use_error>`, and the sub-agent then does
383
+ * the craft itself from the prose in its own prompt. The artifacts land in exactly the right place,
384
+ * so the order gate and the sandbox guard both pass, the phase post-condition passes, and the run
385
+ * advances having applied none of the shipped craft. The receipt (`hooks/dispatch-receipt.mjs`) is
386
+ * the only fact that separates the two, and this is the only place that fact is load-bearing.
387
+ *
388
+ * ALL THREE CONDITIONS, NOT MERELY EXISTENCE. A receipt that only has to EXIST is satisfied by a
389
+ * stale one from an earlier relaunch — order paths like `orders/orient.json` are stable across
390
+ * relaunches and the re-dispatch path reuses them verbatim — and a receipt from the wrong skill is
391
+ * satisfied by any dispatch at all. So: same `order_id`, `skill_invoked` equal to the worker the
392
+ * order DECLARES, and not older than the order it claims to answer.
393
+ *
394
+ * `compiled_at` is optional in the schema, so the staleness bound is applied only when the order
395
+ * carries one; an order without it is checked on the first two conditions rather than waved through
396
+ * on a timestamp nobody wrote.
397
+ *
398
+ * @param {object} order - The parsed WorkOrder.
399
+ * @param {string} cwd - Project root.
400
+ * @returns {{ok:boolean, reason?:string, receipt?:object, rows:number}} `ok` with the matching
401
+ * receipt, or the reason no row qualified plus how many rows were considered.
402
+ */
403
+ export function attestation(order, cwd) {
404
+ const slug = String(order.order_id).split("/")[0];
405
+ const path = dispatchReceipts(cwd, slug);
406
+ let rows = [];
407
+ try {
408
+ rows = readFileSync(path, "utf8").split("\n").filter(Boolean)
409
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
410
+ } catch { return { ok: false, reason: `no dispatch receipts at ${path}`, rows: 0 }; }
411
+
412
+ const mine = rows.filter((r) => r.order_id === order.order_id);
413
+ if (!mine.length) return { ok: false, reason: `no receipt for ${order.order_id}`, rows: rows.length };
414
+
415
+ const rightSkill = mine.filter((r) => r.skill_invoked === order.worker);
416
+ if (!rightSkill.length) {
417
+ const saw = [...new Set(mine.map((r) => r.skill_invoked))].join(", ");
418
+ return { ok: false, rows: mine.length,
419
+ reason: `${order.order_id} declares worker "${order.worker}" but every receipt names [${saw}] — the dispatch ran a different skill` };
420
+ }
421
+ const floor = order.compiled_at ? Date.parse(order.compiled_at) : null;
422
+ const fresh = floor === null || Number.isNaN(floor)
423
+ ? rightSkill
424
+ : rightSkill.filter((r) => Date.parse(r.at) >= floor);
425
+ if (!fresh.length) {
426
+ return { ok: false, rows: rightSkill.length,
427
+ reason: `every receipt for ${order.order_id} predates the order (compiled ${order.compiled_at}) — a stale attestation from an earlier dispatch` };
428
+ }
429
+ return { ok: true, receipt: fresh[fresh.length - 1], rows: fresh.length };
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Leg completion — the record the run had no way to write.
434
+ // ---------------------------------------------------------------------------
435
+ //
436
+ // A leg has a real start (the dispatch receipt, hook-attested and append-only) and, until now, no
437
+ // end. Without one, "how many scopes ran at once" and "how long did BUILD take" are guesses: the
438
+ // only other candidates are an order file that is overwritten on relaunch and result-file mtimes,
439
+ // which are whatever the last copy did. Ingest is where the end belongs — it is the single writer
440
+ // of shared state and it is the act that closes a leg (compile → dispatch → ingest).
441
+
442
+ /** `<slug>/<scope>-r<N>-a<M>` and `<slug>/evaluate-r<N>` — the round-addressed order id forms. */
443
+ const ROUND_SUFFIX = /^(.*?)-r(\d+)(?:-a(\d+))?$/;
444
+
445
+ /**
446
+ * Split an order id into the scope/phase name and the round-attempt address it encodes.
447
+ *
448
+ * The id is the authority here rather than the payload: `compile` builds it from scope, round and
449
+ * attempt, and it is the field every other record joins on.
450
+ *
451
+ * @param {string} orderId - e.g. `todo-cli/foundation-r2-a1`.
452
+ * @returns {{name:(string|null), round:(number|null), attempt:(number|null)}} Nulls for a phase
453
+ * order that carries no round (`orient`, `wire`, `hammer`).
454
+ */
455
+ export function addressOf(orderId) {
456
+ const suffix = String(orderId ?? "").split("/").slice(1).join("/");
457
+ if (!suffix) return { name: null, round: null, attempt: null };
458
+ const m = suffix.match(ROUND_SUFFIX);
459
+ if (!m) return { name: suffix, round: null, attempt: null };
460
+ return { name: m[1], round: Number(m[2]), attempt: m[3] === undefined ? null : Number(m[3]) };
461
+ }
462
+
463
+ /**
464
+ * Build the leg-completion row for one closed leg.
465
+ *
466
+ * EVERY TIME FIELD IS A SNAPSHOT. `compiled_at` is copied off the order as it reads NOW, because
467
+ * the file is re-written verbatim on relaunch; a row that pointed at the file instead would re-date
468
+ * itself to a later dispatch. `dispatched_at` comes from the receipt that ATTESTED this dispatch —
469
+ * the one that already passed the same-order, same-skill and not-stale tests — so it cannot be an
470
+ * earlier failed dispatch's row.
471
+ *
472
+ * THE RUN KEY IS RESOLVED, NOT COPIED. `order_id` alone collides across runs, and a row that
473
+ * carried only it would be unjoinable exactly when two runs of one slug exist. When the order
474
+ * carries no `run_id` the key is read from the run's own receipt through `lib/paths.mjs`, which is
475
+ * the same derivation every other writer uses.
476
+ *
477
+ * Exported so the suite can assert the row's shape without a live dispatch.
478
+ *
479
+ * @param {object} order - The parsed WorkOrder this leg answered.
480
+ * @param {(object|null)} receipt - The attesting dispatch receipt, when one matched.
481
+ * @param {string} cwd - Project root, for the run-key fallback.
482
+ * @param {string} [ingestedAt] - The completion instant; defaults to now.
483
+ * @returns {object} The row appended to `legs.jsonl`.
484
+ */
485
+ export function legRow(order, receipt, cwd, ingestedAt = new Date().toISOString()) {
486
+ const slug = String(order.order_id ?? "").split("/")[0];
487
+ const { name, round, attempt } = addressOf(order.order_id);
488
+ const dispatchedAt = receipt?.at ?? null;
489
+ const startedAt = dispatchedAt ?? order.compiled_at ?? null;
490
+ const startMs = startedAt ? Date.parse(startedAt) : NaN;
491
+ const endMs = Date.parse(ingestedAt);
492
+ return {
493
+ schema_version: 1,
494
+ // Resolved rather than defaulted to null: an unkeyed row is a row no later run can be
495
+ // distinguished from, and the receipt on disk already answers the question.
496
+ run_id: order.run_id ?? (slug ? readRunId(cwd, slug) : null),
497
+ order_id: order.order_id ?? null,
498
+ worker: order.worker ?? null,
499
+ operation: order.operation ?? null,
500
+ mode: order.mode ?? null,
501
+ scope_id: name,
502
+ round,
503
+ attempt,
504
+ compiled_at: order.compiled_at ?? null,
505
+ dispatched_at: dispatchedAt,
506
+ ingested_at: ingestedAt,
507
+ // Which fact the duration is measured FROM travels with the duration, because the two starts
508
+ // are not the same measurement: a receipt is evidence the skill resolved, `compiled_at` is only
509
+ // evidence an order was written.
510
+ started_from: dispatchedAt ? "dispatch-receipt" : (order.compiled_at ? "compiled_at" : null),
511
+ duration_ms: Number.isFinite(startMs) && Number.isFinite(endMs) ? endMs - startMs : null,
512
+ attested: !!receipt,
513
+ };
514
+ }
515
+
516
+ /**
517
+ * Append one leg-completion row. Never throws.
518
+ *
519
+ * FAIL-OPEN, like the receipt hook it complements: a timing record that can fail an ingest would
520
+ * cost the run a board write to save a measurement, which is the wrong trade. A row that does not
521
+ * reach disk surfaces later as a leg with no completion record — visible, and not mistaken for a
522
+ * leg that ran instantly.
523
+ *
524
+ * @param {object} row - A {@link legRow}.
525
+ * @param {string} cwd - Project root.
526
+ * @returns {(string|null)} The ledger path when the row landed, else null.
527
+ */
528
+ export function appendLeg(row, cwd) {
529
+ try {
530
+ const slug = String(row.order_id).split("/")[0];
531
+ if (!slug) return null;
532
+ const path = legLedger(cwd, slug);
533
+ mkdirSync(dirname(path), { recursive: true });
534
+ appendFileSync(path, JSON.stringify(row) + "\n");
535
+ return path;
536
+ } catch { return null; }
537
+ }
538
+
539
+ /**
540
+ * Apply one WorkResult to shared state — the single writer for the board and the ledgers.
541
+ *
542
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
543
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
544
+ * call `process.exit()` with the subcommand's documented code rather than returning.
545
+ */
546
+ export async function cli(rawArgv) {
547
+ const args = runArgs(ARGV_SPEC, rawArgv);
548
+ const file = args.order ? resultFor(args.order) : args._[0];
549
+ if (!file) {
550
+ console.error(" ✗ nothing to ingest: pass a result path, or --order <order.json> to derive it");
551
+ process.exit(2);
552
+ }
553
+ const cwd = resolve(args.cwd || process.cwd());
554
+
555
+ let result;
556
+ try { result = JSON.parse(readFileSync(resolve(file), "utf8")); }
557
+ catch (e) { console.error(` ✗ result unreadable: ${e.message}`); process.exit(1); }
558
+
559
+ const { valid, errors } = validate(result, RESULT_SCHEMA);
560
+ if (!valid) {
561
+ console.error("ingest-result: result rejected — a malformed result never mutates the board:");
562
+ for (const e of errors) console.error(` ✗ ${e}`);
563
+ process.exit(1);
564
+ }
565
+
566
+ // --- attestation gate -----------------------------------------------------------------------
567
+ // SCOPED TO ORCHESTRATED ORDERS, deliberately. A result with no order beside it is a standalone
568
+ // or fixture ingest — there was no dispatch to attest, so demanding a receipt would refuse work
569
+ // that never made the claim. What it must NOT do is let the orchestrated case opt out by omitting
570
+ // `--order`, which is why a positionally-named result is mirrored back to its order first.
571
+ const orderPath = args.order ? resolve(args.order) : orderFor(file);
572
+ let order = null;
573
+ if (orderPath) { try { order = JSON.parse(readFileSync(orderPath, "utf8")); } catch { order = null; } }
574
+
575
+ // --- identity gate --------------------------------------------------------------------------
576
+ // A RESULT MUST CLAIM THE ORDER IT IS ANSWERING. `order_id` is the only join in the record set:
577
+ // a WorkResult deliberately carries no `run_id` and reaches it through this field, so a result
578
+ // that echoes the wrong id is not mislabelled, it is DETACHED — from its run, its round, and the
579
+ // order whose receipt attests it.
580
+ //
581
+ // Measured on a two-round run: round 1's evaluate order was `todo-cli/evaluate-r1` and its result
582
+ // came back claiming `todo-cli/evaluate`, an order that has never existed. Everything downstream
583
+ // still looked right, because the FILE is named after the order — only the run graph disagreed,
584
+ // and only because it keys nodes off the envelope rather than the filename. Round 2's ids matched,
585
+ // so a single-round run cannot show this at all.
586
+ //
587
+ // The ORDER is authoritative: it is the compiled artifact, and the result is a reply to it. But
588
+ // this refuses rather than rewriting, because silently normalising a worker's output is how the
589
+ // drift becomes permanent and invisible — the same reason ingest refuses a malformed envelope
590
+ // instead of repairing one.
591
+ if (order && String(result.order_id) !== String(order.order_id)) {
592
+ console.error(`ingest-result: result refused — it answers order "${order.order_id}" but claims`);
593
+ console.error(` order_id "${result.order_id}". A result reaches its run through this field and`);
594
+ console.error(` nothing else, so an id that names a different order (or none) detaches the record`);
595
+ console.error(` from the run that produced it. Correct the result's order_id to match its order.`);
596
+ process.exit(1);
597
+ }
598
+
599
+ // Resolved for EVERY order, not only the gated ones: the attesting receipt is this leg's start,
600
+ // and a standalone or `--no-receipt-check` ingest still deserves a truthful timing row rather
601
+ // than one silently falling back to the order's re-writable `compiled_at`.
602
+ const att = order ? attestation(order, cwd) : { ok: false, receipt: null };
603
+
604
+ if (order?.mode === "orchestrated" && !args.noReceiptCheck) {
605
+ if (!att.ok) {
606
+ console.error(`ingest-result: result refused — ${att.reason}.`);
607
+ console.error(` The order declares mode "orchestrated", so the dispatch must have left a receipt`);
608
+ console.error(` (${dispatchReceipts(cwd, String(order.order_id).split("/")[0])}). No receipt means the Skill`);
609
+ console.error(` dispatch never ran: the plugin may be absent, disabled, or a different version, and the`);
610
+ console.error(` artifacts on disk were produced by something other than the shipped skill.`);
611
+ console.error(` Re-dispatch the worker, or pass --no-receipt-check if the receipt failed for an`);
612
+ console.error(` environmental reason and you are accepting the result without that attestation.`);
613
+ process.exit(1);
614
+ }
615
+ console.log(` ✓ attested: ${att.receipt.order_id} ran ${att.receipt.skill_invoked} at ${att.receipt.at}`);
616
+ }
617
+
618
+ const s = applyResult(result, { cwd });
619
+
620
+ // The leg closes HERE, after the writes land — a row written before them would date a leg by an
621
+ // ingest that could still fail. Only when an order is on disk: a fixture ingest answers no
622
+ // dispatch, so it has no leg to close and inventing one would put a phantom interval in the
623
+ // record set every later measurement reads.
624
+ if (order) {
625
+ const row = legRow(order, att.ok ? att.receipt : null, cwd);
626
+ if (!appendLeg(row, cwd)) {
627
+ console.error(` ! leg-completion row could not be written for ${row.order_id} — this leg will ` +
628
+ `read as having no completion record (harness probe concurrency reports it as such).`);
629
+ }
630
+ }
631
+
632
+ console.log(`✅ ingested ${result.order_id} — tasks: [${s.tasks_updated.join(", ")}] · ACs ticked: ${s.acs_ticked} · unblocked: [${s.unblocked.join(", ")}] · discoveries: ${s.discoveries_appended} · verdict lines: ${s.verdict_lines} · refuted un-ticked: ${s.refuted_unticked}`);
633
+ }