shapeup-sdlc 3.18.2 → 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 +29 -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,1419 +0,0 @@
1
- #!/usr/bin/env node
2
- // WorkOrder compiler (pure-skill architecture v1.0, plan P1).
3
- //
4
- // The orchestrator's pipeline sub-layer: assembles the structured input envelope a worker is
5
- // dispatched with. Replaces tech-lead's hand-assembled `isolated_brief()` prose step and every
6
- // worker's GATE A/B plumbing (path resolution, run-state parse, board glob-matching, dependency
7
- // reads, mode detection). Deterministic, zero LLM tokens.
8
- //
9
- // A worker depends on its ORDER, never on filesystem topology — moving a directory again
10
- // (the v3.2 lesson) touches this script, zero skills.
11
- //
12
- // Zero dependencies, zero network.
13
- //
14
- // Usage:
15
- // Scope attempt (isolated attempt loop):
16
- // node kernel/harness.mjs compile --scope shapeup/<slug>/scopes/<id>.md \
17
- // --round N --attempt M [--cwd <dir>] [--test-cmd "<cmd>"]
18
- // Single task (no scope contracts — pre-v0.3.0 boards):
19
- // node kernel/harness.mjs compile --task TASK-003 --slug <slug> [--worker task-executor]
20
- // node kernel/harness.mjs compile --next --slug <slug>
21
- // Non-build operation (planner/judge/QA dispatches; flag surface → operation + whitelist):
22
- // node kernel/harness.mjs compile --operation reconcile --slug <slug> [--round N]
23
- // [--worker ba-pitch-analyzer] [--payload '<json>']
24
- //
25
- // Output: .shapeup/<slug>/orders/<order-file>.json (schema-validated before write; a
26
- // pretty-printed envelope, colocated so audits can read it). Prints the path on stdout.
27
-
28
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
29
- import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "./gate.mjs";
30
- import { resolve, join, dirname, basename, relative, sep } from "node:path";
31
- import { fileURLToPath } from "node:url";
32
- import { validate } from "./verify/envelope.mjs";
33
- import { readTrials } from "./verify/t0.mjs";
34
- import { runArgs } from "./lib/argv.mjs";
35
- import { readRunId, dispatchReceipts, legLedger, readReceipt, receipt } from "./lib/paths.mjs";
36
- // `specDir` is aliased: this module has a local `let specDir` holding the resolved, possibly
37
- // --spec-overridden directory, and the import is the convention-derived default.
38
- import {
39
- tasksDir, specDir as defaultSpecDir, roundLedger, trials, verdictsDir, ordersDir,
40
- relShared, relLocal, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir, localRoot,
41
- } from "./lib/paths.mjs";
42
- import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT, reqId } from "./lib/contract.mjs";
43
- import { writeActiveOrder } from "./probe/resume.mjs";
44
- import { greenVerdict } from "./probe/t0.mjs";
45
- import { attemptEvidence, readReceipts } from "./probe/attempts.mjs";
46
- import { readLegs } from "./probe/leg.mjs";
47
- import { latestRoundBuild, latestRoundBuildFile, launchProbeFor } from "./verify/build.mjs";
48
- // The SAME matcher the sandbox hook enforces with. "Is this cited file inside this scope's
49
- // substrate" has to mean exactly what the guard means, or a bug is addressed to a scope that is
50
- // then denied the write that fixes it.
51
- import { matchesAny } from "../hooks/sandbox-guard.mjs";
52
-
53
- /**
54
- * Workers that read a coaching file. Kept beside the compile step because this is the only place
55
- * the file is handed over: a worker not in this set never sees `payload.kb_rules_path`, however
56
- * many rules the coach files for it. Mirrored by the coach skill's category list (structural test).
57
- */
58
- export const COACHABLE = new Set([
59
- "task-executor", "ba-pitch-analyzer", "qa-edge-hunter",
60
- "orient", "scope-architect", "solution-architect",
61
- ]);
62
-
63
- const HERE = dirname(fileURLToPath(import.meta.url));
64
- const ORDER_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "./schemas/work-order.schema.json"), "utf8"));
65
-
66
- // --- tiny frontmatter reader (scalar keys + [a, b] inline lists) --------------------------
67
- /**
68
- * Parse a task/spec Markdown frontmatter block into scalar keys and inline `[a, b]` lists.
69
- * @param {string} md - Full Markdown document text.
70
- * @returns {Object<string,(string|string[])>} A flat map of top-level frontmatter keys; a value
71
- * written as `[x, y]` becomes a trimmed string[], every other value stays a string. Returns
72
- * {} when the document has no leading `---`-delimited block.
73
- */
74
- export function frontmatter(md) {
75
- const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
76
- if (!m) return {};
77
- const meta = {};
78
- for (const line of m[1].split(/\r?\n/)) {
79
- const c = line.indexOf(":");
80
- if (c === -1 || /^\s/.test(line)) continue;
81
- const key = line.slice(0, c).trim();
82
- let val = line.slice(c + 1).trim().replace(/^["']|["']$/g, "");
83
- if (/^\[.*\]$/.test(val)) {
84
- meta[key] = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
85
- } else meta[key] = val;
86
- }
87
- return meta;
88
- }
89
-
90
- /**
91
- * Parse one TASK-NNN.md file into the task entry a WorkOrder carries.
92
- * @param {string} path - Absolute path to the task Markdown file.
93
- * @returns {{id:string, title:string, status:string, priority:number, depends_on:string[],
94
- * use_case_refs:string[], scope_id:string, body_path:string, acceptance_criteria:Array<string|{text:string,
95
- * covers:string[]}>}} The task entry. Each acceptance criterion is the checkbox text
96
- * byte-identical (so ingest can tick it back); a trailing `(covers: REQ-…)` clause yields
97
- * {text, covers} instead of a plain string. `priority` defaults to 999 when unset.
98
- * @throws {Error} If `path` is not readable.
99
- */
100
- export function parseTaskFile(path) {
101
- const body = readFileSync(path, "utf8");
102
- const fm = frontmatter(body);
103
- const acceptance_criteria = [];
104
- // A `(covers: …)` clause is read across the WHOLE bullet — the checkbox line and the indented
105
- // continuation lines under it — and each token folds through the one key helper. The clause was
106
- // scanned on the checkbox line alone, so a board whose every AC carried one on its continuation
107
- // line projected as a board carrying none: the requirements matrix printed "no evidence" for
108
- // clauses that had a PASS criterion anchored to them. Measured live. `text` stays byte-identical
109
- // to the checkbox line, because ingest ticks the box by matching it back.
110
- const lines = body.split(/\r?\n/);
111
- for (let i = 0; i < lines.length; i++) {
112
- const m = lines[i].match(/^\s*- \[[ x]\]\s+(.*)$/);
113
- if (!m) continue;
114
- const text = m[1].trim();
115
- let block = text;
116
- for (let j = i + 1; j < lines.length; j++) {
117
- const l = lines[j];
118
- if (!l.trim() || /^\s*[-*+]\s/.test(l) || /^#/.test(l) || !/^\s/.test(l)) break;
119
- block += "\n" + l;
120
- }
121
- const covers = [];
122
- for (const cov of block.matchAll(/\(covers:\s*([^)]*)\)/gi)) {
123
- for (const raw of cov[1].split(",")) { const id = reqId(raw); if (/^REQ-\d+$/.test(id) && !covers.includes(id)) covers.push(id); }
124
- }
125
- acceptance_criteria.push(covers.length ? { text, covers } : text);
126
- }
127
- return {
128
- id: fm.id || basename(path).match(/TASK-[\w.-]+?(?=-|\.md)/)?.[0] || basename(path, ".md"),
129
- title: fm.title || "",
130
- status: fm.status || "unknown",
131
- priority: Number(fm.priority) || 999,
132
- depends_on: Array.isArray(fm.depends_on) ? fm.depends_on : [],
133
- use_case_refs: Array.isArray(fm.use_case_refs) ? fm.use_case_refs : [],
134
- scope_id: fm.scope_id || "",
135
- body_path: path,
136
- acceptance_criteria,
137
- };
138
- }
139
-
140
- /**
141
- * Read every task entry on the LOCAL board for a slug, sorted by ascending priority.
142
- * @param {string} cwd - Working-directory root the `.shapeup/<slug>/tasks` path resolves against.
143
- * @param {string} slug - Feature slug naming the run.
144
- * @returns {Array<object>} The parsed task entries (see {@link parseTaskFile}); [] when the tasks
145
- * directory does not exist.
146
- */
147
- export function readBoard(cwd, slug) {
148
- const dir = tasksDir(cwd, slug);
149
- if (!existsSync(dir)) return [];
150
- return readdirSync(dir)
151
- .filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))
152
- .map((f) => parseTaskFile(join(dir, f)))
153
- .sort((a, b) => a.priority - b.priority);
154
- }
155
-
156
- /**
157
- * Extract a scope's advisor-answer rows from the committed round ledger's Decisions table.
158
- * @param {string} ledgerText - Full `round-ledger.md` text ("" / null → no decisions).
159
- * @param {string} [scopeId] - When given, keep only rows whose cells name this scope; omit to keep all.
160
- * @returns {Array<{id:string, answer:string}>} One entry per `ESC-N` row (id = the ESC id, answer =
161
- * the row's last cell); [] when the Decisions section is absent or empty.
162
- */
163
- export function ledgerDecisions(ledgerText, scopeId) {
164
- const decisions = [];
165
- let inDecisions = false;
166
- for (const line of (ledgerText || "").split(/\r?\n/)) {
167
- if (/^#+\s*Decisions/i.test(line)) { inDecisions = true; continue; }
168
- if (inDecisions && /^#+\s/.test(line)) inDecisions = false;
169
- if (!inDecisions || !line.trim().startsWith("|")) continue;
170
- const cells = line.split("|").map((s) => s.trim()).filter(Boolean);
171
- if (cells.length < 2 || /^[-: ]+$/.test(cells[0])) continue;
172
- const id = cells.find((c) => /^ESC-\d+/i.test(c));
173
- if (!id) continue;
174
- if (scopeId && !cells.some((c) => c === scopeId)) continue;
175
- decisions.push({ id, answer: cells[cells.length - 1] });
176
- }
177
- return decisions;
178
- }
179
-
180
- /**
181
- * Operation → owning worker (mirrors `domain.schema.json#/$defs/Operation` ownership). Lets a
182
- * non-build dispatch resolve its worker from the operation alone, without a redundant `--worker`.
183
- *
184
- * Exported and at module scope so it is ONE table. Every value here must be a member of
185
- * `WorkerName`: an operation mapped to a name the enum does not carry compiles an order that fails
186
- * its own schema, and the dispatch is then denied by the order gate for a reason that names the
187
- * envelope rather than the typo. That parity is checked by the suite, which can only read a table
188
- * it can import.
189
- */
190
- export const OP_OWNER = {
191
- analyze: "ba-pitch-analyzer", reconcile: "ba-pitch-analyzer",
192
- // `board` regenerates the per-machine board from a committed spec tree without re-deriving the
193
- // tree — the half of ANALYZE that does not survive a clone. Same worker, narrower write surface.
194
- board: "ba-pitch-analyzer",
195
- "retrofit-surface": "ba-pitch-analyzer", coverage: "ba-pitch-analyzer",
196
- "map-scopes": "scope-architect",
197
- wire: "solution-architect", evaluate: "spec-evaluator", orient: "orient",
198
- hunt: "qa-edge-hunter", translate: "translator",
199
- hammer: "scope-hammer", coach: "coach",
200
- // `scan` is the coach reading the project instead of L4 feedback, and `research` the coach
201
- // reading the platform's official documentation instead of the project — a project with nothing
202
- // on disk has nothing to scan. Same worker, same write surface, same categorization gate. An
203
- // operation the schema enumerates but this table does not route compiles no order at all, so the
204
- // suite checks the two the other way round as well.
205
- scan: "coach",
206
- research: "coach",
207
- };
208
-
209
- /**
210
- * Resolve the write-contract (sandbox substrate) for an operation — one whitelist template per
211
- * operation, so mode/flag differences are enforced by the sandbox hook reading the order's substrate, not trusted to prose.
212
- * @param {string} operation - The order's operation (execute|fix|spike|analyze|reconcile|
213
- * retrofit-surface|coverage|map-scopes|wire|evaluate|orient|hunt|translate|hammer|coach|scan|research).
214
- * @param {{slug?:string, specDir?:string, scope?:object, ownStem?:string}} [ctx] - slug (names
215
- * LOCAL/SHARED roots), specDir (overrides the default spec path), scope (contract supplying
216
- * allowed/shared substrates), ownStem (this order's own file stem, so a build leg may write its
217
- * own WorkResult and no one else's).
218
- * @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[], own?:string[]}}
219
- * The substrate contract: globs the worker may write (`allowed`), shared-write globs, read-only
220
- * `frozen` globs, `append_only` globs, and `own` — the paths this order may write DESPITE a
221
- * broader freeze, derived by the compiler from the order's own identity and never requested.
222
- * An unknown operation returns a LOCAL-only default.
223
- */
224
- export function substrateFor(operation, ctx = {}) {
225
- // EVERY ORDER NAMES THE RESULT IT ANSWERS WITH. A worker used to infer that path from its order's
226
- // own filename — the one thing in the envelope that was convention rather than contract — so the
227
- // one file every dispatch must write was the one the order did not mention. It is `own` for every
228
- // operation now: the compiler derives it from the order's identity, which is also what keeps a leg
229
- // from writing somebody else's.
230
- const base = substrateTemplate(operation, ctx);
231
- if (!ctx.ownStem) return base;
232
- const ownResult = `${globLocal(ctx.slug)}/results/${ctx.ownStem}.json`;
233
- return { ...base, own: [...new Set([...(base.own || []), ownResult])] };
234
- }
235
-
236
- /**
237
- * The per-operation template {@link substrateFor} builds on.
238
- *
239
- * @param {string} operation - The order's operation.
240
- * @param {object} [ctx] - As {@link substrateFor}: slug, specDir, scope, ownStem.
241
- * @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[], own?:string[]}}
242
- * The operation's write contract before the result path every order names is merged in.
243
- */
244
- function substrateTemplate(operation, { slug, specDir, scope, ownStem = null } = {}) {
245
- const local = globLocal(slug);
246
- const spec = specDir || globShared(slug, "spec");
247
- const scopesDir = globShared(slug, "scopes");
248
- // Working notes live in the LOCAL tier since ADR-0001: the committed `spec/` keeps only what the
249
- // evaluator grades against and a reviewer needs. `synthesis.md`, `assess-report.md`,
250
- // `feedback.md`, `api-feasibility.md` and `integration.md` are analysis, not contract.
251
- const working = `${local}/working`;
252
- const FROZEN_SPEC_CORE = [`${spec}/domain-model.md`, `${spec}/usecases/*.md#Steps`, `${spec}/contracts/**`, `${spec}/ux-behavior.md`];
253
- // THE STAGED PITCH IS THE RUN'S OWN INPUT TRUTH, and it is a separate constant from the spec core
254
- // on purpose: the spec core is what a planner PRODUCES and a judge grades against, while these two
255
- // are what the run was asked for. `init run` stages them beside the receipt that digests them, and
256
- // every planning and evaluating operation reads them — so a worker that can rewrite either can
257
- // rewrite the question it is about to be measured on, and the receipt's digest stops describing
258
- // the file next to it. Frozen, not merely unlisted: `analyze` already carries the whole run trace
259
- // in its `allowed` globs, so only a `frozen` entry denies the write. `translate` is the one
260
- // operation that legitimately rewrites a pitch, and it writes the COMMITTED copy, not this one.
261
- const FROZEN_INTAKE = [`${local}/intake.md`, `${local}/breadboard.md`];
262
- // THE LEG'S OWN ATTESTATION. A dispatch receipt, a leg-completion row and a T0 verdict exist to
263
- // answer "did a worker actually run, and what did it measure" from evidence the leg being judged
264
- // does not control — the whole reason to prefer them over a compiled order, which is writable by
265
- // that same hand. None of the three is ever produced by this assistant's own edit tool in normal
266
- // operation: a receipt is stamped by the dispatch hook reacting to the call itself, a
267
- // leg-completion row by the step that reads a landed result, and a verdict by the command that
268
- // runs the fixtures. A build leg reaching for `Edit`/`Write` on any of them is not doing its job
269
- // by another route — it is handing itself the grade — so freezing them costs a well-behaved leg
270
- // nothing and removes the one channel through which it could attest work it did not do. The
271
- // carve-out below still covers everything else under this root — the doer's own task board and
272
- // discovery ledger — because neither lives under any of these three.
273
- //
274
- // THE RESULT ENVELOPE IS DELIBERATELY NOT ON THIS LIST, and the reasoning is worth keeping because
275
- // it looks like it belongs. It is the leg's own claim about its own work, so on the argument above
276
- // it is the first thing you would freeze. But a result is not evidence ABOUT the leg, it is the
277
- // leg's PRODUCT — the other half of the envelope port, and the thing that answers the order. The
278
- // order is unanswered at that moment by construction, so freezing the path would deny every build
279
- // leg its documented last step, every time, on the first round: measured end to end against a
280
- // compiled order, with the denial telling the worker to widen a substrate that cannot lift a
281
- // frozen entry. Nothing else writes an ordinary result either, so there is no fallback. The census
282
- // that reads it is already built for this: a result alone attests nothing, and can only turn an
283
- // ALREADY-receipted attempt into a spent one, which spends the forger's own budget. What that does
284
- // not cover is a leg forging a SIBLING's result, which is a real hole with a different fix — the
285
- // glob form here cannot say "every result except this order's own", so closing it needs a
286
- // mechanism rather than one more entry on this list.
287
- // The board index is ingest's projection of the task results, not the doer's bookkeeping — the
288
- // task files are. The executor's contract already forbids editing it; the freeze makes that a
289
- // denial rather than a rule, on the same terms as the attestation channels.
290
- const FROZEN_ATTESTATION = [`${local}/receipts/**`, `${local}/legs.jsonl`, `${local}/t0/verdicts/**`, `${local}/tasks/_index.md`];
291
- switch (operation) {
292
- case "execute": case "fix": case "spike":
293
- // THE RUN TRACE IS THE KERNEL'S, EXCEPT WHAT THIS LEG AUTHORS. The freeze used to be a list of
294
- // channels a defect had named — the staged pitch, the receipts, the leg ledger, the T0
295
- // verdicts, the board index — and each review found more of the same class: the trial ledger,
296
- // the gate ledger, the round build gates, the graph, the run args, the run ledger itself. A
297
- // list that grows one defect at a time is not a boundary. So the boundary is inverted here:
298
- // everything under the run trace is frozen for a build leg, and `own` carries the short,
299
- // derived list of what such a leg actually writes. Everything else under there is written by
300
- // the kernel in its own process or by the hook layer, and neither goes through this guard —
301
- // so freezing it costs no legitimate write. `${local}/**` subsumes FROZEN_INTAKE and
302
- // FROZEN_ATTESTATION for this operation, and a check asserts that rather than restating them.
303
- return {
304
- allowed: [...(scope?.allowed_file_substrate || []), `${local}/spikes/**`],
305
- shared: scope?.shared_substrate || [],
306
- frozen: [`${local}/**`],
307
- own: [
308
- // The result this order answers with, and no sibling's: a leg that can write another
309
- // leg's WorkResult can report work nobody did, and ingest would have no way to tell.
310
- // With no stem the caller is asking about the operation in general, not about one order,
311
- // and the honest answer is the whole directory rather than a guess at which file.
312
- ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`]),
313
- `${local}/tasks/TASK-*.md`,
314
- `${local}/discovery/**`,
315
- `${local}/spikes/**`,
316
- ],
317
- };
318
- case "analyze":
319
- return { allowed: [`${spec}/**`, `${local}/**`], frozen: [...FROZEN_INTAKE] };
320
-
321
- case "board":
322
- // The spec is READ, never written: this operation exists because the tree is already on disk
323
- // and the board is not. Task ids renumber per machine by design, so nothing here is committed.
324
- return {
325
- allowed: [`${local}/tasks/**`, `${working}/**`],
326
- frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${spec}/usecases/*.md`, `${spec}/scope-summary.md`],
327
- };
328
-
329
- case "reconcile":
330
- return {
331
- allowed: [`${local}/tasks/**`, `${spec}/scope-summary.md`, `${working}/**`],
332
- append_only: [`${spec}/usecases/*.md#Invariants`, `${spec}/usecases/*.md#Test Surface`],
333
- frozen: FROZEN_SPEC_CORE,
334
- };
335
- case "retrofit-surface":
336
- return { allowed: [], append_only: [`${spec}/usecases/*.md#Test Surface`], frozen: FROZEN_SPEC_CORE };
337
-
338
- case "coverage":
339
- // The covers-closure input truth. Writes ONLY the derived registry: the REQ source it
340
- // extracts from is frozen alongside the spec core, because a planner that may edit the
341
- // requirements it is being measured against is not measuring anything.
342
- return { allowed: [globShared(slug, "requirements.md")], frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE] };
343
-
344
- case "map-scopes":
345
- return {
346
- allowed: [`${scopesDir}/*.md`, globShared(slug, "scope-board.md")],
347
- frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${local}/tasks/**`],
348
- };
349
- case "wire":
350
- // solution-architect writes the SHARED wiring map DIRECTLY (precedent: scope-architect
351
- // writes scopes/*.md). The spec core, the scopes, and the profile stay frozen.
352
- return {
353
- allowed: [globShared(slug, "wiring-map.md")],
354
- frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${scopesDir}/**`, globShared(slug, "project-profile.md")],
355
- };
356
- case "evaluate":
357
- // THE JUDGE MAY NOT WRITE THE EVIDENCE IT CITES. Its substrate froze the spec, the pitch and
358
- // the board — and not `t0/verdicts/**`, so a judge could write a green verdict artifact into
359
- // the canonical directory under the run-trace carve-out and then cite it, correctly hashed.
360
- // Same inversion as a build leg: the run trace is the kernel's, and `own` names what the
361
- // judge authors — its report and the envelope that answers its order.
362
- return {
363
- allowed: [],
364
- frozen: [`${spec}/**`, `${local}/**`],
365
- own: [`${local}/evaluation/**`, ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`])],
366
- };
367
- case "hunt":
368
- return {
369
- allowed: [],
370
- frozen: [`${spec}/**`, `${local}/**`],
371
- own: [`${local}/qa/**`, ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`])],
372
- };
373
- case "orient":
374
- return { allowed: [`${local}/orient/**`], frozen: [`${spec}/**`] };
375
- case "translate":
376
- return { allowed: [globShared(slug, "shaping/*.md"), globShared(slug, "glossary.md")] };
377
- case "hammer":
378
- return { allowed: [globShared(slug, "REPORT.md"), `${local}/reports/**`] };
379
- case "coach":
380
- case "scan":
381
- case "research":
382
- // `scan` seeds the same files from the project on disk and `research` from the platform's
383
- // official documentation, instead of from L4 feedback; all three write only the knowledge
384
- // base, and the profile they suggest probes for stays the tech lead's to write (single
385
- // writer of the committed tier). Research is a source, not a verification: what it reads
386
- // is still a claim until the tech lead pins it at L0 and the kernel runs it.
387
- return { allowed: [relKnowledgeBase("*")] };
388
- default:
389
- return { allowed: [`${local}/**`] };
390
- }
391
- }
392
-
393
- // --- inspect(): the attempt loop's history ------------------------------------------------
394
- //
395
- // WHAT THIS REPLACED. The entire history mechanism used to be one read of one file:
396
- //
397
- // const prev = join(cwd, ".shapeup", slug, "t0", "verdicts", `r${round}-a${attempt-1}.json`);
398
- // digestedErrors = JSON.parse(readFileSync(prev)).discovered_tasks || [];
399
- //
400
- // Three bounds followed, and each is a way the loop can repeat itself. It read ONE ATTEMPT BACK,
401
- // not attempts 1…N−1 — so with the default `attempt_budget: 5`, attempt 4 could re-propose a change
402
- // that already failed at attempt 1. It read ONLY WITHIN THE CURRENT ROUND — round 2 attempt 1 began
403
- // blind to everything round 1 learned. And it carried ONLY AEGIS error triples: what was tried,
404
- // whether it helped, and whether the tree was kept were not in the envelope because no field held
405
- // them. Excluding the prior TRANSCRIPT is right and deliberate (zero-memory handoff). Excluding the
406
- // structured TRIAL RECORD is the opposite thing, and the two were dropped together.
407
- //
408
- // TOKEN DISCIPLINE. `compactTrial` strips stdout/stderr and truncates the digest to three triples.
409
- // Eight rows ≈ 600 tokens against an `attempt_budget` of 5 — cheaper than one re-proposed failed
410
- // change.
411
-
412
- /** How many trial rows an order carries. Matches `WorkOrderPayload.trial_history.maxItems`. */
413
- export const TRIAL_HISTORY_MAX = 8;
414
-
415
- /**
416
- * Reduce a trial ledger row to what a worker can act on.
417
- *
418
- * Drops the fields a worker cannot use — `artifact`, `sha256`, `at`, `tree_ref`, `baseline_trial`
419
- * — and truncates the digest. Keeps `schema_version` and `scope_id` so the row stays a valid
420
- * `TrialRow`: the registry defines each cross-boundary record ONCE, and a payload carrying a
421
- * near-TrialRow that needs its own definition would be exactly the ad-hoc field the registry
422
- * exists to prevent.
423
- *
424
- * @param {object} t - A row from `t0/trials.jsonl`.
425
- * @returns {{schema_version:number, trial:number, round:number, attempt:number, scope_id:string,
426
- * score:object, status:string, delta:string, digest:Array<object>}} The compacted row.
427
- */
428
- export function compactTrial(t) {
429
- return {
430
- schema_version: t.schema_version ?? 1,
431
- trial: t.trial, round: t.round, attempt: t.attempt, scope_id: t.scope_id,
432
- score: t.score, status: t.status, delta: t.delta ?? "",
433
- digest: (t.digest || []).slice(0, 3),
434
- };
435
- }
436
-
437
- /**
438
- * Select the trial rows an order should carry: this scope, this round or the one before it,
439
- * most recent {@link TRIAL_HISTORY_MAX} in write order.
440
- *
441
- * Crossing the round boundary is the point — a fix round that starts blind to the build round is
442
- * how the same failed change gets proposed twice.
443
- *
444
- * @param {Array<object>} trials - All rows read from `t0/trials.jsonl`.
445
- * @param {{scopeId?:string, round?:number}} sel - Scope id and current round to select against.
446
- * @returns {Array<object>} The compacted rows, oldest first; [] when nothing matches.
447
- */
448
- export function selectTrialHistory(trials, { scopeId, round } = {}) {
449
- return (trials || [])
450
- .filter((t) => !scopeId || t.scope_id === scopeId)
451
- .filter((t) => !round || t.round === round || t.round === round - 1)
452
- .slice(-TRIAL_HISTORY_MAX)
453
- .map(compactTrial);
454
- }
455
-
456
- /**
457
- * The stagnation term of the inner circuit breaker.
458
- *
459
- * `attempt_budget` counts attempts; it cannot see that the last two produced nothing. One term
460
- * joins it: `no_progress_k` consecutive non-`kept` trials ends the scope early and queues the
461
- * EXISTING GATE H proposal rather than blocking the round — composing with the three-level breaker
462
- * instead of adding a fourth. On a flailing scope this saves three of five attempts.
463
- *
464
- * @param {Array<object>} trials - This scope's trial rows, oldest first.
465
- * @param {number} [k=2] - Consecutive non-`kept` trials that trip the breaker.
466
- * @returns {{stagnant:boolean, streak:number, k:number}} The streak of trailing non-`kept` trials
467
- * and whether it has reached `k`.
468
- */
469
- export function stagnation(trials, k = 2) {
470
- let streak = 0;
471
- for (let i = (trials || []).length - 1; i >= 0; i--) {
472
- if (trials[i].status === "kept") break;
473
- streak++;
474
- }
475
- return { stagnant: streak >= k && k > 0, streak, k };
476
- }
477
-
478
- // --- the FAIL verdict's bugs, carried into the round that must fix them ----------------------
479
- //
480
- // WHY THE KERNEL OWNS THIS AND NOT THE ORCHESTRATOR (measured twice, two different ways).
481
- //
482
- // `WorkOrderPayload.bugs` is defined as "the EVAL report's bug entries for this task — touch
483
- // nothing else", and AGENTS.md states the regression rule as "bugs + full Test Surface of touched
484
- // UC". Nothing ever populated it. The orchestrator's first fix threaded the evaluator's findings
485
- // from memory into the build leg's `payload`, and it could not work, for two independent reasons:
486
- //
487
- // 1. A BUILD ORDER TAKES NO PAYLOAD FROM THE CALLER. Build legs override the compile line
488
- // (`compile --scope … --round … --attempt …`) because an order is addressed by scope, round
489
- // and attempt; only the generic fallback line serialises `--payload`. So the payload object
490
- // the orchestrator built for a build leg was constructed, filtered, and dropped on the floor.
491
- // 2. IT LIVED ONLY IN MEMORY. Round r's verdict is what round r+1 must act on, and a run killed
492
- // after EVAL and relaunched — the normal case here — starts round r+1 in a fresh process with
493
- // an empty variable. The verdict was on disk the whole time.
494
- //
495
- // Both vanish if the evidence is read where the order is written: the same read on a fresh round
496
- // and a resumed one, no process boundary to survive, and no model in the path — an LLM courier
497
- // transcribing a repro string is a repro string that no longer reproduces.
498
- //
499
- // The shape is also RICHER than the in-memory channel ever was: the ledgered verdict carries
500
- // severity, file:line, repro, expected and actual per bug, where the structured return carried
501
- // only `{id, criterion, evidence}`.
502
-
503
- /**
504
- * The previous round's cited defects, if that round returned FAIL.
505
- *
506
- * @param {string} cwd - Project root.
507
- * @param {string} slug - Feature slug.
508
- * @param {number} [round] - The round being compiled; round 1 has no predecessor.
509
- * @returns {Array<object>} Bug entries, minus any the adversarial check overturned. Empty for a
510
- * first round, a PASS, or an absent/unreadable result — a fix round with nothing to fix is a
511
- * normal build round, never an error.
512
- */
513
- export function verdictBugs(cwd, slug, round) {
514
- if (!round || round < 2) return [];
515
- const p = join(resultsDir(cwd, slug), `evaluate-r${round - 1}.json`);
516
- if (!existsSync(p)) return [];
517
- let v;
518
- try { v = JSON.parse(readFileSync(p, "utf8"))?.verdict; } catch { return []; }
519
- if (v?.overall !== "FAIL" || !Array.isArray(v.bugs)) return [];
520
- // A refuted acceptance criterion is one the judge withdrew. Re-dispatching it would send a
521
- // worker to "fix" behaviour that was found correct.
522
- const refuted = new Set(
523
- (Array.isArray(v.refuted) ? v.refuted : [])
524
- .flatMap((r) => [r?.id, r?.ac_id, r?.criterion, typeof r === "string" ? r : null])
525
- .filter(Boolean).map(String),
526
- );
527
- return v.bugs.filter((b) => !refuted.has(String(b?.id)) && !refuted.has(String(b?.criterion)));
528
- }
529
-
530
- /**
531
- * Who owns the row a bug or a failed criterion is about — by use case, not by file.
532
- *
533
- * A bug names a code location, and electing its owner from that location is right when one scope
534
- * writes the file. Screens are shared: on a live run two bugs about rows of UC-05 and UC-07 both
535
- * named screen files that three scopes could write, both were elected to a third scope that owned
536
- * neither row, and that scope could not write the row's check and escalated. The criterion says
537
- * which row it is; the row's use case says which scope owns it. Read from the name directly
538
- * (`UC-05 …`), else from the spec's use-case file whose Test Surface lists the row id.
539
- *
540
- * @param {string} cwd - Project root.
541
- * @param {string} slug - Feature slug.
542
- * @returns {function(string): (string|undefined)} Criterion text → owning scope_id, or undefined
543
- * when no use case can be read off it or no scope lists that use case.
544
- */
545
- export function rowOwner(cwd, slug) {
546
- const owners = new Map();
547
- for (const { contract, id } of readAllContracts(scopesDir(cwd, slug))) {
548
- const sid = contract?.scope_id || id;
549
- for (const uc of Array.isArray(contract?.use_cases) ? contract.use_cases : []) if (!owners.has(uc)) owners.set(uc, sid);
550
- }
551
- const rowUc = new Map();
552
- const ucDir = join(defaultSpecDir(cwd, slug), "usecases");
553
- let files = [];
554
- try { files = readdirSync(ucDir).filter((f) => /^UC-.*\.md$/.test(f)); } catch { /* no spec tree */ }
555
- for (const f of files) {
556
- const uc = f.replace(/\.md$/, "");
557
- let text = "";
558
- try { text = readFileSync(join(ucDir, f), "utf8"); } catch { continue; }
559
- for (const m of text.matchAll(/^\|\s*(TS-[A-Za-z0-9_-]+)\s*\|/gm)) if (!rowUc.has(m[1])) rowUc.set(m[1], uc);
560
- }
561
- return (criterion) => {
562
- const s = String(criterion ?? "");
563
- const uc = (s.match(/\bUC-[A-Za-z0-9_-]+/) || [])[0] || rowUc.get((s.match(/\bTS-[A-Za-z0-9_-]+/) || [])[0]);
564
- return uc ? owners.get(uc) : undefined;
565
- };
566
- }
567
-
568
- /**
569
- * The previous round's FAILED CRITERIA that the judge filed no bug for, as bug entries.
570
- *
571
- * A verdict carries two lists: the criteria it graded and the bugs it filed. Only the second
572
- * reached the next round, so a criterion graded FAIL — "no evidence: no check names this row", a
573
- * device row the judge could not observe — with no matching bug handed the fix round nothing to do.
574
- * Measured: three fix rounds in a row compiled with no bugs over a FAIL verdict, and the run ended
575
- * where it started. Every failed criterion is a thing the round must address, so each one without a
576
- * bug becomes one, addressed to the scope whose contract lists the use case the criterion names.
577
- *
578
- * @param {string} cwd - Project root.
579
- * @param {string} slug - Feature slug.
580
- * @param {number} [round] - The round being compiled.
581
- * @returns {Array<object>} `{criterion, severity, expected, actual, source:"criterion", scope_id?}`
582
- * per uncovered FAIL criterion; [] for round 1, a PASS, or an unreadable result.
583
- */
584
- export function criteriaBugs(cwd, slug, round) {
585
- if (!round || round < 2) return [];
586
- const p = join(resultsDir(cwd, slug), `evaluate-r${round - 1}.json`);
587
- if (!existsSync(p)) return [];
588
- let v;
589
- try { v = JSON.parse(readFileSync(p, "utf8"))?.verdict; } catch { return []; }
590
- if (v?.overall !== "FAIL" || !Array.isArray(v.criteria)) return [];
591
- const filed = (Array.isArray(v.bugs) ? v.bugs : []).map((b) => String(b?.criterion ?? ""));
592
- const refuted = new Set((Array.isArray(v.refuted) ? v.refuted : [])
593
- .flatMap((r) => [r?.id, r?.ac_id, r?.criterion, typeof r === "string" ? r : null]).filter(Boolean).map(String));
594
- const owners = new Map();
595
- for (const { contract, id } of readAllContracts(scopesDir(cwd, slug))) {
596
- const sid = contract?.scope_id || id;
597
- for (const uc of Array.isArray(contract?.use_cases) ? contract.use_cases : []) if (!owners.has(uc)) owners.set(uc, sid);
598
- }
599
- const out = [];
600
- for (const c of v.criteria) {
601
- if (c?.verdict !== "FAIL" || typeof c.criterion !== "string") continue;
602
- const name = c.criterion;
603
- if (refuted.has(name)) continue;
604
- if (filed.some((f) => f && (f === name || f.includes(name) || name.includes(f)))) continue;
605
- const uc = (name.match(/\bUC-[A-Za-z0-9_-]+/) || [])[0];
606
- const owner = uc ? owners.get(uc) : undefined;
607
- out.push({
608
- criterion: name,
609
- severity: "major",
610
- expected: "the criterion is met, with evidence the judge can cite",
611
- actual: String(c.evidence ?? "graded FAIL").slice(0, 400),
612
- source: "criterion",
613
- ...(owner ? { scope_id: owner } : {}),
614
- });
615
- }
616
- return out;
617
- }
618
-
619
- /**
620
- * Stamp each verdict bug with the scope that owns the row it names, where one can be read. A bug
621
- * that already carries a scope, or names no row a scope owns, is left to the file election.
622
- *
623
- * @param {Array<object>} bugs - Verdict and criterion bugs.
624
- * @param {function(string): (string|undefined)} owner - From {@link rowOwner}.
625
- * @returns {Array<object>} The same bugs, `scope_id` added where the row's owner is known.
626
- */
627
- export function byRow(bugs, owner) {
628
- return (bugs || []).map((b) => {
629
- if (b?.scope_id) return b;
630
- const sid = owner(b?.criterion);
631
- return sid ? { ...b, scope_id: sid } : b;
632
- });
633
- }
634
-
635
- /**
636
- * The previous round's RED BUILD GATE, as bug entries the fix round can act on.
637
- *
638
- * THE JUDGE IS NOT THE ONLY SOURCE OF A FAIL. `verify build` runs the feature's build and its
639
- * launch probe once per round, before EVAL, and a red gate ends the round with no verdict — there is
640
- * nothing to grade in an app that does not compile or start. But a round that ends without a
641
- * verdict left the next round with no `payload.bugs`, so it compiled as a plain `execute`, every
642
- * worker re-ran its fixtures, found them green (they never tested the build) and reported done —
643
- * the identical loop the ledgered verdict was threaded through {@link verdictBugs} to break.
644
- *
645
- * So a failing step becomes a bug: the criterion is the command that must exit 0, the locator is
646
- * every file the tool's own output names that exists in the tree, and the evidence is the output's
647
- * tail. Addressed by {@link bugsForScope} exactly like a judged defect — the scope whose substrate
648
- * holds the cited file gets it, and a failure citing no file goes to every scope marked `unowned`.
649
- *
650
- * @param {string} cwd - Project root.
651
- * @param {string} slug - Feature slug.
652
- * @param {number} [round] - The round being compiled; round 1 has no predecessor.
653
- * @returns {Array<object>} One entry per failing step of the previous round's latest gate
654
- * artifact; empty when the gate did not run, was green, or this is round 1.
655
- */
656
- export function buildBugs(cwd, slug, round) {
657
- if (!round || round < 2) return [];
658
- const gate = latestRoundBuild(cwd, slug, round - 1);
659
- if (!gate || gate.overall !== "red") return [];
660
- const out = [];
661
- for (const step of gate.steps || []) {
662
- if (step.skipped || step.pass) continue;
663
- const text = `${step.stdout_tail || ""}\n${step.stderr_tail || ""}`;
664
- const files = outputPaths(text, cwd);
665
- out.push({
666
- id: `BUILD-r${round - 1}-${step.kind}`,
667
- severity: "blocker",
668
- source: "verify build",
669
- criterion: `${step.kind} exits 0: ${step.cmd}`,
670
- location: files.join(", "),
671
- expected: "exit 0",
672
- actual: step.error ? `did not run: ${step.error}` : `exit ${step.exit}`,
673
- evidence: (step.stderr_tail || step.stdout_tail || "").slice(-2000),
674
- digest: Array.isArray(gate.discovered_tasks) ? gate.discovered_tasks.slice(0, 8) : [],
675
- });
676
- }
677
- return out;
678
- }
679
-
680
- /**
681
- * The project files a build or launch log names, repo-relative and de-duplicated.
682
- *
683
- * Wider than {@link bugLocations} in one way and narrower in another: it accepts absolute paths
684
- * (compilers print them) and strips the project root off; and it keeps only paths that EXIST under
685
- * the project, because a tool's own stack frames and cache paths look exactly like file citations
686
- * and would address the bug to nobody. First-seen order.
687
- *
688
- * @param {string} text - Raw log text.
689
- * @param {string} cwd - Project root.
690
- * @returns {string[]} Repo-relative POSIX paths.
691
- */
692
- export function outputPaths(text, cwd) {
693
- const out = [];
694
- const root = resolve(cwd);
695
- for (const m of String(text || "").matchAll(/(?:^|[\s(,\[:"'`])((?:\/|\.\/)?[\w@][\w.\/-]*\.[A-Za-z][A-Za-z0-9]{0,5})(?::\d+)?/gm)) {
696
- let p = m[1].replace(/^\.\//, "");
697
- if (p.startsWith("/")) {
698
- const rel = relative(root, p);
699
- if (!rel || rel.startsWith("..")) continue;
700
- p = rel.split(sep).join("/");
701
- }
702
- if (out.includes(p)) continue;
703
- if (!existsSync(join(root, p))) continue;
704
- out.push(p);
705
- }
706
- return out;
707
- }
708
-
709
- /**
710
- * Every repo-relative file a bug is cited against.
711
- *
712
- * A LOCATOR NAMES MORE THAN ONE SITE, ROUTINELY. The judge writes what it found, and what it finds
713
- * is often the same defect at several lines, sometimes across files:
714
- * `"bin/todo.js:53, bin/todo.js:85"`, or `"lib/parse-index.js:5, :13, :21 (rendered by
715
- * bin/todo.js:66)"`. A parser that accepts only a lone `file:line` returns nothing for those, and
716
- * "nothing" routes the bug to every scope as unowned — the safe direction, but it throws away an
717
- * address the judge did supply. On the measured round-1 verdict that was two of five bugs.
718
- *
719
- * @param {object} bug - One bug entry.
720
- * @returns {string[]} Distinct paths, `:line` suffixes and `./` prefixes stripped, in first-seen
721
- * order. Empty when the entry carries no locator this can read.
722
- */
723
- export function bugLocations(bug) {
724
- const raw = String(bug?.location ?? bug?.file ?? "").trim();
725
- // A path-looking token: at least one dot-extension, and no whitespace. The extension is what
726
- // keeps prose out ("exit 1", "Node 20") without needing to know the project's layout.
727
- const out = [];
728
- for (const m of raw.matchAll(/(?:^|[\s(,[])([\w@][\w./-]*\.[A-Za-z][A-Za-z0-9]{0,5})(?::\d+)?/g)) {
729
- const p = m[1].replace(/^\.\//, "");
730
- if (!out.includes(p)) out.push(p);
731
- }
732
- return out;
733
- }
734
-
735
- /**
736
- * Elect the ONE scope that should fix a defect cited against a given file.
737
- *
738
- * OWNERSHIP IS BY SUBSTRATE, because that is what the sandbox enforces: a scope is exactly the set
739
- * of files its worker may write, so a scope whose substrate excludes the cited line cannot fix it
740
- * however well it understands the bug. The substrate a scope may write is `allowed ∪ shared` — the
741
- * same union `sandbox-guard` composes at the fence — so a path declared ONLY in a contract's
742
- * `shared` list is a file that scope may legitimately write, and the election must see it too —
743
- * filtering on `allowed` alone elects no one for a shared-only path, and `bugsForScope` then reads
744
- * that null as "no scope owns this" and fans the bug out to every scope instead of the one or two
745
- * that declared it.
746
- *
747
- * BUT A MATCH IS NOT AN ELECTION. An entry point is routinely SHARED — on the measured run
748
- * `bin/todo.js` sits in five scopes' substrate at once — so "address it to every scope that
749
- * matches" hands the same one-line fix to five workers building concurrently against one file.
750
- * That is a write race the harness sets up itself, and four of the five fixes are waste even when
751
- * it resolves. So: prefer a scope that owns the file EXCLUSIVELY (allowed, not shared), and among
752
- * equals — every remaining candidate declares it shared, exclusive or not — take the lowest scope
753
- * id: a rule that needs no coordination to agree with itself, since each leg compiles its own
754
- * order in its own process.
755
- *
756
- * @param {string} path - Repo-relative file the bug cites.
757
- * @param {Array<{scope_id:string, allowed:string[], shared:string[]}>} scopes - Every scope.
758
- * @returns {string|null} The elected scope id, or null when no scope may write that file.
759
- */
760
- export function electOwner(path, scopes) {
761
- const can = (scopes || []).filter((s) => matchesAny(path, s.allowed) || matchesAny(path, s.shared || []));
762
- if (!can.length) return null;
763
- const exclusive = can.filter((s) => matchesAny(path, s.allowed) && !matchesAny(path, s.shared || []));
764
- return (exclusive.length ? exclusive : can).map((s) => s.scope_id).sort()[0];
765
- }
766
-
767
- /**
768
- * Address each bug to the scope that must fix it.
769
- *
770
- * AN UNOWNED BUG GOES TO EVERYONE, MARKED. A cited defect matching no scope's substrate — or
771
- * carrying no locator this can read — has no owner, and dropping it silently is precisely how a
772
- * judged defect survives a fix round to reappear in the next verdict. Better one scope reads a bug
773
- * it turns out not to own than the run forgets a defect it already paid to find.
774
- *
775
- * @param {Array<object>} bugs - The previous verdict's bugs (see {@link verdictBugs}).
776
- * @param {string|undefined} scopeId - The scope being compiled for.
777
- * @param {Array<{scope_id:string, allowed:string[], shared:string[]}>} scopes - Every scope.
778
- * @returns {Array<object>} The subset this order should carry; unowned entries get `unowned: true`.
779
- */
780
- export function bugsForScope(bugs, scopeId, scopes) {
781
- const out = [];
782
- for (const b of bugs || []) {
783
- if (b?.scope_id) {
784
- if (b.scope_id === scopeId) out.push(b);
785
- continue;
786
- }
787
- const owners = bugLocations(b).map((p) => electOwner(p, scopes)).filter(Boolean);
788
- if (owners.includes(scopeId)) out.push(b);
789
- else if (!owners.length) out.push({ ...b, unowned: true });
790
- }
791
- return out;
792
- }
793
-
794
- /**
795
- * Every scope's write substrate, for the election in {@link electOwner}.
796
- *
797
- * @param {string} cwd - Project root.
798
- * @param {string} slug - Feature slug.
799
- * @returns {Array<{scope_id:string, allowed:string[], shared:string[]}>} One entry per readable
800
- * contract; `[]` when the scopes directory is absent or unreadable.
801
- */
802
- export function scopeSubstrates(cwd, slug) {
803
- const dir = scopesDir(cwd, slug);
804
- let files;
805
- try { files = readdirSync(dir).filter((f) => f.endsWith(".md") || f.endsWith(".json")); } catch { return []; }
806
- const out = [];
807
- for (const f of files) {
808
- const c = readContract(join(dir, f), SCOPE_CONTRACT)?.contract;
809
- if (!c?.scope_id || !Array.isArray(c.allowed_file_substrate)) continue;
810
- out.push({
811
- scope_id: c.scope_id,
812
- allowed: c.allowed_file_substrate,
813
- shared: Array.isArray(c.shared_substrate) ? c.shared_substrate : [],
814
- });
815
- }
816
- return out;
817
- }
818
-
819
- // --- the T0 artifacts the judge must cite ----------------------------------------------------
820
- //
821
- // WHY THE KERNEL DERIVES THEM. spec-evaluator treats a scoped spec whose order lists no T0 artifact
822
- // as NOT gradeable and returns `failed` without grading a criterion. The precondition is right —
823
- // its verdict must cite a T0 artifact it re-hashed itself — and nothing met it: the list was once
824
- // assembled by an orchestrator courier from the paths each scope reported, and was lost when the
825
- // orchestrator became a workflow script that passes only `{dimensions, run_cmd, round}`. Evaluators
826
- // that went looking on disk graded anyway; one that followed its contract refused, and the run
827
- // aborted at L3 over a round whose every scope was green.
828
- //
829
- // Derived here for the reason `bugs` is: this is the one line every lane compiles through, and the
830
- // evidence is already on disk. A caller could not rebuild the list from filenames in any case —
831
- // verdict files are addressed by round, attempt and trial, never by scope, so the scope lives only
832
- // inside each body, which is what `probe t0` reads.
833
-
834
- /**
835
- * The green T0 verdict each scope contract holds for a round — an evaluate order's `t0_artifacts`.
836
- *
837
- * @param {string} cwd - Project root.
838
- * @param {string} slug - Feature slug.
839
- * @param {number} [round] - The round being evaluated. Omitted, each scope's newest green verdict
840
- * of any round — a standalone evaluation has no round.
841
- * @returns {{artifacts: string[], missing: string[]}} Repo-relative verdict paths in scope-id
842
- * order, one per scope that has one; and the scopes that have none. Both empty on an unscoped spec.
843
- */
844
- export function t0ArtifactsFor(cwd, slug, round) {
845
- const artifacts = [];
846
- const missing = [];
847
- for (const { contract, id } of readAllContracts(scopesDir(cwd, slug))) {
848
- const scopeId = contract?.scope_id || id;
849
- const { green, path } = greenVerdict(cwd, slug, scopeId, round);
850
- if (green) artifacts.push(relLocal(slug, "t0", "verdicts", basename(path)));
851
- else missing.push(scopeId);
852
- }
853
- return { artifacts, missing };
854
- }
855
-
856
- // --- checks a scope rewrote between a failing trial and a passing one --------------------------
857
-
858
- /**
859
- * Every revised check any trial of the round recorded, with the scope it belongs to.
860
- *
861
- * Read across all of the round's trials rather than off the cited verdict alone: a check rewritten
862
- * on attempt 2 and re-run unchanged on attempt 3 leaves no mark on attempt 3's verdict, which is the
863
- * one the judge cites.
864
- *
865
- * @param {string} cwd - Project root.
866
- * @param {string} slug - Feature slug.
867
- * @param {number} [round] - The round being evaluated; omitted, every round.
868
- * @returns {Array<{scope_id:string, id:string, file:string}>} Deduplicated by scope, id and file.
869
- */
870
- export function revisedChecksFor(cwd, slug, round) {
871
- const seen = new Set();
872
- const out = [];
873
- for (const tr of readTrials(trials(cwd, slug))) {
874
- if (round != null && tr.round !== round) continue;
875
- if (!tr.artifact) continue;
876
- let v;
877
- try { v = JSON.parse(readFileSync(join(localRoot(cwd, slug), tr.artifact), "utf8")); } catch { continue; }
878
- for (const r of Array.isArray(v?.revised_checks) ? v.revised_checks : []) {
879
- const key = `${tr.scope_id}\u0000${r.id}\u0000${r.file}`;
880
- if (seen.has(key)) continue;
881
- seen.add(key);
882
- out.push({ scope_id: tr.scope_id, id: r.id, file: r.file });
883
- }
884
- }
885
- return out;
886
- }
887
-
888
- // --- the launch evidence the judge grades `[ui]` rows against ---------------------------------
889
- //
890
- // WHY THE KERNEL DERIVES IT. A `[ui]` criterion is graded on the RUNNING app, and the evaluator's
891
- // contract for getting one is `payload.run_cmd`; absent, an orchestrated evaluator escalates rather
892
- // than guess. The ledger's `run_cmd` is what the round build gate runs FIRST, as the build — on a
893
- // toolchain where building and launching are different acts it is a build, and the ledger carries
894
- // none at all when nobody pinned one. Meanwhile the gate had already run the project's launch probe
895
- // (install, start, assert the first screen) moments earlier, recorded its output, and left the
896
- // app up. None of that reached the order: the gate's artifact was forwarded only when RED, as the
897
- // next round's bug list, so a green launch was proven and then unavailable to the one reader who
898
- // needed it. Every `[ui]` row was graded "no evidence on the running app" over a build that launched.
899
- //
900
- // Two fields, both derived from disk, both optional, both absent when there is nothing to say —
901
- // the same non-regression rule as `t0_artifacts`: a project that declares no launch probe compiles
902
- // exactly the order it always did.
903
-
904
- /**
905
- * What an evaluate order should carry about launching the app.
906
- *
907
- * @param {string} cwd - Project root.
908
- * @param {string} slug - Feature slug.
909
- * @param {number} [round] - The round being evaluated. Omitted, the newest gate artifact of any
910
- * round — a standalone evaluation has no round.
911
- * @returns {{build_gate?: string, launch_cmd?: string}} `build_gate`: repo-relative path of this
912
- * run's newest round build gate artifact (each step's exit and output tail). `launch_cmd`: the
913
- * profile's launch probe. A key is omitted, never null, when its source is absent.
914
- */
915
- export function launchEvidenceFor(cwd, slug, round) {
916
- const out = {};
917
- const gate = latestRoundBuildFile(cwd, slug, round);
918
- if (gate) out.build_gate = relLocal(slug, "build", basename(gate.path));
919
- const cmd = launchProbeFor(cwd, slug);
920
- if (cmd) out.launch_cmd = cmd;
921
- return out;
922
- }
923
-
924
- // --- the dimensions the judge grades ------------------------------------------------------------
925
- //
926
- // WHY THE KERNEL RESOLVES THEM. The judge's own rule is that an explicit `dimensions` list overrides
927
- // its auto-enable, and every orchestrated order used to carry one: the ledger's default
928
- // `[spec-conformance]`, which nobody chose. So a spec whose every use case carried a Test Surface
929
- // was never graded by `test-surface-conformance`, `completeness` never ran over Invariants, and
930
- // `tdd-surface` — always-on in the judge's registry — was off in every orchestrated run. Resolving
931
- // the set here, from the spec, with the judge's rules, makes it a fact on the order rather than a
932
- // default: the order file is the record of what was graded, and GATE L4 reads it back.
933
-
934
- /** The shipped dimensions, in the judge's load order. Security and performance ship disabled. */
935
- export const SHIPPED_DIMENSIONS = ["spec-conformance", "tdd-surface", "integration", "completeness",
936
- "test-surface-conformance", "security", "performance"];
937
-
938
- /**
939
- * The dimension set an evaluate order grades when the run named none — the judge registry's rules,
940
- * applied to the spec on disk.
941
- *
942
- * @param {string} cwd - Project root.
943
- * @param {string} slug - Feature slug (reads the board for task variants).
944
- * @param {(string|null)} specDir - The spec folder, relative to `cwd`; null → no use cases read.
945
- * @returns {string[]} `spec-conformance` and `tdd-surface` always; `integration` when a board task is
946
- * a `.be`/`.e2e` variant; `completeness` when a use case has `## Invariants`; `test-surface-conformance`
947
- * when one has `## Test Surface`. In {@link SHIPPED_DIMENSIONS} order.
948
- */
949
- export function resolveDimensions(cwd, slug, specDir) {
950
- const on = new Set(["spec-conformance", "tdd-surface"]);
951
- let tasks = [];
952
- try { tasks = readBoard(cwd, slug); } catch { /* an unreadable board names no variant */ }
953
- if (tasks.some((t) => /\.(be|e2e)$/i.test(String(t.id || "")))) on.add("integration");
954
- if (specDir) {
955
- const dir = join(resolve(cwd, specDir), "usecases");
956
- let files = [];
957
- try { files = readdirSync(dir).filter((f) => /^UC-.*\.md$/i.test(f)); } catch { /* no use cases */ }
958
- for (const f of files) {
959
- let body = "";
960
- try { body = readFileSync(join(dir, f), "utf8"); } catch { continue; }
961
- if (/^##\s+Invariants\b/m.test(body)) on.add("completeness");
962
- if (/^##\s+Test Surface\b/m.test(body)) on.add("test-surface-conformance");
963
- }
964
- }
965
- return SHIPPED_DIMENSIONS.filter((d) => on.has(d));
966
- }
967
-
968
- /**
969
- * Assemble a WorkOrder envelope. Pure given its inputs — the CLI wrapper does the disk reads.
970
- * @param {object} opts - The order inputs (destructured):
971
- * @param {string} opts.slug - Feature slug (names the order_id and substrate roots).
972
- * @param {string} opts.worker - Target worker skill (e.g. "task-executor").
973
- * @param {string} [opts.mode="orchestrated"] - Dispatch mode written onto the order.
974
- * @param {string} [opts.operation] - Operation the worker runs (drives the substrate template).
975
- * @param {number} [opts.round] - Round number (for the order_id suffix + attempt pairing).
976
- * @param {number} [opts.attempt] - Attempt number within the round.
977
- * @param {object} [opts.scope] - Scope contract, carried as payload.scope_contract + substrate source.
978
- * @param {Array<object>} [opts.tasks] - Task entries to include in the payload.
979
- * @param {Array<{id:string,answer:string}>} [opts.decisions] - This scope's advisor answers.
980
- * @param {Array<object>} [opts.digestedErrors] - Prior-attempt AEGIS triples (payload.digested_errors).
981
- * @param {Array<object>} [opts.trialHistory] - Compacted trial rows (payload.trial_history).
982
- * @param {Array<object>} [opts.bugs] - The previous round's cited defects (payload.bugs).
983
- * @param {string} [opts.testCmd] - Verify command, recorded under payload.verify.test_cmd.
984
- * @param {object} [opts.payloadExtra] - Extra payload fields merged last (spec_folder, feature, …).
985
- * @param {string} [opts.specDir] - Spec directory, threaded into the substrate template.
986
- * @param {object} [opts.interaction] - Interaction flags (e.g. {pause_gates}).
987
- * @param {string} [opts.runId] - The run key (`mintRunId` in `lib/paths.mjs`); omitted when no receipt is readable.
988
- * @param {string} [opts.compiledAt] - ISO compile time; omitted rather than invented.
989
- * @returns {object} A WorkOrder: {schema_version, order_id ("<slug>/<suffix>"), run_id?,
990
- * compiled_at?, worker, mode, operation?, interaction?, substrate (from {@link substrateFor}),
991
- * payload{…}}. A coachable worker ({@link COACHABLE}) also gets payload.kb_rules_path. Not validated here — the CLI
992
- * validates before writing.
993
- */
994
- export function compileOrder({
995
- slug, worker, mode = "orchestrated", operation, round, attempt,
996
- scope, tasks, decisions, digestedErrors, trialHistory, bugs, testCmd, payloadExtra, specDir, interaction,
997
- runId, compiledAt,
998
- }) {
999
- // A build order's id carries its SCOPE. Without it, `r<round>-a<attempt>` is the same name for
1000
- // every scope in a round, so scope 2's order file overwrites scope 1's the moment it is compiled
1001
- // — measured on the kill/resume probe, where it made `orders/ minus results/` read EMPTY on a run
1002
- // that was re-dispatching a completed phase. The contract row watching that property therefore
1003
- // passed on the exact failure it exists to catch. ``harness verify t0``'s verdict artifacts have always
1004
- // been self-identifying this way (`r<R>-a<A>-t<T>.json`, `wx`-created); orders now match, so
1005
- // `orders/` is an audit trail of dispatches rather than a rolling buffer of the last one.
1006
- //
1007
- // The shape stays `<slug>/<suffix>`: every consumer splits on the FIRST "/" (`harness reduce ingest`
1008
- // reads [0] as the slug and [1] as the file stem), and a scope id is already filename-safe
1009
- // because it is a scope contract's own basename.
1010
- // `scope` arrives as the PARSED contract object (the CLI reads the .md and embeds it), so the id
1011
- // comes from its own `scope_id` field; a caller that passes a path instead still works. The id is
1012
- // lowercased and stripped to the character class `work-order.schema.json` allows after the "/" —
1013
- // an order that cannot pass its own schema is refused before it is written, and a naming
1014
- // improvement must not be able to cause that.
1015
- const rawScopeId = scope && typeof scope === "object"
1016
- ? scope.scope_id
1017
- : (scope ? String(scope).split("/").pop().replace(/\.(md|json)$/, "") : null);
1018
- const scopeId = String(rawScopeId || "").toLowerCase().replace(/[^a-z0-9.-]/g, "-").replace(/^[^a-z0-9]+/, "");
1019
- const buildSuffix = round && attempt ? (scopeId ? `${scopeId}-r${round}-a${attempt}` : `r${round}-a${attempt}`) : null;
1020
- // A non-BUILD order was suffixed by operation+round alone, with no per-leg discriminator — so two
1021
- // concurrent non-BUILD legs of the SAME operation and round, dispatched for different scopes
1022
- // (e.g. two scopes both running `evaluate` in the same round), compiled to the identical suffix
1023
- // and the second write clobbered the first order file on disk while the run read green. `scopeId`
1024
- // is already derived above for any operation, not only BUILD; folding it in here — only when a
1025
- // scope was actually passed — closes that collision. Operation-level dispatches (orient, analyze,
1026
- // wire, map-scopes, evaluate, hunt, hammer) never pass a scope, so `scopeId` is empty for them and
1027
- // this reduces to the unchanged `${operation}-r${round}` / `${operation}` shape.
1028
- const scopedRoundSuffix = scopeId
1029
- ? (round ? `${operation}-${scopeId}-r${round}` : `${operation}-${scopeId}`)
1030
- : (round ? `${operation}-r${round}` : operation);
1031
- const suffix = buildSuffix || scopedRoundSuffix;
1032
- const order = {
1033
- schema_version: 1,
1034
- order_id: `${slug}/${suffix}`,
1035
- // WHERE THE KERNEL IS, AS A PATH A PERMISSION RULE CAN MATCH. Workers were told to run
1036
- // `node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" …`. In a headless session that variable is not
1037
- // expanded for them, and a command carrying an unexpanded variable is refused before any rule is
1038
- // consulted ("Contains expansion") — so every kernel query a worker's contract requires was
1039
- // refused, and workers reported it as "not permitted". The absolute path of this very file's
1040
- // kernel is known here, and quoted it matches the grant `init` writes.
1041
- kernel: join(HERE, "harness.mjs"),
1042
- // THE TWO ANALYTIC FIELDS, and why they are on the ORDER rather than the result.
1043
- //
1044
- // `order_id` identifies a dispatch within a run and repeats across runs of the same slug, so
1045
- // the dispatch record needed a run key to be groupable at all. It is stamped here because this
1046
- // is the one place every lane passes through — the same reason the active-order pointer is
1047
- // published here — so the workflow lane, `--tiny`, the prose round loop and a standalone
1048
- // dispatch all carry it without four separate stamps to keep in step.
1049
- //
1050
- // The RESULT deliberately gets neither. It is written by the worker, and a field a worker has
1051
- // to remember to copy is a field that goes missing under exactly the conditions you most want
1052
- // the record: the run that went wrong. Results join to orders on `order_id`, which they already
1053
- // echo and `validate-envelope` already checks — so the key reaches the result leg through a
1054
- // join that is enforced, instead of through a worker's cooperation.
1055
- ...(runId ? { run_id: runId } : {}),
1056
- ...(compiledAt ? { compiled_at: compiledAt } : {}),
1057
- worker,
1058
- mode,
1059
- ...(operation ? { operation } : {}),
1060
- ...(interaction ? { interaction } : {}),
1061
- substrate: substrateFor(operation, { slug, specDir, scope, ownStem: suffix }),
1062
- payload: {
1063
- ...(scope ? { scope_contract: scope } : {}),
1064
- ...(tasks?.length ? { tasks } : {}),
1065
- ...(decisions?.length ? { decisions } : {}),
1066
- ...(digestedErrors?.length ? { digested_errors: digestedErrors } : {}),
1067
- ...(trialHistory?.length ? { trial_history: trialHistory } : {}),
1068
- // Before `payloadExtra`, so an explicit `--payload '{"bugs":…}'` still wins: an operator
1069
- // hand-addressing a defect outranks the derivation.
1070
- ...(bugs?.length ? { bugs } : {}),
1071
- ...(testCmd ? { verify: { test_cmd: testCmd, env: [] } } : {}),
1072
- ...(payloadExtra || {}),
1073
- },
1074
- };
1075
- // The coachable set — every worker that reads `shapeup/knowledge-base/<worker>.md` at the top
1076
- // of its run. Guidance only: a rule here steers craft and never resolves, skips or reorders a
1077
- // gate, and never widens a substrate (the sandbox hook reads the order, not the KB). The judge
1078
- // (spec-evaluator) and the census (scope-hammer) are excluded on purpose: coaching the judge
1079
- // makes a second grader, and the hammer's ownership claims must come from `probe owner`.
1080
- if (COACHABLE.has(worker)) order.payload.kb_rules_path = relKnowledgeBase(worker);
1081
- return order;
1082
- }
1083
-
1084
- // ---------------------------------------------------------------------------
1085
- /** The typed argv contract (see `./lib/argv.mjs`). */
1086
- export const ARGV_SPEC = {
1087
- usage: "harness.mjs compile (--scope <contract.json> --round N --attempt M | --task TASK-NNN --slug <slug> " +
1088
- "| --next --slug <slug> | --operation <op> --slug <slug>) [--worker <skill>] [--payload '<json>'] " +
1089
- "[--spec <dir>] [--test-cmd \"<cmd>\"] [--cwd <dir>] [--pause-gates]",
1090
- _: { arity: 0, max: 0, name: "(no positional operands)" },
1091
- cwd: { type: "path" },
1092
- slug: { type: "str" },
1093
- spec: { type: "path" },
1094
- scope: { type: "path" },
1095
- round: { type: "int", min: 1 },
1096
- attempt: { type: "int", min: 1 },
1097
- operation: { type: "str" },
1098
- worker: { type: "str" },
1099
- task: { type: "str" },
1100
- next: { type: "flag" },
1101
- payload: { type: "json" },
1102
- "test-cmd": { type: "str" },
1103
- "pause-gates": { type: "flag" },
1104
- };
1105
-
1106
- /**
1107
- * Compile a WorkOrder for one dispatch and publish the substrate pointer that fences it.
1108
- *
1109
- * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
1110
- * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
1111
- * call `process.exit()` with the subcommand's documented code rather than returning.
1112
- */
1113
- export async function cli(rawArgv) {
1114
- const argv = runArgs(ARGV_SPEC, rawArgv);
1115
- /**
1116
- * Read a parsed flag's value, normalising "absent" to null (the shape the body below expects).
1117
- * @param {string} name - Flag name without leading dashes.
1118
- * @returns {*} The parsed value, or null when the flag was not given.
1119
- */
1120
- const flag = (name) => argv[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] ?? null;
1121
- /**
1122
- * Test whether a boolean flag was given.
1123
- * @param {string} name - Flag name without leading dashes.
1124
- * @returns {boolean} True when `--<name>` appears in argv.
1125
- */
1126
- const has = (name) => !!flag(name);
1127
- const cwd = resolve(flag("cwd") || process.cwd());
1128
-
1129
- let slug = flag("slug");
1130
- let scope = null;
1131
- let specDir = flag("spec") || null;
1132
- const scopePath = flag("scope");
1133
- if (scopePath) {
1134
- // Markdown on disk, JSON on the wire (ADR-0001): the contract is parsed here and the resulting
1135
- // OBJECT is embedded in payload.scope_contract, so the envelope and its schema are unchanged.
1136
- const abs = resolve(cwd, scopePath);
1137
- const found = readContract(abs, SCOPE_CONTRACT);
1138
- if (!found) { console.error(`compile-order: no scope contract at ${abs} (.md or .json)`); process.exit(2); }
1139
- scope = found.contract;
1140
- // shapeup/<slug>/scopes/<id>.md → slug
1141
- if (!slug) slug = basename(dirname(dirname(abs)));
1142
- }
1143
- if (!slug) { console.error("compile-order: --slug (or a --scope path it derives from) is required"); process.exit(2); }
1144
- if (!specDir && existsSync(defaultSpecDir(cwd, slug))) {
1145
- specDir = relShared(slug, "spec");
1146
- }
1147
-
1148
- const round = flag("round");
1149
- const attempt = flag("attempt");
1150
-
1151
- // AN ATTEMPT MAY NOT OPEN OVER AN UNANSWERED ONE — the write half of the attested-channel rule.
1152
- //
1153
- // `--attempt` is a flag: the CALLER picks the number, and nothing here used to check that the
1154
- // previous one had come back. Measured on a consumer run: attempt 2 was compiled and T0-verified
1155
- // while attempt 1 was still in flight, so the round graded the tree attempt 1 was still writing,
1156
- // counted the attempt, and stopped at GATE H with most of its budget and clock unspent. An order
1157
- // and a T0 verdict are both writable by the very scope being judged; only a dispatch receipt, a
1158
- // leg row or a WorkResult attests that work actually happened.
1159
- //
1160
- // FAILS OPEN, NEVER CLOSED, unless the bad state is positively proven. The proof required is the
1161
- // receipts ledger EXISTING while carrying no row for the previous attempt: a lane that does not
1162
- // attest dispatches at all (no ledger on disk) cannot be judged by this rule and is waved
1163
- // through, so `--tiny`, a prose round loop and a standalone build are untouched.
1164
- //
1165
- // THE RUN KEY IS PART OF THE QUESTION. `attemptEvidence` matches a receipt to an attempt by
1166
- // `order_id` AND `run_id` — deliberately, so a previous run's receipt over the same slug cannot
1167
- // answer for this one — and this call used to omit the key. With no key nothing matches, so every
1168
- // previous attempt read "unattested" and every attempt 2 was refused as unanswered, on every run,
1169
- // with the receipt, the leg row and the result all on disk. Measured on a live run: one attempt
1170
- // spent of five, the census reading it spent, and the gate refusing to open the next one three
1171
- // times over. The ratchet was one attempt deep for as long as the gate has existed. A run with no
1172
- // readable receipt has no key to ask with and is waved through, like a lane with no ledger.
1173
- const myRunId = (scope?.scope_id && round && attempt > 1) ? readRunId(cwd, slug) : null;
1174
- if (scope?.scope_id && round && attempt > 1 && myRunId) {
1175
- const receiptsPath = dispatchReceipts(cwd, slug);
1176
- if (existsSync(receiptsPath)) {
1177
- const prev = attemptEvidence(
1178
- cwd, slug, scope.scope_id, round, attempt - 1,
1179
- readReceipts(receiptsPath), readLegs(legLedger(cwd, slug)), myRunId,
1180
- );
1181
- if (prev.state !== "spent") {
1182
- const why = prev.state === "unattested"
1183
- ? "no dispatch receipt was ever written for it"
1184
- : "it was dispatched but has neither a leg-completion row nor a WorkResult";
1185
- console.error(
1186
- `compile-order: refusing to open attempt ${attempt} for "${scope.scope_id}" in round ${round} — ` +
1187
- `attempt ${attempt - 1} (${prev.orderId}) is unanswered: ${why}. Grading a tree the previous ` +
1188
- `attempt may still be writing counts an attempt that never ran, and spends a budget on work ` +
1189
- `nobody did. Wait for it to return, or record its outcome, before opening the next one.`);
1190
- process.exit(3);
1191
- }
1192
- }
1193
- }
1194
-
1195
- // The fix round's inbound evidence. Derived here, from the ledgered verdict, for every lane —
1196
- // the workflow, `--tiny`, the prose round loop and a standalone `/build` all compile through
1197
- // this line, and none of them can pass a payload to a build order (see the banner above).
1198
- // Two sources, one channel: the judge's cited defects and the build gate's failing steps.
1199
- const bugs = scope
1200
- ? bugsForScope(byRow([...verdictBugs(cwd, slug, round), ...criteriaBugs(cwd, slug, round)], rowOwner(cwd, slug)).concat(buildBugs(cwd, slug, round)), scope.scope_id, scopeSubstrates(cwd, slug))
1201
- : [];
1202
-
1203
- // A ROUND CARRYING CITED DEFECTS IS A `fix`, AND THE ORDER HAS TO SAY SO.
1204
- //
1205
- // `payload.bugs` is the only field task-executor's input contract binds to a specific operation:
1206
- // "`fix` (only the bugs in `payload.bugs` — touch nothing else)". Dispatching a fix round as
1207
- // `execute` hands the worker a field its own contract associates with a different operation,
1208
- // against a Zero-memory rule that says to treat anything not in the order as unknown. Delivering
1209
- // evidence the receiving contract does not claim is half a delivery.
1210
- //
1211
- // Free of blast radius by construction: `substrateFor` returns the same whitelist for
1212
- // execute/fix/spike, so the sandbox contract is byte-identical, and the order id is addressed by
1213
- // scope/round/attempt rather than by operation, so no filename moves. An explicit `--operation`
1214
- // still wins — an operator naming the operation outranks the derivation.
1215
- let operation = flag("operation")
1216
- || (scopePath || flag("task") || has("next") ? (bugs.length ? "fix" : "execute") : null);
1217
- const worker = flag("worker")
1218
- || (scopePath || flag("task") || has("next") ? "task-executor" : null)
1219
- || (operation ? OP_OWNER[operation] : null);
1220
- if (!worker || !operation) { console.error("compile-order: could not resolve --worker/--operation"); process.exit(2); }
1221
-
1222
- // GATE COACH-1 HAS A DETERMINISTIC CALL SITE: THE COACH DISPATCH. The gate asks whether a PO is
1223
- // present to categorize feedback; its answer used to be a prose instruction inside the coach
1224
- // skill, so an unattended lane could dispatch a coach nobody would answer and no row recorded the
1225
- // decision. Compiling the coach order resolves it with the run's own answer set and records the
1226
- // row; `skip` refuses the order, which is what "no live PO" means. `ask` compiles it — the
1227
- // categorization conversation the coach then holds IS the answer.
1228
- if (operation === "coach") {
1229
- const ga = readReceipt(receipt(cwd, slug))?.config?.gate_answers ?? null;
1230
- const presetName = ga && PRESETS[ga] ? ga : null;
1231
- let found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
1232
- if (found.error) found = { set: PRESETS.interactive, source: "preset:interactive (no answer set on disk)" };
1233
- const r = resolveGate(found.set, "COACH-1", found.source);
1234
- appendGateLedger(cwd, slug, {
1235
- at: new Date().toISOString(), run_id: readRunId(cwd, slug), gate: "COACH-1", status: r.status,
1236
- decision: r.decision ?? null, source: r.source ?? found.source, note: r.note ?? r.reason ?? null, round: null,
1237
- });
1238
- if (r.decision === "skip") {
1239
- console.error(`compile-order: GATE COACH-1 resolved "skip" (${r.source ?? found.source}) — no live PO to categorize feedback, so no coach order is compiled. The decision is on the gate ledger.`);
1240
- process.exit(3);
1241
- }
1242
- }
1243
-
1244
- // Task selection.
1245
- let tasks;
1246
- const board = readBoard(cwd, slug);
1247
- if (flag("task")) {
1248
- tasks = board.filter((t) => t.id === flag("task"));
1249
- if (!tasks.length) { console.error(`compile-order: ${flag("task")} not found on the ${slug} board`); process.exit(2); }
1250
- } else if (has("next")) {
1251
- const doneIds = new Set(board.filter((t) => t.status === "done").map((t) => t.id));
1252
- tasks = board.filter((t) => t.status === "ready" && t.depends_on.every((d) => doneIds.has(d))).slice(0, 1);
1253
- if (!tasks.length) { console.error("compile-order: no ready task with satisfied dependencies"); process.exit(2); }
1254
- } else if (scope) {
1255
- // Scope attempt: the board tasks anchored to this scope's use cases, else every not-done task
1256
- // on the board (the attempt loop owns sequencing, not the worker).
1257
- //
1258
- // The contract names USE CASES, never task ids: it is committed and the board is not, so a
1259
- // contract holding `TASK-004` pointed into a gitignored per-machine tier and resolved to
1260
- // nothing on a fresh clone — silently, because an empty filter and an absent board are the
1261
- // same empty array. A scope that anchors no use case still falls back to the whole open board
1262
- // rather than dispatching nothing; spec-lint's SCOPE-ANCHOR is what reports the missing anchor.
1263
- const anchored = tasksForScope(board, scope);
1264
- tasks = anchored.length || (scope.use_cases || []).length
1265
- ? anchored
1266
- : board.filter((t) => t.status !== "done");
1267
- }
1268
-
1269
- // Ledger decisions for this scope.
1270
- const ledgerPath = roundLedger(cwd, slug);
1271
- const decisions = existsSync(ledgerPath)
1272
- ? ledgerDecisions(readFileSync(ledgerPath, "utf8"), scope?.scope_id)
1273
- : [];
1274
-
1275
- // inspect() — the attempt loop's history (see selectTrialHistory above).
1276
- //
1277
- // NON-REGRESSION. With no `trials.jsonl` on disk this falls back to exactly the pre-ratchet
1278
- // read — the previous attempt's verdict artifact, AEGIS triples only, byte-for-byte the same
1279
- // order. Every new arm is skipped when its artifact is absent, per the ✦/✚ convention.
1280
- let digestedErrors = [];
1281
- let trialHistory = [];
1282
- const trialsPath = trials(cwd, slug);
1283
- const allTrials = readTrials(trialsPath);
1284
- if (allTrials.length) {
1285
- trialHistory = selectTrialHistory(allTrials, { scopeId: scope?.scope_id, round });
1286
- const lastRed = [...trialHistory].reverse().find((t) => t.digest?.length);
1287
- digestedErrors = lastRed?.digest ?? [];
1288
- } else if (round && attempt && attempt > 1) {
1289
- const dir = verdictsDir(cwd, slug);
1290
- // Immutable addressing (v1.5) writes `r<R>-a<A>-t<T>.json`; the pre-v1.5 unsuffixed name is
1291
- // still read so artifacts already on disk keep working.
1292
- const suffixed = existsSync(dir)
1293
- ? readdirSync(dir)
1294
- .filter((f) => new RegExp(`^r${round}-a${attempt - 1}-t\\d+\\.json$`).test(f))
1295
- .sort((a, b) => Number(b.match(/-t(\d+)\./)[1]) - Number(a.match(/-t(\d+)\./)[1])) // newest first
1296
- .map((f) => join(dir, f))
1297
- : [];
1298
- const prev = [...suffixed, join(dir, `r${round}-a${attempt - 1}.json`)].find((p) => existsSync(p));
1299
- if (prev) {
1300
- try { digestedErrors = JSON.parse(readFileSync(prev, "utf8")).discovered_tasks || []; } catch { /* stale artifact → none */ }
1301
- }
1302
- }
1303
-
1304
- // `--payload` is coerced and rejected at the argv boundary now (type "json"), so a malformed
1305
- // payload never reaches this point — the same discipline validate-envelope applies to an order.
1306
- let payloadExtra = flag("payload") || {};
1307
- if (specDir && !payloadExtra.spec_folder) payloadExtra.spec_folder = specDir;
1308
- if (!payloadExtra.feature) payloadExtra.feature = slug;
1309
- // The judge's citations, for every lane (see t0ArtifactsFor). An explicit `--payload` list still
1310
- // wins, as it does for `bugs`: an operator naming the evidence outranks the derivation.
1311
- if (operation === "evaluate" && payloadExtra.t0_artifacts === undefined) {
1312
- const { artifacts, missing } = t0ArtifactsFor(cwd, slug, round);
1313
- if (artifacts.length) payloadExtra.t0_artifacts = artifacts;
1314
- // A scope that did not go green leaves its rows ungraded, not the round: the judge refused a whole
1315
- // round over one such scope and the run aborted at L3, before the next round or GATE H's census
1316
- // could act on it. With at least one citation the round is gradeable, and the order names the
1317
- // scopes whose rows are FAILs for want of a green T0.
1318
- if (artifacts.length && missing.length) payloadExtra.scopes_without_t0 = missing;
1319
- // On stderr, never stdout: stdout is the order path the caller consumes.
1320
- if (missing.length) {
1321
- console.error(`compile-order: warning — no green T0 verdict${round ? ` in round ${round}` : ""} for ` +
1322
- `${missing.join(", ")}; the evaluator has nothing to cite for ${missing.length === 1 ? "that scope" : "those scopes"}`);
1323
- }
1324
- }
1325
- // The graded dimensions (see resolveDimensions). A set the PO named at L0.5 travels in `--payload`
1326
- // and wins; an absent or empty list is resolved from the spec, never left to the judge's default.
1327
- if (operation === "evaluate" && !(Array.isArray(payloadExtra.dimensions) && payloadExtra.dimensions.length)) {
1328
- payloadExtra.dimensions = resolveDimensions(cwd, slug, payloadExtra.spec_folder || specDir);
1329
- }
1330
- // The launch evidence, for every lane (see launchEvidenceFor). Same rule as above: an explicit
1331
- // `--payload` value outranks the derivation.
1332
- if (operation === "evaluate" && payloadExtra.revised_checks === undefined) {
1333
- const revised = revisedChecksFor(cwd, slug, round);
1334
- if (revised.length) payloadExtra.revised_checks = revised;
1335
- }
1336
- // The QA hunt drives the same running app the judge grades, so it needs the same way in. A mobile
1337
- // deliverable has no URL, and a hunt order carrying only `app_url: null` reported "no reachable
1338
- // deliverable" and hunted nothing over a build the round gate had just launched.
1339
- if (operation === "evaluate" || operation === "hunt") {
1340
- const ev = launchEvidenceFor(cwd, slug, round);
1341
- if (ev.build_gate !== undefined && payloadExtra.build_gate === undefined) payloadExtra.build_gate = ev.build_gate;
1342
- if (ev.launch_cmd !== undefined && payloadExtra.launch_cmd === undefined) payloadExtra.launch_cmd = ev.launch_cmd;
1343
- }
1344
-
1345
- const order = compileOrder({
1346
- slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory, bugs,
1347
- testCmd: flag("test-cmd"), payloadExtra, specDir,
1348
- interaction: has("pause-gates") ? { pause_gates: true } : { pause_gates: false },
1349
- // Read off the receipt rather than passed in: a standalone `compile-order` invocation gets the
1350
- // same key as one the workflow drove, and a dispatch in a workspace with no open run simply
1351
- // carries no key instead of failing — the analytic field must never be able to block a build.
1352
- runId: readRunId(cwd, slug),
1353
- compiledAt: new Date().toISOString(),
1354
- });
1355
-
1356
- const { valid, errors } = validate(order, ORDER_SCHEMA);
1357
- if (!valid) {
1358
- console.error("compile-order: produced an order that fails its own schema — refusing to write:");
1359
- for (const e of errors) console.error(` ✗ ${e}`);
1360
- process.exit(1);
1361
- }
1362
- const outDir = ordersDir(cwd, slug);
1363
- mkdirSync(outDir, { recursive: true });
1364
- const outPath = join(outDir, `${order.order_id.split("/")[1]}.json`);
1365
- writeFileSync(outPath, JSON.stringify(order, null, 2) + "\n");
1366
- console.log(outPath);
1367
-
1368
- // POINT THE SANDBOX AT THIS ORDER, HERE, because this is the only place every lane passes
1369
- // through.
1370
- //
1371
- // `hooks/sandbox-guard.mjs` enforces the order's own `substrate` block — allowed/shared,
1372
- // append_only, frozen — and it finds the RUN through `.shapeup/active-order`. Until this write
1373
- // existed the pointer had exactly one author, the workflow script, so the guard fenced the
1374
- // workflow lane and DEFERRED everywhere else: `--tiny`, the prose round loop, and a standalone
1375
- // `/build` all compiled an order carrying a write contract that nothing enforced. A substrate
1376
- // that is only enforced on the lane that also happens to be the most supervised one is the wrong
1377
- // way round. This is now the pointer's ONLY author.
1378
- //
1379
- // Compiling an order is the moment the write contract comes into existence, so it is the correct
1380
- // moment to publish it. What the pointer supplies is the run's slug; which of that run's orders
1381
- // are LIVE is derived from the order set (compiled, not yet answered), never from this file — so
1382
- // republishing it on every compile costs nothing and a stale one fences nothing.
1383
- //
1384
- // Best-effort, on stderr, and never fatal: a compiled order that cannot publish its pointer is
1385
- // still a valid order, and stdout belongs to the order path the caller consumes. The guard
1386
- // fails open on a missing pointer by design, so the failure mode is "unfenced", which is
1387
- // exactly what a warning is for.
1388
- const ptr = writeActiveOrder(cwd, slug, outPath);
1389
- if (!ptr.ok) console.error(`compile-order: warning — ${ptr.reason} (this order's substrate will not be enforced)`);
1390
-
1391
- // The stagnation breaker reports on stderr, never on stdout: stdout is the order path the
1392
- // orchestrator consumes, and a breaker that corrupts the pipeline's own output would be worse
1393
- // than the flailing it detects. It advises; the orchestrator queues the GATE H proposal.
1394
- if (scope?.scope_id) {
1395
- const k = Number(scope.no_progress_k ?? payloadExtra.no_progress_k ?? 2);
1396
- // SCOPED TO THIS RUN, for the same reason the attempt census is. `t0/trials.jsonl` is per-slug
1397
- // and append-only, so a streak survives the run that produced it. Measured on a consumer: two
1398
- // non-kept trials from earlier runs — one of them graded against an order no worker was ever
1399
- // dispatched for — read as a stagnation streak that every later run of that scope tripped on,
1400
- // seven hours after the tree they graded had been fixed. The breaker escalated on evidence that
1401
- // predated its own fix, and no attempt of the tripping run had been dispatched at all.
1402
- //
1403
- // A trial with no run key counts for no run; an unresolvable current run matches nothing. Both
1404
- // leave the breaker un-tripped, which is the fail-open direction this repo's guards take when
1405
- // the bad state cannot be positively proven.
1406
- const myRunId = readRunId(cwd, slug);
1407
- const st = stagnation(
1408
- allTrials.filter((t) => t.scope_id === scope.scope_id && myRunId != null && t.run_id === myRunId),
1409
- k,
1410
- );
1411
- if (st.stagnant) {
1412
- console.error(JSON.stringify({
1413
- breaker: "stagnation", scope_id: scope.scope_id, streak: st.streak, no_progress_k: st.k,
1414
- action: "queue a GATE H proposal for this scope and move on — do NOT block the round",
1415
- reason: `${st.streak} consecutive non-kept trials: the loop is not ratcheting on this scope.`,
1416
- }));
1417
- }
1418
- }
1419
- }