shapeup-sdlc 3.18.3 → 4.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 (141) hide show
  1. package/.claude/settings.local.example.json +2 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +115 -56
  4. package/README.md +101 -143
  5. package/SECURITY.md +16 -25
  6. package/agents/orient.md +14 -0
  7. package/agents/qa-edge-hunter.md +13 -0
  8. package/agents/spec-evaluator.md +14 -0
  9. package/agents/task-executor.md +14 -0
  10. package/bin/init.mjs +13 -16
  11. package/bin/lib/grant.mjs +8 -19
  12. package/commands/build.md +9 -14
  13. package/commands/eval.md +3 -3
  14. package/commands/hammer.md +9 -5
  15. package/commands/retro.md +12 -11
  16. package/commands/scopes.md +1 -12
  17. package/commands/ship.md +45 -88
  18. package/commands/wire.md +1 -8
  19. package/hooks/dispatch-receipt.mjs +94 -8
  20. package/hooks/hooks.json +0 -41
  21. package/hooks/lib/decision.mjs +6 -9
  22. package/hooks/lib/tier.mjs +50 -0
  23. package/hooks/sandbox-guard.mjs +140 -64
  24. package/kernel/gate.mjs +203 -22
  25. package/kernel/harness.mjs +20 -67
  26. package/kernel/init/run.mjs +175 -523
  27. package/kernel/lib/contract.mjs +9 -93
  28. package/kernel/lib/floor.mjs +64 -0
  29. package/kernel/lib/paths.mjs +39 -112
  30. package/kernel/order.mjs +157 -0
  31. package/kernel/probe/digest.mjs +2 -38
  32. package/kernel/schemas/domain.schema.json +146 -1125
  33. package/kernel/schemas/work-order.schema.json +8 -4
  34. package/kernel/schemas/work-result.schema.json +1 -1
  35. package/kernel/verify/envelope.mjs +2 -2
  36. package/kernel/verify/skills.mjs +27 -8
  37. package/kernel/verify/t0.mjs +2 -2
  38. package/package.json +4 -2
  39. package/skills/building/SKILL.md +144 -0
  40. package/skills/building/resources/gates.md +76 -0
  41. package/skills/building/resources/guidance.md +23 -0
  42. package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
  43. package/skills/building/resources/intake.md +157 -0
  44. package/skills/building/resources/orders.md +69 -0
  45. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
  46. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
  47. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
  48. package/skills/building/resources/plan-scopes.md +95 -0
  49. package/skills/building/resources/plan-spec.md +102 -0
  50. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
  51. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
  52. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
  53. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
  54. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
  55. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
  56. package/skills/building/resources/plan-wire.md +82 -0
  57. package/skills/building/resources/plan.md +98 -0
  58. package/skills/building/resources/retro-research.md +60 -0
  59. package/skills/building/resources/retro-scan.md +49 -0
  60. package/skills/building/resources/retro.md +225 -0
  61. package/skills/building/resources/rounds.md +78 -0
  62. package/skills/building/resources/stop.md +127 -0
  63. package/skills/orient/SKILL.md +37 -37
  64. package/skills/qa-edge-hunter/SKILL.md +14 -14
  65. package/skills/shapeup/SKILL.md +5 -5
  66. package/skills/shapeup/resources/breadboarding.md +1 -1
  67. package/skills/shapeup/resources/context-compaction.md +5 -5
  68. package/skills/shapeup/resources/shaping.md +1 -1
  69. package/skills/spec-evaluator/SKILL.md +17 -21
  70. package/skills/spec-evaluator/references/dimension-contract.md +1 -1
  71. package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
  72. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
  73. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
  74. package/skills/task-executor/SKILL.md +11 -15
  75. package/commands/hill.md +0 -10
  76. package/hooks/gate-intake.mjs +0 -109
  77. package/hooks/gate-zerowork.mjs +0 -349
  78. package/hooks/tier-guard.mjs +0 -162
  79. package/kernel/compile.mjs +0 -1419
  80. package/kernel/init/fit.mjs +0 -199
  81. package/kernel/init/run-args.mjs +0 -206
  82. package/kernel/lib/breadboard.mjs +0 -165
  83. package/kernel/probe/attempts.mjs +0 -161
  84. package/kernel/probe/concurrency.mjs +0 -535
  85. package/kernel/probe/eval.mjs +0 -405
  86. package/kernel/probe/hunt.mjs +0 -104
  87. package/kernel/probe/leg.mjs +0 -229
  88. package/kernel/probe/owner.mjs +0 -142
  89. package/kernel/probe/requirements.mjs +0 -328
  90. package/kernel/probe/resume.mjs +0 -1110
  91. package/kernel/probe/rounds.mjs +0 -115
  92. package/kernel/probe/staged.mjs +0 -83
  93. package/kernel/probe/stats.mjs +0 -477
  94. package/kernel/probe/t0.mjs +0 -115
  95. package/kernel/reduce/board.mjs +0 -350
  96. package/kernel/reduce/graph.mjs +0 -482
  97. package/kernel/reduce/hill.mjs +0 -322
  98. package/kernel/reduce/ingest.mjs +0 -767
  99. package/kernel/reduce/leftovers.mjs +0 -159
  100. package/kernel/reduce/ship.mjs +0 -529
  101. package/kernel/reduce/snapshot.mjs +0 -291
  102. package/kernel/reduce/verdict.mjs +0 -172
  103. package/kernel/report/export.mjs +0 -384
  104. package/kernel/report/facts.mjs +0 -234
  105. package/kernel/verify/budget.mjs +0 -153
  106. package/kernel/verify/build.mjs +0 -428
  107. package/kernel/verify/dispatch.mjs +0 -114
  108. package/kernel/verify/spec.mjs +0 -975
  109. package/kernel/verify/trace.mjs +0 -604
  110. package/skills/ba-pitch-analyzer/SKILL.md +0 -204
  111. package/skills/coach/SKILL.md +0 -397
  112. package/skills/hill-chart/SKILL.md +0 -154
  113. package/skills/hill-chart/assets/dashboard.template.html +0 -1432
  114. package/skills/scope-architect/SKILL.md +0 -174
  115. package/skills/scope-hammer/SKILL.md +0 -231
  116. package/skills/solution-architect/SKILL.md +0 -158
  117. package/skills/tech-lead/SKILL.md +0 -151
  118. package/skills/tech-lead/references/gates.md +0 -605
  119. package/skills/tech-lead/references/protocol.md +0 -889
  120. package/skills/tech-lead/references/tiny-lane.md +0 -53
  121. package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
  122. package/skills/translator/SKILL.md +0 -258
  123. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
  124. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
  125. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
  126. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
  127. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
  128. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
  129. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
  130. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
  131. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
  132. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
  133. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
  134. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
  135. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
  136. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
  137. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
  138. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
  139. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
  140. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
  141. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
@@ -1,767 +0,0 @@
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
- // deviations[] (when → append to the SAME discovery ledger, tagged distinctly. A worker's
12
- // status:"escalated") protocol names deviations[] as the only channel a blocked worker has —
13
- // there is no escalates[] field — and until now nothing routed it anywhere:
14
- // a worker that correctly stopped rather than guessed produced a number and
15
- // silence. Landing here reaches a reader that already exists — the census
16
- // reads this ledger's open entries, and the ship report's own "Discovered,
17
- // not built" section is generated from it.
18
- // verdict.criteria[] → append evaluation/.verdicts-<target>.jsonl (old evaluator B.0), every
19
- // row keyed by run_id and carrying the judge's traces_to[] anchor back to
20
- // the requirement — see step 4 for why neither may be dropped here
21
- // verdict.refuted[] → un-tick refuted AC boxes + set eval_verdict frontmatter (old B.2/B.2b)
22
- // (the leg itself) → append one leg-completion row to .shapeup/<slug>/legs.jsonl
23
- //
24
- // That last one is the LEG'S END, and it is here because this is the step that closes a leg. The
25
- // dispatch receipt gives a start; nothing gave an end, so how long a leg took and how many ran at
26
- // once were unanswerable from the record set. See legRow() for why every field is a snapshot.
27
- //
28
- // Zero dependencies, zero network, schema-validated input (a malformed result never mutates
29
- // the board). Single-writer becomes mechanically true, not aspirational.
30
- //
31
- // Usage: node kernel/harness.mjs reduce ingest <result.json> [--cwd <dir>]
32
- // Exit: 0 = ingested, 1 = result rejected (schema) or a write failed.
33
-
34
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, rmSync, statSync, existsSync, readdirSync } from "node:fs";
35
- import { resolve, join, dirname, basename } from "node:path";
36
- import { fileURLToPath } from "node:url";
37
- import { validate } from "../verify/envelope.mjs";
38
- import { runArgs } from "../lib/argv.mjs";
39
- import { tasksDir, localRoot, dispatchReceipts, legLedger, readRunId, qaDir } from "../lib/paths.mjs";
40
- import { citationProblem, coverageProblem, dimensionsProblem, tracesProblem, verdictProblem } from "../probe/eval.mjs";
41
- import { huntProblem } from "../probe/hunt.mjs";
42
-
43
- const HERE = dirname(fileURLToPath(import.meta.url));
44
- const RESULT_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "../schemas/work-result.schema.json"), "utf8"));
45
-
46
- /**
47
- * @returns {string} Today's date as an ISO `YYYY-MM-DD` string (UTC), for log/frontmatter stamps.
48
- */
49
- const today = () => new Date().toISOString().slice(0, 10);
50
-
51
- /**
52
- * Hold an exclusive lock on one run's shared state for the duration of `fn`.
53
- *
54
- * WHY THIS EXISTS (measured, not theorized). Since scopes fan out, several legs call this reducer
55
- * AT THE SAME TIME, each with its own result. Most of what it writes is per-task and cannot
56
- * collide — but `tasks/_index.md`, the discovery ledger and the verdict record are one file each,
57
- * updated read-modify-write. Three concurrent ingests over three different tasks left all three
58
- * task files `done` and the BOARD showing two: the middle write read the index before the first
59
- * had written it and then overwrote it. A board that disagrees with its own task files is the
60
- * exact "parallel work corrupts shared state" failure the single-writer rule exists to prevent,
61
- * and the rule was true of the CODE and false of the PROCESS the moment there were two of them.
62
- *
63
- * `mkdir` is the primitive: it is atomic on POSIX and Windows alike, needs no dependency, and
64
- * leaves a directory a human can delete. A stale lock older than the timeout is broken rather than
65
- * waited on forever — a crashed reducer must not wedge every later one.
66
- *
67
- * @param {string} cwd - Project root.
68
- * @param {string} slug - Feature slug; the lock is per run, never global.
69
- * @param {Function} fn - The critical section.
70
- * @returns {*} Whatever `fn` returns.
71
- */
72
- function withLock(cwd, slug, fn) {
73
- const lock = join(localRoot(cwd, slug), ".ingest.lock");
74
- const owner = join(lock, "owner.json");
75
- const STALE_MS = 30_000, WAIT_MS = 20;
76
- const startedAt = Date.now();
77
- mkdirSync(dirname(lock), { recursive: true });
78
-
79
- /**
80
- * Is the process that took this lock still running?
81
- *
82
- * AGE ALONE CANNOT ANSWER IT, and that was the hole. `mkdir` stamps the lock's mtime once and the
83
- * critical section is synchronous, so a holder cannot refresh it while it works — which makes "has
84
- * been working for 31 seconds" and "died 31 seconds ago" the same observation. A waiter then broke
85
- * a live holder's lock and entered the section beside it, so the reducer that exists to serialise
86
- * shared-state writes had two writers inside it. Reproduced directly: a 45-second-old lock over a
87
- * working holder was broken and the second ingest wrote.
88
- *
89
- * `kill(pid, 0)` sends no signal and only asks whether the process exists. A lock with no readable
90
- * owner is treated as ABANDONED once it is also stale — that is the pre-existing behaviour, kept
91
- * for locks written before this file recorded an owner.
92
- *
93
- * @returns {boolean} True when a live process holds the lock.
94
- */
95
- const heldByLiveProcess = () => {
96
- try {
97
- const o = JSON.parse(readFileSync(owner, "utf8"));
98
- if (typeof o?.pid !== "number") return false;
99
- process.kill(o.pid, 0); // throws ESRCH when the process is gone
100
- return true;
101
- } catch { return false; }
102
- };
103
-
104
- for (;;) {
105
- try {
106
- mkdirSync(lock);
107
- // Best-effort: the lock is already held by us, so failing to name the owner costs a later
108
- // waiter its liveness check, never correctness of this section.
109
- try { writeFileSync(owner, JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); } catch { /* ignore */ }
110
- break;
111
- } catch { /* held — wait, or break an abandoned one */ }
112
- let heldFor = 0;
113
- try { heldFor = Date.now() - statSync(lock).mtimeMs; } catch { continue; } // released mid-check
114
- // Break it only when it is BOTH stale AND ownerless-or-dead. A slow holder keeps its lock.
115
- if (heldFor > STALE_MS && !heldByLiveProcess()) {
116
- try { rmSync(lock, { recursive: true, force: true }); } catch { /* someone else broke it */ }
117
- continue;
118
- }
119
- if (Date.now() - startedAt > STALE_MS) {
120
- // Refusing is the safe direction: proceeding unlocked is how the lost update happened.
121
- throw new Error(`ingest could not take the ${slug} lock within ${STALE_MS} ms (${lock}) — another reducer is holding it`);
122
- }
123
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, WAIT_MS); // sleep, no busy spin
124
- }
125
- try { return fn(); } finally { try { rmSync(lock, { recursive: true, force: true }); } catch { /* already gone */ } }
126
- }
127
-
128
- /**
129
- * Locate a task file on the LOCAL board by id.
130
- * @param {string} cwd - Working-directory root.
131
- * @param {string} slug - Feature slug.
132
- * @param {string} taskId - Task id prefix to match (e.g. "TASK-001").
133
- * @returns {string|null} Absolute path of the first `<taskId>*.md` file, or null when the tasks
134
- * directory or a matching file does not exist.
135
- */
136
- export function findTaskFile(cwd, slug, taskId) {
137
- const dir = tasksDir(cwd, slug);
138
- if (!existsSync(dir)) return null;
139
- const f = readdirSync(dir).find((n) => n.startsWith(taskId) && n.endsWith(".md"));
140
- return f ? join(dir, f) : null;
141
- }
142
-
143
- /**
144
- * Set a scalar frontmatter field in a task-file body, adding it when absent.
145
- * @param {string} body - Full task-file text.
146
- * @param {string} key - Frontmatter key to set.
147
- * @param {(string|number)} value - Value to write (stringified inline).
148
- * @returns {string} The body with `key: value` set; returned unchanged when the body has no
149
- * frontmatter block.
150
- */
151
- export function setFrontmatter(body, key, value) {
152
- const m = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
153
- if (!m) return body;
154
- const re = new RegExp(`^${key}:.*$`, "m");
155
- const fm = re.test(m[1]) ? m[1].replace(re, `${key}: ${value}`) : `${m[1]}\n${key}: ${value}`;
156
- return body.replace(m[1], fm);
157
- }
158
-
159
- /**
160
- * Tick or un-tick the first acceptance-criterion checkbox whose text matches `ac`.
161
- * @param {string} body - Full task-file text.
162
- * @param {string} ac - Criterion text to match (case/space-insensitive substring, either direction).
163
- * @param {boolean} checked - true to write `[x]`, false to write `[ ]`.
164
- * @returns {{body:string, hit:boolean}} The updated body and whether a checkbox matched (only the
165
- * first match is changed).
166
- */
167
- export function setCheckbox(body, ac, checked) {
168
- const needle = ac.toLowerCase().replace(/\s+/g, " ").trim();
169
- const lines = body.split(/\r?\n/);
170
- let hit = false;
171
- const out = lines.map((line) => {
172
- const m = line.match(/^(\s*- \[)([ x])(\]\s+)(.*)$/);
173
- if (!m || hit) return line;
174
- const text = m[4].toLowerCase().replace(/\s+/g, " ").trim();
175
- if (text.includes(needle) || needle.includes(text)) {
176
- hit = true;
177
- return `${m[1]}${checked ? "x" : " "}${m[3]}${m[4]}`;
178
- }
179
- return line;
180
- });
181
- return { body: out.join("\n"), hit };
182
- }
183
-
184
- /**
185
- * Flip a task's row in `tasks/_index.md` to a done state.
186
- * @param {string} indexBody - Full board-index text.
187
- * @param {string} taskId - Task id whose row to update.
188
- * @param {boolean} done - When true, rewrite the row's status emoji/word to done; false is a no-op.
189
- * @returns {string} The board text with the matching row updated (unchanged when no row matches).
190
- */
191
- /**
192
- * Is this index line the row FOR `taskId` — its first cell — rather than a row that merely
193
- * mentions it? A task id appears in other rows' `Depends On` column, and matching by substring
194
- * anywhere in the line flipped every dependent of a finished task to done along with it. Measured
195
- * on a live run: the executor reported one task `skipped`, its task file still said `ready`, and
196
- * the index showed it ✅ because the task it depended on had just been ticked. The census read the
197
- * index.
198
- * @param {string} line - One line of `tasks/_index.md`.
199
- * @param {string} taskId - `TASK-NNN`.
200
- * @returns {boolean}
201
- */
202
- export function rowIs(line, taskId) {
203
- const id = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
204
- return new RegExp(`^\\|\\s*(?:\\[\\[)?${id}(?:\\\\\\|${id}\\]\\])?\\s*\\|`).test(line);
205
- }
206
-
207
- /**
208
- * The status cell for one TaskResult status, or null when the index should not change.
209
- * @param {(string|boolean)} status - A TaskResult status (`true` is accepted as `done` for callers written before skipped existed).
210
- * @returns {({icon:string, word:string}|null)} The icon and word the index row should carry.
211
- */
212
- export function boardCell(status) {
213
- if (status === true || status === "done") return { icon: "✅", word: "done" };
214
- if (status === "skipped") return { icon: "⏭", word: "skipped" };
215
- if (status === "partial" || status === "failed") return { icon: "🔄", word: "in-progress" };
216
- return null;
217
- }
218
-
219
- /**
220
- * Rewrite one task's status cell in `tasks/_index.md` — the row whose id cell is `taskId`, never a
221
- * row that merely mentions it in `Depends On`.
222
- * @param {string} indexBody - The index file's text.
223
- * @param {string} taskId - `TASK-NNN`.
224
- * @param {(string|boolean)} status - A TaskResult status; statuses the index does not render leave it unchanged.
225
- * @returns {string} The rewritten index text.
226
- */
227
- export function updateBoardRow(indexBody, taskId, status) {
228
- const cell = boardCell(status);
229
- if (!cell) return indexBody;
230
- return indexBody.split(/\r?\n/).map((line) => {
231
- if (!rowIs(line, taskId)) return line;
232
- return line.replace(/⬜|🔄|⏳|🚫|✅|⏭/g, cell.icon).replace(/\b(ready|in-progress|blocked|done|skipped)\b/gi, cell.word);
233
- }).join("\n");
234
- }
235
-
236
- /**
237
- * Apply one validated WorkResult to the working tree — the single-writer step (D6): ticks AC
238
- * boxes, flips task status, appends the Execution Log, propagates unblocks, appends discoveries,
239
- * writes the verdict ledger + un-ticks refuted boxes.
240
- * verdict{criteria[],refuted[]}).
241
- * @param {{cwd:string}} opts - cwd: working-directory root every LOCAL path resolves against.
242
- * @returns {{slug:string, tasks_updated:string[], acs_ticked:number, unblocked:string[],
243
- * discoveries_appended:number, escalations_appended:number, refuted_unticked:number, verdict_lines:number}} A summary of every write performed.
244
- * @throws {Error} If a task/board/ledger file it must write is not writable (fs error propagates).
245
- * `evaluation/.verdicts-*.jsonl` under `.shapeup/<slug>/`.
246
- */
247
- export function applyResult(result, { cwd }) {
248
- const slug = result.order_id.split("/")[0];
249
- return withLock(cwd, slug, () => applyResultLocked(result, { cwd, slug }));
250
- }
251
-
252
- /**
253
- * The reducer proper. Runs inside the run's ingest lock — see {@link withLock}.
254
- *
255
- * @param {object} result - A schema-valid WorkResult.
256
- * @param {object} ctx - `{cwd, slug}`.
257
- * @returns {object} The summary {@link applyResult} returns.
258
- */
259
- function applyResultLocked(result, { cwd, slug }) {
260
- const local = localRoot(cwd, slug);
261
- const summary = { slug, tasks_updated: [], acs_ticked: 0, unblocked: [], discoveries_appended: 0, escalations_appended: 0, refuted_unticked: 0, verdict_lines: 0 };
262
-
263
- // 1. Task results → task files + board (old task-executor P3.1/P3.2/P3.6).
264
- const boardIndex = join(local, "tasks", "_index.md");
265
- for (const tr of result.task_results || []) {
266
- const path = findTaskFile(cwd, slug, tr.task_id);
267
- if (!path) continue;
268
- let body = readFileSync(path, "utf8");
269
- for (const acr of tr.ac_results || []) {
270
- if (acr.result === "pass") {
271
- const r = setCheckbox(body, acr.ac, true);
272
- body = r.body;
273
- if (r.hit) summary.acs_ticked++;
274
- }
275
- }
276
- if (tr.status === "done") {
277
- body = setFrontmatter(body, "status", "done");
278
- body = setFrontmatter(body, "completed_at", today());
279
- } else if (tr.status === "partial" || tr.status === "failed") {
280
- body = setFrontmatter(body, "status", "in-progress");
281
- } else if (tr.status === "skipped") {
282
- // Rendered as what it is. A skipped task used to leave its file at `ready` and, through the
283
- // substring match above, could show ✅ on the index — the census read the index.
284
- body = setFrontmatter(body, "status", "skipped");
285
- }
286
- // Execution Log (append; the checkbox list must never disagree with it).
287
- const logLines = (tr.ac_results || []).map((a) => `- ${a.ac}: ${a.result}${a.evidence ? ` (${a.evidence})` : ""}`).join("\n");
288
- 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`;
289
- writeFileSync(path, body);
290
- summary.tasks_updated.push(tr.task_id);
291
- if (existsSync(boardIndex) && boardCell(tr.status)) {
292
- writeFileSync(boardIndex, updateBoardRow(readFileSync(boardIndex, "utf8"), tr.task_id, tr.status));
293
- }
294
- }
295
-
296
- // 2. Unblock propagation (old P3.4): any blocked task whose dependencies are all done → ready.
297
- const tasksDir = join(local, "tasks");
298
- if (existsSync(tasksDir)) {
299
- const files = readdirSync(tasksDir).filter((f) => /^TASK-[\w.-]+\.md$/i.test(f));
300
- const statusOf = {};
301
- const parsed = files.map((f) => {
302
- const body = readFileSync(join(tasksDir, f), "utf8");
303
- const id = (body.match(/^id:\s*(TASK-[\w.-]+)/im) || [])[1] || f.replace(/\.md$/, "");
304
- const status = (body.match(/^status:\s*(\S+)/im) || [])[1] || "unknown";
305
- const deps = (body.match(/^depends_on:\s*\[([^\]]*)\]/im) || [, ""])[1]
306
- .split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
307
- statusOf[id] = status;
308
- return { f, id, status, deps, body };
309
- });
310
- for (const t of parsed) {
311
- if (t.status === "blocked" && t.deps.length && t.deps.every((d) => statusOf[d] === "done")) {
312
- writeFileSync(join(tasksDir, t.f), setFrontmatter(t.body, "status", "ready"));
313
- summary.unblocked.push(t.id);
314
- if (existsSync(boardIndex)) {
315
- const idx = readFileSync(boardIndex, "utf8").split(/\r?\n/).map((line) =>
316
- rowIs(line, t.id)
317
- ? line.replace(/🚫|⏳/g, "⬜").replace(/\bblocked\b/gi, "ready")
318
- : line).join("\n");
319
- writeFileSync(boardIndex, idx);
320
- }
321
- }
322
- }
323
- }
324
-
325
- // 3. Discoveries → the ledger (old P3.7 / QA H.3), plus an ESCALATE's deviations, tagged the same
326
- // way and landed under the same heading. Single writer: this script. A worker's protocol names
327
- // `deviations[]` as the only channel a blocked worker has — there is no `escalates[]` field —
328
- // and routing it into THIS ledger is what makes it reach a reader that already exists: the
329
- // ship report's own "Discovered, not built" section, and the census a scope's own worker reads
330
- // before proposing a cut, both read this file's unresolved `+`/`~` entries.
331
- const escalations = result.status === "escalated" ? (result.deviations || []) : [];
332
- if (result.discoveries?.length || escalations.length) {
333
- const ledgerDir = join(local, "discovery");
334
- mkdirSync(ledgerDir, { recursive: true });
335
- const ledger = join(ledgerDir, "ledger.md");
336
- if (!existsSync(ledger)) writeFileSync(ledger, `---\nfeature: ${slug}\n---\n# Discovery Ledger — ${slug}\n`);
337
- // IDEMPOTENT ON THE ORDER, not merely append-only. `reduce ingest` is the single writer, but
338
- // nothing stops the SAME order/result pair from being applied twice — a replayed ingest over an
339
- // already-applied result, never a fresh attempt (a real re-attempt earns its own order_id,
340
- // `r<N>-a<N+1>`). Measured: replaying one identical escalated WorkResult doubled its
341
- // `[ESCALATE]` line in this ledger, and both GATE H's census and the ship report's "Discovered,
342
- // not built" section count this file's open entries — so a replay silently inflated the count
343
- // for a WorkResult that ran exactly once. A block heading names its order verbatim, so a second
344
- // ingest of the same order recognises its own prior write and skips the append rather than
345
- // duplicating it.
346
- const existingLedger = readFileSync(ledger, "utf8");
347
- /**
348
- * The ledger heading one order's block is filed under — the idempotency key: a second ingest
349
- * of the SAME order recognises its own prior write by this string, verbatim.
350
- * @param {string} oid - `result.order_id` this block belongs to.
351
- * @returns {string} The heading prefix (open-ended — the date suffix varies, the order id does not).
352
- */
353
- const headingFor = (oid) => `## Discovered — ${oid} (`;
354
- const alreadyLogged = existingLedger.includes(headingFor(result.order_id));
355
- if (alreadyLogged) {
356
- summary.discoveries_appended = 0;
357
- summary.escalations_appended = 0;
358
- } else {
359
- const discoveryLines = (result.discoveries || []).map((d) => {
360
- 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);
361
- 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("")}`;
362
- });
363
- // `+` (candidate work), matching the schema's own reading of that marker — an ESCALATE is
364
- // exactly that: work a worker could not safely do without a decision only the census can make.
365
- const escalateLines = escalations.map((d) => `+ [ESCALATE] ${d}`);
366
- const lines = [...discoveryLines, ...escalateLines].join("\n");
367
- appendFileSync(ledger, `\n${headingFor(result.order_id)}${today()})\n${lines}\n`);
368
- summary.discoveries_appended = (result.discoveries || []).length;
369
- summary.escalations_appended = escalations.length;
370
- }
371
- }
372
-
373
- // 4. Verdict bookkeeping (old evaluator B.0/B.2/B.2b) — judge returns data, ingest writes.
374
- //
375
- // THE PROJECTION KEEPS THE TWO KEYS A LATER READER CANNOT RE-DERIVE. Measured on the first real
376
- // verdict of a spined run: 85 of 97 criteria carried `traces_to` in the WorkResult and 0 of 97
377
- // survived into this file, so the anchor from a criterion back to the requirement it grades was
378
- // written by the judge and discarded one step later. And the row carried only the monotonic `run`
379
- // counter, which restarts per ledger file and repeats across runs of one slug — `run_id` is the
380
- // only key that separates them, so a projection over this file silently mixed two runs without
381
- // it. A WorkResult carries no run key; it is read off the run's own receipt, the same derivation
382
- // every other writer here uses.
383
- if (result.verdict) {
384
- const evalDir = join(local, "evaluation");
385
- mkdirSync(evalDir, { recursive: true });
386
- if (result.verdict.criteria?.length) {
387
- const target = result.order_id.split("/")[1] || "run";
388
- const ledger = join(evalDir, `.verdicts-${target}.jsonl`);
389
- const runId = readRunId(cwd, slug);
390
- let run = 1;
391
- if (existsSync(ledger)) {
392
- const prior = readFileSync(ledger, "utf8").trim().split(/\n/).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
393
- run = prior.reduce((mx, r) => Math.max(mx, r.run || 0), 0) + 1;
394
- }
395
- const lines = result.verdict.criteria.map((c) => JSON.stringify({
396
- run, run_id: runId, dimension: c.dimension || "spec-conformance", criterion: c.criterion,
397
- verdict: c.verdict, confidence: c.confidence, reprobed: !!c.reprobed,
398
- evidence: c.evidence || "", traces_to: Array.isArray(c.traces_to) ? c.traces_to : [],
399
- at: new Date().toISOString(),
400
- })).join("\n");
401
- appendFileSync(ledger, lines + "\n");
402
- summary.verdict_lines = result.verdict.criteria.length;
403
- }
404
- for (const ref of result.verdict.refuted || []) {
405
- const path = findTaskFile(cwd, slug, ref.task_id);
406
- if (!path) continue;
407
- let body = readFileSync(path, "utf8");
408
- const r = setCheckbox(body, ref.ac, false);
409
- if (r.hit) summary.refuted_unticked++;
410
- body = setFrontmatter(r.body, "eval_verdict", "fail");
411
- body = setFrontmatter(body, "eval_at", today());
412
- writeFileSync(path, body);
413
- }
414
- }
415
-
416
-
417
-
418
- return summary;
419
- }
420
-
421
- // ---------------------------------------------------------------------------
422
- /** The typed argv contract (see `./lib/argv.mjs`). */
423
- export const ARGV_SPEC = {
424
- usage: "harness.mjs reduce ingest (<result.json> | --order <order.json>) [--cwd <dir>] [--no-receipt-check]",
425
- _: { arity: 0, max: 1, name: "result.json" },
426
- order: { type: "path" },
427
- cwd: { type: "path" },
428
- // The documented way through. The receipt channel is best-effort by design — a hook that could
429
- // fail a tool call would get the layer disabled — so a gate built on it needs an escape for the
430
- // environmental case, and one that appears in the run's own output rather than in folklore.
431
- "no-receipt-check": { type: "flag" },
432
- };
433
-
434
- /**
435
- * The result a given order is answered by.
436
- *
437
- * THE PAIRING IS A FACT OF THE ENVELOPE PORT, not a claim: `compile` writes `orders/<suffix>.json`
438
- * and every worker writes `results/<suffix>.json`. Deriving it here is what stops a dispatch from
439
- * being aimed by a worker's own report of where it put the work — measured once, when a worker that
440
- * had done its whole job reported a DIRECTORY as its result path, ingest read it, got EISDIR, and
441
- * the phase was thrown away. The caller naming the ORDER cannot make that mistake, because the
442
- * order is the thing it already holds.
443
- *
444
- * @param {string} orderPath - Path to the order this result answers.
445
- * @returns {string} The result path implied by that order.
446
- */
447
- export function resultFor(orderPath) {
448
- const abs = resolve(orderPath);
449
- const dir = dirname(abs);
450
- if (basename(dir) !== "orders") {
451
- console.error(` ✗ --order must name a file inside an orders/ directory (got ${abs})`);
452
- process.exit(2);
453
- }
454
- return join(dirname(dir), "results", basename(abs));
455
- }
456
-
457
- /**
458
- * The order a given result answers — the inverse of {@link resultFor}.
459
- *
460
- * Used only when a result was passed positionally, so an ingest aimed the documented old way is
461
- * still held to the same attestation as one aimed by `--order`.
462
- *
463
- * @param {string} resultPath - Path to the result.
464
- * @returns {string|null} The mirrored order path, or null when the result does not live in a
465
- * `results/` directory (a fixture tree, typically — which is exactly when there is nothing to
466
- * attest).
467
- */
468
- export function orderFor(resultPath) {
469
- const abs = resolve(resultPath);
470
- const dir = dirname(abs);
471
- if (basename(dir) !== "results") return null;
472
- return join(dirname(dir), "orders", basename(abs));
473
- }
474
-
475
- /**
476
- * Did the SHIPPED skill actually run for this order?
477
- *
478
- * WHY AN INGEST ASKS THIS AT ALL. A dispatch that fails — plugin absent, disabled, or the wrong
479
- * version — returns `<tool_use_error>Unknown skill</tool_use_error>`, and the sub-agent then does
480
- * the craft itself from the prose in its own prompt. The artifacts land in exactly the right place,
481
- * so the order gate and the sandbox guard both pass, the phase post-condition passes, and the run
482
- * advances having applied none of the shipped craft. The receipt (`hooks/dispatch-receipt.mjs`) is
483
- * the only fact that separates the two, and this is the only place that fact is load-bearing.
484
- *
485
- * ALL THREE CONDITIONS, NOT MERELY EXISTENCE. A receipt that only has to EXIST is satisfied by a
486
- * stale one from an earlier relaunch — order paths like `orders/orient.json` are stable across
487
- * relaunches and the re-dispatch path reuses them verbatim — and a receipt from the wrong skill is
488
- * satisfied by any dispatch at all. So: same `order_id`, `skill_invoked` equal to the worker the
489
- * order DECLARES, and not older than the order it claims to answer.
490
- *
491
- * `compiled_at` is optional in the schema, so the staleness bound is applied only when the order
492
- * carries one; an order without it is checked on the first two conditions rather than waved through
493
- * on a timestamp nobody wrote.
494
- *
495
- * @param {object} order - The parsed WorkOrder.
496
- * @param {string} cwd - Project root.
497
- * @returns {{ok:boolean, reason?:string, receipt?:object, rows:number}} `ok` with the matching
498
- * receipt, or the reason no row qualified plus how many rows were considered.
499
- */
500
- export function attestation(order, cwd) {
501
- const slug = String(order.order_id).split("/")[0];
502
- const path = dispatchReceipts(cwd, slug);
503
- let rows = [];
504
- try {
505
- rows = readFileSync(path, "utf8").split("\n").filter(Boolean)
506
- .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
507
- } catch { return { ok: false, reason: `no dispatch receipts at ${path}`, rows: 0 }; }
508
-
509
- const mine = rows.filter((r) => r.order_id === order.order_id);
510
- if (!mine.length) return { ok: false, reason: `no receipt for ${order.order_id}`, rows: rows.length };
511
-
512
- const rightSkill = mine.filter((r) => r.skill_invoked === order.worker);
513
- if (!rightSkill.length) {
514
- const saw = [...new Set(mine.map((r) => r.skill_invoked))].join(", ");
515
- return { ok: false, rows: mine.length,
516
- reason: `${order.order_id} declares worker "${order.worker}" but every receipt names [${saw}] — the dispatch ran a different skill` };
517
- }
518
- const floor = order.compiled_at ? Date.parse(order.compiled_at) : null;
519
- const fresh = floor === null || Number.isNaN(floor)
520
- ? rightSkill
521
- : rightSkill.filter((r) => Date.parse(r.at) >= floor);
522
- if (!fresh.length) {
523
- return { ok: false, rows: rightSkill.length,
524
- reason: `every receipt for ${order.order_id} predates the order (compiled ${order.compiled_at}) — a stale attestation from an earlier dispatch` };
525
- }
526
- return { ok: true, receipt: fresh[fresh.length - 1], rows: fresh.length };
527
- }
528
-
529
- // ---------------------------------------------------------------------------
530
- // Leg completion — the record the run had no way to write.
531
- // ---------------------------------------------------------------------------
532
- //
533
- // A leg has a real start (the dispatch receipt, hook-attested and append-only) and, until now, no
534
- // end. Without one, "how many scopes ran at once" and "how long did BUILD take" are guesses: the
535
- // only other candidates are an order file that is overwritten on relaunch and result-file mtimes,
536
- // which are whatever the last copy did. Ingest is where the end belongs — it is the single writer
537
- // of shared state and it is the act that closes a leg (compile → dispatch → ingest).
538
-
539
- /** `<slug>/<scope>-r<N>-a<M>` and `<slug>/evaluate-r<N>` — the round-addressed order id forms. */
540
- const ROUND_SUFFIX = /^(.*?)-r(\d+)(?:-a(\d+))?$/;
541
-
542
- /**
543
- * Split an order id into the scope/phase name and the round-attempt address it encodes.
544
- *
545
- * The id is the authority here rather than the payload: `compile` builds it from scope, round and
546
- * attempt, and it is the field every other record joins on.
547
- *
548
- * @param {string} orderId - e.g. `todo-cli/foundation-r2-a1`.
549
- * @returns {{name:(string|null), round:(number|null), attempt:(number|null)}} Nulls for a phase
550
- * order that carries no round (`orient`, `wire`, `hammer`).
551
- */
552
- export function addressOf(orderId) {
553
- const suffix = String(orderId ?? "").split("/").slice(1).join("/");
554
- if (!suffix) return { name: null, round: null, attempt: null };
555
- const m = suffix.match(ROUND_SUFFIX);
556
- if (!m) return { name: suffix, round: null, attempt: null };
557
- return { name: m[1], round: Number(m[2]), attempt: m[3] === undefined ? null : Number(m[3]) };
558
- }
559
-
560
- /**
561
- * Build the leg-completion row for one closed leg.
562
- *
563
- * EVERY TIME FIELD IS A SNAPSHOT. `compiled_at` is copied off the order as it reads NOW, because
564
- * the file is re-written verbatim on relaunch; a row that pointed at the file instead would re-date
565
- * itself to a later dispatch. `dispatched_at` comes from the receipt that ATTESTED this dispatch —
566
- * the one that already passed the same-order, same-skill and not-stale tests — so it cannot be an
567
- * earlier failed dispatch's row.
568
- *
569
- * THE RUN KEY IS RESOLVED, NOT COPIED. `order_id` alone collides across runs, and a row that
570
- * carried only it would be unjoinable exactly when two runs of one slug exist. When the order
571
- * carries no `run_id` the key is read from the run's own receipt through `lib/paths.mjs`, which is
572
- * the same derivation every other writer uses.
573
- *
574
- * Exported so the suite can assert the row's shape without a live dispatch.
575
- *
576
- * @param {object} order - The parsed WorkOrder this leg answered.
577
- * @param {(object|null)} receipt - The attesting dispatch receipt, when one matched.
578
- * @param {string} cwd - Project root, for the run-key fallback.
579
- * @param {string} [ingestedAt] - The completion instant; defaults to now.
580
- * @returns {object} The row appended to `legs.jsonl`.
581
- */
582
- export function legRow(order, receipt, cwd, ingestedAt = new Date().toISOString()) {
583
- const slug = String(order.order_id ?? "").split("/")[0];
584
- const { name, round, attempt } = addressOf(order.order_id);
585
- const dispatchedAt = receipt?.at ?? null;
586
- const startedAt = dispatchedAt ?? order.compiled_at ?? null;
587
- const startMs = startedAt ? Date.parse(startedAt) : NaN;
588
- const endMs = Date.parse(ingestedAt);
589
- return {
590
- schema_version: 1,
591
- // Resolved rather than defaulted to null: an unkeyed row is a row no later run can be
592
- // distinguished from, and the receipt on disk already answers the question.
593
- run_id: order.run_id ?? (slug ? readRunId(cwd, slug) : null),
594
- order_id: order.order_id ?? null,
595
- worker: order.worker ?? null,
596
- operation: order.operation ?? null,
597
- mode: order.mode ?? null,
598
- scope_id: name,
599
- round,
600
- attempt,
601
- compiled_at: order.compiled_at ?? null,
602
- dispatched_at: dispatchedAt,
603
- ingested_at: ingestedAt,
604
- // Which fact the duration is measured FROM travels with the duration, because the two starts
605
- // are not the same measurement: a receipt is evidence the skill resolved, `compiled_at` is only
606
- // evidence an order was written.
607
- started_from: dispatchedAt ? "dispatch-receipt" : (order.compiled_at ? "compiled_at" : null),
608
- duration_ms: Number.isFinite(startMs) && Number.isFinite(endMs) ? endMs - startMs : null,
609
- attested: !!receipt,
610
- };
611
- }
612
-
613
- /**
614
- * Append one leg-completion row. Never throws.
615
- *
616
- * FAIL-OPEN, like the receipt hook it complements: a timing record that can fail an ingest would
617
- * cost the run a board write to save a measurement, which is the wrong trade. A row that does not
618
- * reach disk surfaces later as a leg with no completion record — visible, and not mistaken for a
619
- * leg that ran instantly.
620
- *
621
- * @param {object} row - A {@link legRow}.
622
- * @param {string} cwd - Project root.
623
- * @returns {(string|null)} The ledger path when the row landed, else null.
624
- */
625
- export function appendLeg(row, cwd) {
626
- try {
627
- const slug = String(row.order_id).split("/")[0];
628
- if (!slug) return null;
629
- const path = legLedger(cwd, slug);
630
- mkdirSync(dirname(path), { recursive: true });
631
- appendFileSync(path, JSON.stringify(row) + "\n");
632
- return path;
633
- } catch { return null; }
634
- }
635
-
636
- /**
637
- * Apply one WorkResult to shared state — the single writer for the board and the ledgers.
638
- *
639
- * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
640
- * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
641
- * call `process.exit()` with the subcommand's documented code rather than returning.
642
- */
643
- export async function cli(rawArgv) {
644
- const args = runArgs(ARGV_SPEC, rawArgv);
645
- const file = args.order ? resultFor(args.order) : args._[0];
646
- if (!file) {
647
- console.error(" ✗ nothing to ingest: pass a result path, or --order <order.json> to derive it");
648
- process.exit(2);
649
- }
650
- const cwd = resolve(args.cwd || process.cwd());
651
-
652
- let result;
653
- try { result = JSON.parse(readFileSync(resolve(file), "utf8")); }
654
- catch (e) { console.error(` ✗ result unreadable: ${e.message}`); process.exit(1); }
655
-
656
- const { valid, errors } = validate(result, RESULT_SCHEMA);
657
- if (!valid) {
658
- console.error("ingest-result: result rejected — a malformed result never mutates the board:");
659
- for (const e of errors) console.error(` ✗ ${e}`);
660
- process.exit(1);
661
- }
662
-
663
- // --- attestation gate -----------------------------------------------------------------------
664
- // SCOPED TO ORCHESTRATED ORDERS, deliberately. A result with no order beside it is a standalone
665
- // or fixture ingest — there was no dispatch to attest, so demanding a receipt would refuse work
666
- // that never made the claim. What it must NOT do is let the orchestrated case opt out by omitting
667
- // `--order`, which is why a positionally-named result is mirrored back to its order first.
668
- const orderPath = args.order ? resolve(args.order) : orderFor(file);
669
- let order = null;
670
- if (orderPath) { try { order = JSON.parse(readFileSync(orderPath, "utf8")); } catch { order = null; } }
671
-
672
- // --- identity gate --------------------------------------------------------------------------
673
- // A RESULT MUST CLAIM THE ORDER IT IS ANSWERING. `order_id` is the only join in the record set:
674
- // a WorkResult deliberately carries no `run_id` and reaches it through this field, so a result
675
- // that echoes the wrong id is not mislabelled, it is DETACHED — from its run, its round, and the
676
- // order whose receipt attests it.
677
- //
678
- // Measured on a two-round run: round 1's evaluate order was `todo-cli/evaluate-r1` and its result
679
- // came back claiming `todo-cli/evaluate`, an order that has never existed. Everything downstream
680
- // still looked right, because the FILE is named after the order — only the run graph disagreed,
681
- // and only because it keys nodes off the envelope rather than the filename. Round 2's ids matched,
682
- // so a single-round run cannot show this at all.
683
- //
684
- // The ORDER is authoritative: it is the compiled artifact, and the result is a reply to it. But
685
- // this refuses rather than rewriting, because silently normalising a worker's output is how the
686
- // drift becomes permanent and invisible — the same reason ingest refuses a malformed envelope
687
- // instead of repairing one.
688
- if (order && String(result.order_id) !== String(order.order_id)) {
689
- console.error(`ingest-result: result refused — it answers order "${order.order_id}" but claims`);
690
- console.error(` order_id "${result.order_id}". A result reaches its run through this field and`);
691
- console.error(` nothing else, so an id that names a different order (or none) detaches the record`);
692
- console.error(` from the run that produced it. Correct the result's order_id to match its order.`);
693
- process.exit(1);
694
- }
695
-
696
- // --- Verdict gate: structure, row coverage, T0 citation ---------------------------------------
697
- // A PASS or FAIL on a scoped spec that cites no T0 artifact, or a PASS that does not grade every
698
- // Test Surface row by name, is not a judgement this run may act on (see `citationProblem`,
699
- // `coverageProblem`). `probe eval` refuses it to the round loop; refusing it here as well keeps
700
- // the verdict ledger from recording a verdict the loop will never branch on.
701
- if (result.verdict) {
702
- const evalSlug = String(result.order_id).split("/")[0];
703
- const evalRound = Number((String(result.order_id).match(/-r(\d+)$/) || [])[1]) || null;
704
- const problem = verdictProblem(result.verdict) || coverageProblem(cwd, evalSlug, result.verdict)
705
- || tracesProblem(cwd, evalSlug, result.verdict) || dimensionsProblem(cwd, evalSlug, result.verdict, { round: evalRound })
706
- || citationProblem(cwd, evalSlug, result.verdict, { round: evalRound });
707
- if (problem) {
708
- console.error(`ingest-result: result refused — ${problem}.`);
709
- console.error(` The round stays open: re-dispatch the evaluator against its order, which lists`);
710
- console.error(` the T0 artifacts to cite and the spec whose rows it grades. Nothing was written.`);
711
- process.exit(1);
712
- }
713
- }
714
-
715
- // --- Hunt gate: a `done` hunt over a reachable app ran at least one charter ---------------------
716
- // A hunter that reached the app and drafted nothing returned `done`, and a `done` with no findings
717
- // reads as a clean app. See `huntProblem`; `probe hunt` refuses the same result to the run.
718
- if (result.worker === "qa-edge-hunter") {
719
- const reportRel = (Array.isArray(result.artifacts) ? result.artifacts : []).find((a) => /hunt-report\.md$/.test(String(a)));
720
- const reportAbs = reportRel ? (String(reportRel).startsWith("/") ? reportRel : join(cwd, reportRel)) : null;
721
- let report = null;
722
- for (const p of [reportAbs, join(qaDir(cwd, String(result.order_id).split("/")[0]), "hunt-report.md")]) {
723
- if (!report && p && existsSync(p)) report = readFileSync(p, "utf8");
724
- }
725
- const problem = huntProblem(result, order.payload || {}, report);
726
- if (problem) {
727
- console.error(`ingest-result: result refused — ${problem}.`);
728
- console.error(` Re-dispatch the hunter against its order. Nothing was written.`);
729
- process.exit(1);
730
- }
731
- }
732
-
733
- // Resolved for EVERY order, not only the gated ones: the attesting receipt is this leg's start,
734
- // and a standalone or `--no-receipt-check` ingest still deserves a truthful timing row rather
735
- // than one silently falling back to the order's re-writable `compiled_at`.
736
- const att = order ? attestation(order, cwd) : { ok: false, receipt: null };
737
-
738
- if (order?.mode === "orchestrated" && !args.noReceiptCheck) {
739
- if (!att.ok) {
740
- console.error(`ingest-result: result refused — ${att.reason}.`);
741
- console.error(` The order declares mode "orchestrated", so the dispatch must have left a receipt`);
742
- console.error(` (${dispatchReceipts(cwd, String(order.order_id).split("/")[0])}). No receipt means the Skill`);
743
- console.error(` dispatch never ran: the plugin may be absent, disabled, or a different version, and the`);
744
- console.error(` artifacts on disk were produced by something other than the shipped skill.`);
745
- console.error(` Re-dispatch the worker, or pass --no-receipt-check if the receipt failed for an`);
746
- console.error(` environmental reason and you are accepting the result without that attestation.`);
747
- process.exit(1);
748
- }
749
- console.log(` ✓ attested: ${att.receipt.order_id} ran ${att.receipt.skill_invoked} at ${att.receipt.at}`);
750
- }
751
-
752
- const s = applyResult(result, { cwd });
753
-
754
- // The leg closes HERE, after the writes land — a row written before them would date a leg by an
755
- // ingest that could still fail. Only when an order is on disk: a fixture ingest answers no
756
- // dispatch, so it has no leg to close and inventing one would put a phantom interval in the
757
- // record set every later measurement reads.
758
- if (order) {
759
- const row = legRow(order, att.ok ? att.receipt : null, cwd);
760
- if (!appendLeg(row, cwd)) {
761
- console.error(` ! leg-completion row could not be written for ${row.order_id} — this leg will ` +
762
- `read as having no completion record (harness probe concurrency reports it as such).`);
763
- }
764
- }
765
-
766
- console.log(`✅ ingested ${result.order_id} — tasks: [${s.tasks_updated.join(", ")}] · ACs ticked: ${s.acs_ticked} · unblocked: [${s.unblocked.join(", ")}] · discoveries: ${s.discoveries_appended} · escalations: ${s.escalations_appended} · verdict lines: ${s.verdict_lines} · refuted un-ticked: ${s.refuted_unticked}`);
767
- }