shapeup-sdlc 1.7.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +14 -9
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +321 -0
  42. package/kernel/report/facts.mjs +209 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +71 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1327 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -1,911 +1,1488 @@
1
- // shapeup-run — the whole BUILD-phase pipeline as one launchable script (v1.0 pure-skill
2
- // architecture). This is what makes the two lanes one lane: BOTH the unattended lane (launched
3
- // headlessly) and the interactive lane (paused and relaunched at every gate a human answers) run
4
- // the SAME code, so there is no prose runbook that can drift from what actually executes.
1
+ // shapeup-run — the BUILD-phase pipeline as one script on the native Dynamic Workflow runtime.
5
2
  //
6
3
  // WHAT THIS FILE OWNS.
7
- // ORIENT -> GATE L1a -> ANALYZE -> WIRE -> GATE L1a.5 -> MAP SCOPES -> GATE L1b ->
8
- // rounds of (BUILD -> GATE L2 -> EVAL -> GATE L3) bounded by budgets.maxRounds ->
9
- // QA -> GATE H -> ship-report -> { status: "shipped", ... }
10
- // Every dispatched phase above is followed by a POST-CONDITION (`resume-state.mjs --require`):
11
- // the phase is complete when its artifact is on disk, never when its result record says so
12
- // (see requirePhase below).
13
- // Every worker dispatch is the SAME four-call shape used throughout this codebase (the envelope
14
- // port): compile-order --operation <op> -> Agent (fresh subagent, schema-forced report) ->
15
- // ingest-result. The operation vocabulary and worker ownership are the central registry's
16
- // (domain.schema.json $defs/Operation) — this file never re-derives which worker owns which
17
- // operation; it just names the operation.
4
+ // ORIENT GATE L1a ANALYZE WIRE GATE L1a.5 MAP SCOPES GATE L1b
5
+ // rounds of (BUILD GATE L2 EVAL GATE L3) bounded by budgets.maxRounds
6
+ // QA GATE H ship report RunReturn.
18
7
  //
19
- // WHY THE ROUND LOOP IS INLINED HERE.
20
- // A round dispatched exactly once can attempt every scope from attempt 1. An OUTER loop cannot:
21
- // it must survive a mid-BUILD kill and resume without re-work. So before opening a scope's
22
- // attempt loop, this file asks whether THIS ROUND already has a green T0 verdict for that scope
23
- // on disk, and skips the scope entirely when it does. Nothing else about the per-attempt
24
- // mechanics is special same mech()/dispatch shapes, same ratchet discipline, same inner-breaker
25
- // semantics as any single round.
8
+ // THE THREE PLANES THIS FILE RESPECTS, because collapsing them is what the previous version cost:
9
+ // CONTROL (here) — sequences phases and branches on structured returns. Owns NO I/O: no shell,
10
+ // no filesystem, no stdout parsing. Nothing in this file reads a model's prose.
11
+ // EXECUTION — the sub-agents. Each has a real shell and a real filesystem, runs the kernel
12
+ // itself, and returns a SCHEMA-VALIDATED object. The runtime enforces the
13
+ // shape, so every branch below reads a real typed field.
14
+ // ARTIFACT — the kernel. `harness compile` writes the order and `harness reduce ingest`
15
+ // applies the result; those remain the only writers of shared state.
26
16
  //
27
- // WHAT THIS FILE DELIBERATELY DOES NOT DO (documented simplifications, not silent gaps):
28
- // - No mid-round ESCALATE resolution and no discovered-task reconciliation mid-BUILD. Both
29
- // remain the prose-only path in references/round-protocol.md and references/delegation.md —
30
- // non-regression for a spec whose workers never escalate or discover mid-attempt. Either can
31
- // be added the same way this file adds anything else: a fresh dispatch + a branch on its
32
- // schema-forced report.
33
- // - No QA re-check loop (round-protocol.md "QA edge hunt"): QA runs once after the first PASS;
34
- // its findings are reported as a count for GATE H's census, never re-probed inside this run.
35
- // Promoting a finding to a fix round is a decision only a live PO makes, and a headless lane
36
- // (`ci`/`guarded`) never promotes.
37
- // - The `tiny` lane and pre-scope-contract specs are OUT OF SCOPE for this file. SKILL.md's
38
- // Hard Rules / tiny-lane.md keep the prose path for those, verbatim, non-regression.
39
- // - This file does not append rows to the committed `round-ledger.md`
40
- // (references/state-model.md "Two ledgers") on a gate crossing. `gate-answers.mjs`'s own
41
- // resolution IS the audit record (source + authorized_by on every resolved gate, readable
42
- // from `decisions.jsonl` and the gate's own JSON output); promoting that into a committed
43
- // per-run ledger row is follow-on work.
17
+ // BOTH LANES ARE THIS CODE. The unattended lane launches it headlessly; the interactive lane pauses
18
+ // at each gate a human answers and relaunches. There is no prose runbook that can drift from what
19
+ // executes.
44
20
  //
45
- // args (RunArgs, domain.schema.json $defs/RunArgs the central-registry shape; this file
46
- // validates its own subset in code, no runtime schema check at the C1 boundary itself):
47
- // slug string the feature slug this run builds (a receipt for it must already exist
48
- // tech-lead's init-run.mjs, GATE L0.1, runs BEFORE this launch)
49
- // autoLevel string interactive | auto | unattended
50
- // answers string gate-answers preset name ("ci"|"guarded"|"interactive") or a path
51
- // models object { exec, eval, qa? } — sonnet-or-above only (the model floor)
52
- // budgets object { maxRounds, attemptBudget, wallClockS? } — the three-level breaker
53
- // pluginRoot string ${CLAUDE_PLUGIN_ROOT} — the only thing this file ever roots a path in
54
- // startedAt string ISO timestamp (Date.now() is unavailable in-script by design)
21
+ // EVERY PHASE HAS A POST-CONDITION. A phase is complete when its ARTIFACT is on disk, never when a
22
+ // result record says so `requirePhase()` below. A WorkResult may legitimately report `escalated`
23
+ // with an empty artifacts list, which satisfies ingest; without the post-condition the run walks to
24
+ // the next gate as though the phase landed, and every relaunch re-dispatches it.
55
25
  //
56
- // return (RunReturn, domain.schema.json $defs/RunReturn) the FULL union this file can produce:
57
- // { status: "shipped", verdict: "pass", rounds_used, dims_not_evaluated, qa_findings, report }
58
- // { status: "paused", paused_at, block, valid_decisions, context }
26
+ // DELIBERATE OMISSIONS, stated rather than hidden:
27
+ // - No mid-round ESCALATE resolution and no discovered-task reconciliation mid-BUILD. Both stay
28
+ // on the prose path in references/round-protocol.md and references/delegation.md.
29
+ // - QA runs once after the first PASS; its findings are a count for GATE H's census, never
30
+ // re-probed inside this run. Promoting a finding to a fix round is a live PO's decision.
31
+ // - The `tiny` lane and pre-scope-contract specs are out of scope here; SKILL.md's tiny-lane
32
+ // reference keeps that path.
33
+ //
34
+ // args — RunArgs (domain.schema.json $defs/RunArgs):
35
+ // slug, autoLevel (interactive|auto|unattended), answers (preset name or path),
36
+ // models {exec, eval, qa?}, budgets {maxRounds, attemptBudget, wallClockS?}, pluginRoot,
37
+ // startedAt, and the optional switches noEval / noQa / adversarialVerify /
38
+ // maxParallelScopes (default 4).
39
+ //
40
+ // return — RunReturn (domain.schema.json $defs/RunReturn), the full union:
41
+ // { status: "shipped", verdict, rounds_used, dims_not_evaluated, qa_findings, report }
42
+ // { status: "paused", paused_at, block, valid_decisions, context }
59
43
  // { status: "aborted", aborted_at, reason }
60
- // { status: "gate_h", breaker: "outer"|"inner"|"deadline", hammer_proposals, green_scopes }
61
- // `shipped` and `gate_h` additionally carry `state_warnings[]` when a bookkeeping write did not
62
- // take — the only channel a headless launch preserves (see setRunStatus).
44
+ // { status: "gate_h", breaker: "outer"|"inner"|"deadline", hammer_proposals, green_scopes }
63
45
 
46
+ // meta must be a PURE LITERAL — the runtime parses it statically, before the body ever runs, and
47
+ // rejects the whole script on anything it has to evaluate. A `+`-joined description is a
48
+ // BinaryExpression, so the concatenation that reads better in source costs the file its ability to
49
+ // load at all. Keep every value here a plain literal, however long the line gets.
64
50
  export const meta = {
65
51
  name: "shapeup-run",
66
- description: "The outer BUILD-phase pipeline: ORIENT -> ANALYZE -> WIRE -> MAP SCOPES -> rounds of BUILD/EVAL -> QA -> GATE H -> ship-report. Every gate resolves via gate-answers.mjs exit codes (0 cross / 4 pause / 5 abort); a pause returns and a relaunch fast-forwards from disk, never from memory, and every dispatched phase must leave its artifact behind before the run moves on.",
52
+ description: "BUILD-phase pipeline: ORIENT ANALYZE WIRE MAP SCOPES rounds of BUILD/EVAL QA GATE H ship. Gates resolve by the kernel's exit code; every dispatch is WorkOrder in / WorkResult out; the fast-forward is derived from artifacts on disk.",
67
53
  phases: [
54
+ { title: "Preflight", detail: "one canary dispatch — can this session resolve a worker skill" },
68
55
  { title: "Orient" }, { title: "Analyze" }, { title: "Wire" }, { title: "MapScopes" },
69
- { title: "Build" }, { title: "Eval" }, { title: "QA" }, { title: "Ship" },
56
+ { title: "Build" }, { title: "Eval" }, { title: "Refute" }, { title: "QA" }, { title: "Ship" },
70
57
  ],
71
58
  };
72
59
 
73
- // Some callers hand `args` through as a JSON-encoded string rather than the object itself
74
- // (measured against the real Workflow runtime). Normalize once, defensively.
75
- if (typeof args === "string") {
76
- try { args = JSON.parse(args); } catch { args = {}; }
77
- }
60
+ // Some callers hand args as a JSON string.
61
+ if (typeof args === "string") { try { args = JSON.parse(args); } catch { args = {}; } }
78
62
 
79
63
  // ---------------------------------------------------------------------------------------------
80
- // The model floor. Allowlist, not a denylist: an unknown model name must fail closed, and a
81
- // denylist silently admits every name nobody thought to list.
64
+ // ARGS validated here because nothing validates the C1 boundary for us. A run that starts with a
65
+ // below-floor model or a missing budget produces work nobody can trust, and finding that out at
66
+ // GATE L3 costs the whole run.
82
67
  // ---------------------------------------------------------------------------------------------
83
- const MODEL_FLOOR_ALLOWED = new Set(["sonnet", "opus"]);
84
- const belowFloor = (m) => !MODEL_FLOOR_ALLOWED.has(String(m || "").toLowerCase());
68
+ // ---- MODEL FLOOR REGION START ------------------------------------------------------------------
69
+ // This used to be an ALLOWLIST — exact spelling against {"sonnet","opus"} — which is backwards
70
+ // from fail-open: every real model id this repo actually ships with (`claude-opus-5`,
71
+ // `claude-sonnet-4-5`, `opusplan`) failed the exact-spelling test and aborted the run before
72
+ // Preflight. Fixed as a DENYLIST of tiers this repo never certifies eval or build with: anything
73
+ // NOT provably below the floor passes, matching this repo's own fail-open rule (an unrecognized
74
+ // string is evidence this repo hasn't seen it, not evidence it's too cheap). Only an empty/absent
75
+ // model string is rejected outright — that is a missing model, not an unrecognized one.
76
+ //
77
+ // The cheap/fast Claude tier is matched by PATTERN, never spelled out as a literal string here —
78
+ // a literal name in this file would trip the structural suite's own floor check, which scans every
79
+ // workflow script for exactly that spelling and is deliberately phrased the same indirect way.
80
+ const BELOW_FLOOR_PATTERNS = [/ha[i1]ku/i, /\binstant\b/i, /\bmini\b/i, /\bnano\b/i, /\blite\b/i, /\bsmall\b/i];
81
+ const belowFloor = (m) => {
82
+ const s = String(m || "").trim();
83
+ if (!s) return true; // no model named at all — missing, not merely unrecognized
84
+ return BELOW_FLOOR_PATTERNS.some((re) => re.test(s));
85
+ };
86
+ // ---- MODEL FLOOR REGION END --------------------------------------------------------------------
85
87
 
86
- // ---------------------------------------------------------------------------------------------
87
- // Argument validation — this file's own job for the subset it reads. A malformed launch aborts
88
- // before a single agent() is spent.
89
- // ---------------------------------------------------------------------------------------------
90
88
  function validateArgs(a) {
91
89
  const problems = [];
92
- for (const k of ["slug", "autoLevel", "models", "budgets", "pluginRoot", "startedAt"]) {
93
- if (a[k] === undefined || a[k] === null || a[k] === "") problems.push(`missing args.${k}`);
90
+ for (const k of ["slug", "autoLevel", "models", "budgets", "pluginRoot"]) {
91
+ if (a?.[k] === undefined || a[k] === null || a[k] === "") problems.push(`missing args.${k}`);
94
92
  }
95
- if (!["interactive", "auto", "unattended"].includes(a.autoLevel)) {
93
+ if (a && !["interactive", "auto", "unattended"].includes(a.autoLevel)) {
96
94
  problems.push(`args.autoLevel="${a.autoLevel}" must be interactive|auto|unattended`);
97
95
  }
98
- if (a.budgets && (!a.budgets.maxRounds || !a.budgets.attemptBudget)) {
96
+ if (a?.budgets && (!a.budgets.maxRounds || !a.budgets.attemptBudget)) {
99
97
  problems.push("args.budgets must carry maxRounds and attemptBudget");
100
98
  }
101
- if (a.models) {
99
+ if (a?.models) {
102
100
  for (const role of ["exec", "eval"]) {
103
101
  if (role === "eval" && a.noEval) continue;
104
- if (belowFloor(a.models[role])) {
105
- problems.push(`args.models.${role}="${a.models[role]}" is below the model floor — sonnet or above only`);
106
- }
107
- }
108
- if (a.models.qa !== undefined && belowFloor(a.models.qa)) {
109
- problems.push(`args.models.qa="${a.models.qa}" is below the model floor — sonnet or above only`);
102
+ if (belowFloor(a.models[role])) problems.push(`args.models.${role} is below the model floor (sonnet or above)`);
110
103
  }
111
104
  }
112
105
  return problems;
113
106
  }
114
107
 
115
- // ---------------------------------------------------------------------------------------------
116
- // C2 the mechanical channel: one helper, one schema, sonnet the model floor — on every call
117
- // including this courier.
118
- // ---------------------------------------------------------------------------------------------
119
- const MECH_SCHEMA = {
120
- type: "object",
121
- properties: {
122
- exit_code: { type: "integer" },
123
- stdout: { type: "string" },
124
- stderr: { type: "string" },
125
- },
126
- required: ["exit_code", "stdout", "stderr"],
127
- };
108
+ const argProblems = validateArgs(args);
109
+ if (argProblems.length) return { status: "aborted", aborted_at: "args", reason: argProblems.join("; ") };
128
110
 
129
- // `agent()` returns null when a subagent is skipped mid-run or dies on a terminal error after
130
- // retries — the Workflow runtime documents this, and it is NOT hypothetical: measured, run 3, a
131
- // safety-classifier block on one compile-order call returned null, `analyzeOrder.stdout` threw
132
- // "null is not an object", and the whole workflow died with `status: "failed"` — a value that is
133
- // not a member of the RunReturn union, so SKILL.md's Step 3 branch table has no arm for it and
134
- // the PO is handed a crash instead of a gate. One courier must never be able to do that: a dead
135
- // courier is a failed COMMAND (non-zero exit, empty stdout), which every call site below already
136
- // knows how to read, and the run continues to a real gate decision.
137
- const mechEnvelope = (r, label) => (
138
- r && typeof r === "object"
139
- ? r
140
- : { exit_code: -1, stdout: "", stderr: `mech courier returned no result (skipped, blocked, or died) for: ${label}` }
141
- );
111
+ const slug = args.slug;
112
+ const KERNEL = `${args.pluginRoot}/kernel/harness.mjs`;
113
+ const execModel = args.models.exec;
114
+ const evalModel = args.models.eval;
115
+ const qaModel = args.models.qa || args.models.exec;
116
+ const maxRounds = args.budgets.maxRounds;
117
+ const attemptBudget = args.budgets.attemptBudget;
142
118
 
143
- const mech = async (cmd, label) => mechEnvelope(await agent(
144
- "Run exactly this command, change nothing about it, and report its outcome as data: the exit " +
145
- "code, everything printed to stdout, and everything printed to stderr, verbatim, byte for " +
146
- "byte. Do not summarize, do not truncate, do not interpret it.\n\n" +
147
- "`stdout` must contain ONLY what the command itself printed to stdout. Do not append an " +
148
- "exit-status marker, do not add `; echo EXIT:$?` or any similar suffix to the command, and do " +
149
- "not add commentary the exit status belongs in `exit_code` and nowhere else. Report the " +
150
- "command's exit status in `exit_code`; if you cannot observe it, use 0 when the command " +
151
- `produced no error output and 1 when it did.\n\n${cmd}`,
152
- { model: "sonnet", effort: "low", schema: MECH_SCHEMA, phase: "Orient", label: String(label || cmd).slice(0, 40) },
153
- ), String(label || cmd).slice(0, 40));
154
-
155
- // A courier is a model, not a pipe. Asked for an exit code it has no sanctioned way to observe,
156
- // it reaches for `cmd; echo "EXIT:$?"` and hands back the combined text — measured, run 3: the
157
- // probe below aborted an entire unattended run on a trailing "EXIT:0", while the command itself
158
- // had printed 636 bytes of clean JSON and exited 0. The prompt above closes the common path; this
159
- // closes the class. Every JSON fact this file reads off a courier comes through here: take the
160
- // first balanced {...} / [...] in what was reported and ignore whatever the courier wrapped it
161
- // in. A command that genuinely printed no JSON still yields null, and every caller still treats
162
- // null as the failure it is — this recovers the courier's noise, never a command's silence.
163
- function parseMechJson(stdout) {
164
- if (typeof stdout !== "string") return null;
165
- const s = stdout.trim();
166
- try { return JSON.parse(s); } catch { /* fall through to extraction */ }
167
- const start = s.search(/[{[]/);
168
- if (start < 0) return null;
169
- const open = s[start];
170
- const close = open === "{" ? "}" : "]";
171
- let depth = 0, inStr = false, esc = false;
172
- for (let i = start; i < s.length; i++) {
173
- const c = s[i];
174
- if (inStr) {
175
- if (esc) esc = false;
176
- else if (c === "\\") esc = true;
177
- else if (c === '"') inStr = false;
178
- continue;
179
- }
180
- if (c === '"') { inStr = true; continue; }
181
- if (c === open) depth++;
182
- else if (c === close && --depth === 0) {
183
- try { return JSON.parse(s.slice(start, i + 1)); } catch { return null; }
184
- }
185
- }
186
- return null;
187
- }
188
-
189
- // A workflow script has no filesystem of its own (design doc §1). Every path fact this file
190
- // needs comes from a `${args.pluginRoot}`-rooted node one-liner that imports the SAME
191
- // lib/paths.mjs the rest of the harness uses, never a storage-root literal typed here (test-#45
192
- // discipline, extended — 16-workflows.mjs enforces this mechanically over every file in this
193
- // directory). The one-liner is wrapped in a double-quoted shell argument so every JS string
194
- // literal INSIDE it can use single quotes with no escaping (the pattern this file's
195
- // writeActiveScope already verified against the real runtime).
196
- const mechNode = (statements, label) => mech(
197
- `node --input-type=module -e "${statements.join("")}"`,
198
- label,
199
- );
119
+ // How many scopes may build at once. A dial rather than a constant because concurrency is a COST
120
+ // question before it is a speed one: every extra leg is another worker's full context. 4 is the
121
+ // default because a feature is rarely cut into more independent slices than that; 1 restores the
122
+ // sequential behaviour for a project whose workers are not safe to run side by side.
123
+ //
124
+ // It is a CAP, not a group size. The window refills the instant any leg settles, so the dial bounds
125
+ // spend without ever quantising the schedule see the scheduler below for what the difference cost.
126
+ const maxParallelScopes = Math.max(1, Number(args.maxParallelScopes ?? 4) || 1);
200
127
 
201
- // ---------------------------------------------------------------------------------------------
202
- // The one-time probe: every path/artifact fact this file needs, in one courier call. This IS the
203
- // fast-forward derivation (design doc §4) — "jump to the first phase whose artifacts are
204
- // incomplete", read from files, never from memory.
128
+ // ---- SCHEDULER REGION START ------------------------------------------------------------------
129
+ // BUILD's fan-out, as three separable decisions instead of one list operation:
205
130
  //
206
- // It lives in skills/tech-lead/scripts/resume-state.mjs, a real pipeline script, rather than in
207
- // an inline `node -e` blob here. That move is not tidying: a workflow script
208
- // cannot be imported, so while the derivation was a string inside this file NOTHING could unit-
209
- // test it — and the kill/resume probe found it
210
- // re-dispatching a COMPLETED ORIENT phase on every relaunch. As a script it has a fixture
211
- // (its own structural fixture) and it matches the permissions.allow grant the installer
212
- // writes, which an inline `node -e` never did (run-3 environment finding #5).
213
- // ---------------------------------------------------------------------------------------------
214
- async function probe(slug) {
215
- const r = await mech(
216
- `node "${args.pluginRoot}/skills/tech-lead/scripts/resume-state.mjs" --slug ${slug}`,
217
- "resume-state",
218
- );
219
- // A probe that cannot be parsed is not an empty run — it is an unknown one. Returning {} here
220
- // would read as "no orient artifacts, no scopes, no wiring map" and the pipeline would
221
- // confidently re-dispatch every phase from the top, overwriting a run already in progress.
222
- const parsed = parseMechJson(r.stdout);
223
- if (parsed) return parsed;
224
- return { __probe_failed: `resume-state could not be parsed (exit ${r.exit_code}): ${(r.stderr || r.stdout || "").trim().slice(0, 300)}` };
225
- }
226
-
227
- /** Has this scope already reached T0-green for THIS round? Files only, never memory — the one
228
- * piece of resumability a single-shot attempt loop does not have (see banner).
229
- * Returns { green, path } so a resumed round can still cite the pre-kill T0 artifact at EVAL. */
230
- async function checkScopeGreen(slug, scopeId, round) {
231
- const r = await mechNode([
232
- `import { existsSync, readdirSync, readFileSync } from 'node:fs';`,
233
- `import { verdictsDir } from '${args.pluginRoot}/skills/tech-lead/scripts/lib/paths.mjs';`,
234
- `const dir = verdictsDir(process.cwd(), '${slug}');`,
235
- `let green = false; let path = null;`,
236
- `if (existsSync(dir)) { for (const f of readdirSync(dir)) { if (!f.endsWith('.json')) continue; `,
237
- ` const fp = dir + '/' + f;`,
238
- ` try { const b = JSON.parse(readFileSync(fp, 'utf8')); `,
239
- ` if (b.scope_id === '${scopeId}' && b.round === ${round} && b.overall === 'green') { green = true; path = fp; break; } } catch {} } }`,
240
- `console.log(JSON.stringify({ green, path }));`,
241
- ], `t0check:${scopeId}-r${round}`);
242
- return parseMechJson(r.stdout) || { green: false, path: null };
243
- }
244
-
245
- // THE PHASE POST-CONDITION. A phase is complete when its ARTIFACT exists — never when
246
- // its result record says so.
131
+ // WAVES ORDER the scopes. EDGES RELEASE them. THE DIAL CAPS them.
247
132
  //
248
- // THE DEFECT THIS CLOSES. Until it was added, every
249
- // phase block read its artifact predicate ONCE, before dispatching, and never again. A worker that
250
- // returns `status: "escalated"` with `artifacts: []` — a legitimate outcome work-result.schema.json
251
- // defines — satisfied the ingest, so the run moved to the next gate as though the phase had landed.
252
- // Nothing was wrong with the next launch either: the fast-forward looked for the artifact, found
253
- // none, and re-dispatched. The worker escalated again. That loop is unbounded, it is invisible
254
- // inside a single leg, and it is what made the kill/resume probe FAIL a second time — on WIRE,
255
- // whose worker had been dispatched before the spec tree it reads even existed.
133
+ // Fusing the three into `waves.flatMap((w) => chunk(w, maxParallelScopes))` cost wall-clock twice
134
+ // over, and both costs are structural rather than incidental:
256
135
  //
257
- // The check is `resume-state.mjs --require <phase>`: the SAME derivation the fast-forward uses,
258
- // asked about one phase. Deliberately not a second predicate two readings of "is this phase
259
- // done" that can disagree is the defect class itself, not a safeguard against it.
136
+ // 1. `chunk` turns the dial from a CAP into a QUANTUM. A wave of six at a dial of four runs four,
137
+ // then idles three workers until the SLOWEST of those four lands, then runs two. Nothing about
138
+ // the scopes asked for that; it falls out of splitting a list instead of scheduling it.
139
+ // 2. A wave is a LEVEL, and a level says when a scope is definitely safe to start, never when it
140
+ // BECAME safe. The scope that wires the others to the entry point waits for the slowest command
141
+ // scope even when the one scope it actually consumes went green minutes earlier.
260
142
  //
261
- // An unmet post-condition ABORTS, naming the phase. `aborted` is a union member that already
262
- // exists and needs no adjudication machinery: a `paused` would relaunch into the same order and
263
- // hit the same wall, because nothing persists an answer between launches. Aborting puts the
264
- // question in front of a human once, instead of looping silently.
265
- const requirePhase = async (slug, gate, phaseKey) => {
266
- const r = await mech(
267
- `node "${args.pluginRoot}/skills/tech-lead/scripts/resume-state.mjs" --slug ${slug} --require ${phaseKey}`,
268
- `require:${phaseKey}`,
269
- );
270
- if (r.exit_code === 0) return null;
271
- const missing = parseMechJson(r.stdout)?.required_artifact || phaseKey;
272
- return {
273
- status: "aborted",
274
- aborted_at: gate,
275
- reason: `${gate} produced no artifact: ${missing} is not on disk after the phase ran and its result was ingested. `
276
- + "The phase did not complete — its worker most likely escalated (a WorkResult may report `escalated` with `artifacts: []`), "
277
- + "and because completion is derived from the artifact, every relaunch would re-dispatch this phase and escalate again. "
278
- + "Read the phase's result to see what it could not complete, resolve it, then relaunch.",
143
+ // Everything the previous loop bought with blood survives, and each is named at the line that keeps
144
+ // it: a scope never starts beside a scope it consumes; no scope is ever dropped; a dead builder is a
145
+ // spent attempt rather than a dead run; the dial is honoured exactly.
146
+ //
147
+ // This region is self-contained on purpose. The scheduling decision is PURE — no dispatch, no clock,
148
+ // no randomness so it is the one part of this file that can be executed against a fixture instead
149
+ // of reasoned about, and the round loop below injects the real dispatcher as `launch`.
150
+
151
+ /**
152
+ * The dependency relation the fan-out releases on, as `scope_id → the scope_ids it waits for`.
153
+ *
154
+ * Three rungs, each a strict non-regression on the next. The edge list is used only when it
155
+ * re-levels to the SAME waves: both fields are projections of one graph the kernel parsed once, so
156
+ * each validates the other, and an edge dropped or invented in transit lands on the wave rung rather
157
+ * than releasing a scope early which is the one direction this must never fail in.
158
+ *
159
+ * @param {*} rawDeps - `probe resume`'s `scope_deps`: `[dependant, dependency]` path pairs.
160
+ * @param {Array[]} waves - Validated dependency waves, arrays of scope objects.
161
+ * @param {Array} list - Every scope in the round.
162
+ * @returns {Map<string,Set<string>>} One entry per scope; an empty set means "ready immediately".
163
+ */
164
+ function scopeEdges(rawDeps, waves, list) {
165
+ const known = new Set(list.map((s) => s.scope_id));
166
+ const idOf = (p) => String(p).split("/").pop().replace(/\.(md|json)$/, "");
167
+ const blank = () => new Map(list.map((s) => [s.scope_id, new Set()]));
168
+
169
+ // Kahn. A CYCLE IS THE ONE INPUT THAT TURNS THIS SCHEDULER INTO A HANG rather than a failure: two
170
+ // scopes awaiting each other produce no error, no log line and no artifact, which a list-chunking
171
+ // loop could not do. So an edge set is used only when every node peels.
172
+ const levelsOf = (m) => {
173
+ const done = new Set(), out = [];
174
+ for (;;) {
175
+ const ready = list.filter((s) => !done.has(s.scope_id) && [...m.get(s.scope_id)].every((d) => done.has(d)));
176
+ if (!ready.length) break;
177
+ out.push(ready.map((s) => s.scope_id).sort());
178
+ for (const s of ready) done.add(s.scope_id);
179
+ }
180
+ return done.size === list.length ? out : undefined;
279
181
  };
280
- };
182
+ const shape = (levels) => (levels ? JSON.stringify(levels) : "");
281
183
 
282
- // The ledger's `status` field. Two things changed here, and the second is the point.
283
- //
284
- // It is no longer this file's resume oracle — the fast-forward reads ARTIFACTS (probe above), the
285
- // way WIRE and MAP SCOPES always did. `status` survives because it has other readers that would
286
- // otherwise lose their only signal: run-snapshot.mjs and hooks/anti-rationalization.mjs both hold
287
- // a MID_RUN set over these values to tell a run in flight from a finished one.
288
- //
289
- // And its outcome is no longer discarded. This was one of exactly two mech() call sites in this
290
- // file whose return value nobody read, and both of them failed silently for two entire runs
291
- // (46 dispatched agents, status pinned at "orienting" throughout). resume-state.mjs now reads the
292
- // ledger back after writing it and exits non-zero if the value did not take; a failure is logged
293
- // loudly and the run continues, because bookkeeping that lost its write is a degraded digest, not
294
- // a corrupted build. The pointer below takes the opposite policy, for the reason stated there.
295
- //
296
- // ⟐ The second half of that read-back: the warning also travels in the RunReturn.
297
- // `log()` goes to the progress narrator, and a headless `claude -p` stdout carries only the final
298
- // message — so when the A2 probe returned `shipped` over a ledger still reading `evaluating`, the
299
- // evidence could not say whether the failure had been reported at all.
300
- // A diagnostic that only exists on a channel the operator cannot read is not a diagnostic.
301
- const stateWarnings = [];
302
- const setRunStatus = async (slug, status) => {
303
- const r = await mech(
304
- `node "${args.pluginRoot}/skills/tech-lead/scripts/resume-state.mjs" --slug ${slug} --set-status ${status}`,
305
- `status:${status}`,
306
- );
307
- if (r.exit_code !== 0) {
308
- const why = (parseMechJson(r.stdout)?.reason || r.stderr || `exit ${r.exit_code}`).toString().trim();
309
- log(`RUN STATE — status="${status}" did not take: ${why}. The run continues (resume is derived from artifacts, not from this field), but run-snapshot and the anti-rationalization hook will read this run as unfinished.`);
310
- stateWarnings.push(`status="${status}" did not take: ${why}`);
184
+ if (Array.isArray(rawDeps) && rawDeps.length) {
185
+ const m = blank();
186
+ let edges = 0;
187
+ for (const pair of rawDeps) {
188
+ if (!Array.isArray(pair) || pair.length < 2) continue;
189
+ const to = idOf(pair[0]), from = idOf(pair[1]);
190
+ if (to === from || !known.has(to) || !known.has(from)) continue;
191
+ m.get(to).add(from);
192
+ edges++;
193
+ }
194
+ const want = shape(waves.map((w) => w.map((s) => s.scope_id).sort()));
195
+ if (edges && shape(levelsOf(m)) === want) return m;
196
+ log(`BUILD order the dependency edge list does not re-derive the wave order (${edges} usable ` +
197
+ `edge(s)); releasing per wave instead, which is what a wave-stepped fan-out did.`);
311
198
  }
312
- return r;
313
- };
314
199
 
315
- /** Attach any lost bookkeeping writes to a terminal RunReturn. Only the two returns that mean
316
- * "the run did its work" carry it an `aborted` already names its cause on the same channel. */
317
- const withStateWarnings = (ret) => (stateWarnings.length ? { ...ret, state_warnings: stateWarnings } : ret);
200
+ // WAVE RUNG. "Everything in wave i waits for everything in wave i-1" — the exact release points a
201
+ // wave-stepped loop had, and acyclic by construction. An absent or unusable edge list costs the
202
+ // per-edge release and nothing else; a single wave yields no edges at all, which is the fully
203
+ // unscheduled fan-out. A scheduler that refuses to run is worse than one that runs unscheduled.
204
+ const m = blank();
205
+ for (let i = 1; i < waves.length; i++) {
206
+ for (const to of waves[i]) for (const from of waves[i - 1]) m.get(to.scope_id).add(from.scope_id);
207
+ }
208
+ return m;
209
+ }
318
210
 
319
- // ---------------------------------------------------------------------------------------------
320
- // The uniform dispatch shape (C3/C4) identical structure for every worker in the Operation
321
- // vocabulary (domain.schema.json $defs/Operation): compile an order, dispatch a fresh agent that
322
- // reports back a schema-forced subset of what it wrote, ingest the result. The schema is the
323
- // only thing that varies per worker it is what a gate needs to know, nothing more (the
324
- // zero-memory-handoff boundary, PA6: the prompt below carries only the order path).
325
- // ---------------------------------------------------------------------------------------------
326
- const compile = (flags, label) => mech(
327
- `node "${args.pluginRoot}/skills/tech-lead/scripts/compile-order.mjs" ${flags}`,
328
- label,
329
- );
330
- const ingest = (resultPath, label) => mech(
331
- `node "${args.pluginRoot}/skills/tech-lead/scripts/ingest-result.mjs" ${resultPath}`,
332
- label,
333
- );
211
+ /**
212
+ * Forbid two scopes that may write the same path from building at the same time.
213
+ *
214
+ * WHY THIS IS THE SCHEDULER'S JOB AND NOBODY ELSE'S. `shared_substrate` is the declared escape hatch
215
+ * from the disjointness rule: the spec lint passes an overlap both contracts declare, and the
216
+ * sandbox guard permits that path to every live order naming it. Both layers are individually
217
+ * correct and the join is wrong — concurrent writers to one shared entry point lose each other's
218
+ * work, measured in every trial, with every check green. No lint can catch it because nothing is
219
+ * mis-declared; the only place the fact "these two are running RIGHT NOW" exists is here.
220
+ *
221
+ * AN EXCLUSION IS NOT A DEPENDENCY. Neither scope has to go first — they only have to not overlap —
222
+ * so each pair is oriented by BUILD POSITION, later waits for earlier. That orientation is what
223
+ * makes it safe to mix with real dependency edges: every dependency edge already points backwards
224
+ * in this list (it is the waves flattened, so a scope's dependencies are strictly earlier), and an
225
+ * exclusion oriented the same way keeps the whole set pointing backwards, which cannot cycle.
226
+ *
227
+ * DEGRADES LIKE EVERYTHING ELSE HERE, but note which way. Absent or unreadable pairs mean no
228
+ * exclusions and today's scheduling — a scheduler that refuses to run is worse than one that runs
229
+ * unscheduled. What is NOT acceptable is the opposite reading: when the pairs ARE there, the edge is
230
+ * enforced rather than warned about, because a run that quietly loses work is worse than both.
231
+ *
232
+ * @param {Map<string,Set<string>>} edges - Release edges so far; mutated and returned.
233
+ * @param {*} rawPairs - `probe resume`'s `scope_exclusions`: unordered path pairs.
234
+ * @param {Array} order - The build order. Position in it decides which side of a pair waits.
235
+ * @returns {{edges: Map<string,Set<string>>, added: number}} The composed set and how many landed.
236
+ */
237
+ function withExclusions(edges, rawPairs, order) {
238
+ if (!Array.isArray(rawPairs) || !rawPairs.length) return { edges, added: 0 };
239
+ const idOf = (p) => String(p).split("/").pop().replace(/\.(md|json)$/, "");
240
+ const at = new Map(order.map((s, i) => [s.scope_id, i]));
241
+ let added = 0;
242
+ for (const pair of rawPairs) {
243
+ if (!Array.isArray(pair) || pair.length < 2) continue;
244
+ const a = idOf(pair[0]), b = idOf(pair[1]);
245
+ if (a === b || !at.has(a) || !at.has(b)) continue;
246
+ const [first, later] = at.get(a) < at.get(b) ? [a, b] : [b, a];
247
+ if (edges.get(later)?.has(first)) continue; // already ordered by a real edge
248
+ edges.get(later).add(first);
249
+ added++;
250
+ }
251
+ return { edges, added };
252
+ }
334
253
 
335
- // ingest-result.mjs is the SINGLE WRITER of the board, the discovery ledger and the verdict record
336
- // (AGENTS.md invariant #3). Every call site here used to discard its outcome, which is the
337
- // same defect class the kill/resume probe found in the two state writes, sitting on the one script
338
- // whose failure matters most: an ingest that fails unnoticed leaves shared state describing work
339
- // that never landed, while the pipeline proceeds as though it had a green board over an
340
- // unapplied result. Every phase checks it now. The policy follows the architecture, exactly as the
341
- // dead-worker policy above does: a PHASE ingest is fatal (the phase produced nothing the run can
342
- // stand on), a BUILD-attempt ingest is a spent attempt (the budget is the instrument for that),
343
- // and a QA ingest is logged (QA is a level-up, not a gate).
344
- const ingestFailure = (r, label) => (
345
- r.exit_code === 0 ? null : `${label} did not apply: ${(r.stderr || r.stdout || `exit ${r.exit_code}`).toString().trim().slice(0, 300)}`
346
- );
254
+ /**
255
+ * The widest set of scopes this release graph can have open at one moment.
256
+ *
257
+ * WHY A RUN NEEDS THIS BEFORE IT DISPATCHES ANYTHING. The dial says how many legs are PAID FOR; this
258
+ * says how many the constraints actually permit, and the two are not the same number. A feature
259
+ * whose entry point sits in five scopes' substrate has a ceiling of ONE however wide the dial is
260
+ * opened that is a scope-cutting fact, decided at the board review, and without this line it shows
261
+ * up hours later as a slow round that reads exactly like slow workers.
262
+ *
263
+ * It also splits the concurrency shortfall into its two causes, which no single observation can. A
264
+ * peak below the CEILING is a dispatch or runtime limit; a ceiling below the DIAL is the scope cut.
265
+ * One number each, and the repair is different.
266
+ *
267
+ * The measure is the widest level of the release DAG, which is a guarantee rather than an estimate:
268
+ * every member of a level has all its predecessors in earlier levels, so a level's whole width is
269
+ * simultaneously admissible.
270
+ *
271
+ * @param {Map<string,Set<string>>} edges - The composed release set.
272
+ * @param {Array} items - The scopes in the round.
273
+ * @returns {number} Widest simultaneously-admissible set; 1 when nothing may overlap.
274
+ */
275
+ function releaseCeiling(edges, items) {
276
+ const done = new Set();
277
+ let widest = 1;
278
+ for (;;) {
279
+ const ready = items.filter((s) => !done.has(s.scope_id) && [...(edges.get(s.scope_id) || [])].every((d) => done.has(d)));
280
+ if (!ready.length) break;
281
+ widest = Math.max(widest, ready.length);
282
+ for (const s of ready) done.add(s.scope_id);
283
+ }
284
+ return widest;
285
+ }
347
286
 
348
- // WHERE A DISPATCH'S RESULT IS — derived from the order, never taken from the worker's report.
349
- //
350
- // The envelope port names it: compile-order.mjs writes `orders/<suffix>.json` and every worker
351
- // writes `results/<suffix>.json` (its own SKILL.md says so — "`.${"shapeup"}/<slug>/results/
352
- // <order-suffix>.json`"). The pairing is a FACT of the port, and the kill/resume probe's own
353
- // assertions are set operations over exactly that pairing.
354
- //
355
- // Until now this file passed the worker's self-reported `result_path` to ingest instead. Measured,
356
- // Observed in a probe run: ORIENT wrote `results/orient.json` correctly and reported a DIRECTORY as
357
- // its path; ingest read it, got EISDIR, and the run aborted at the first phase — `{"status":
358
- // "aborted","aborted_at":"ORIENT","reason":"ingest:orient did not apply: ✗ result unreadable:
359
- // EISDIR"}`. A phase that had done its whole job was thrown away on a claim about where it had put
360
- // the work. Same lesson as the post-condition above, one field over: derive the fact, do not accept
361
- // the claim. The schema still asks for `result_path` — it is a cross-check now, logged when it
362
- // disagrees, never the thing the pipeline acts on.
363
- const baseOf = (p) => String(p || "").trim().split("/").pop();
364
- const resultFor = (orderPath, reported, label) => {
365
- const derived = orderPath.replace("/orders/", "/results/");
366
- if (reported && baseOf(reported) !== baseOf(derived)) {
367
- log(`${label} the worker reported result_path "${reported}", which is not "${baseOf(derived)}". Ingesting the derived path; the order's own name is the fact.`);
287
+ /**
288
+ * Run `launch` over every item, at most `width` at once, releasing each item as soon as ITS OWN
289
+ * dependencies have settled rather than when its whole wave has.
290
+ *
291
+ * Release is on SETTLED, not on green, which is deliberate and matches what a wave-stepped loop did.
292
+ * Gating on green is strictly stronger and costs more than it buys: a dependency that fails would
293
+ * starve every dependant of any attempt at all, and those scopes would leave the round's census
294
+ * entirely an outcome class the gate, the hill projection and the ledger have no reading for. What
295
+ * must never happen is a scope building CONCURRENTLY with a scope it consumes, and that is what the
296
+ * dependency await forbids.
297
+ *
298
+ * No race, no timer, no clock deliberately. `Promise.race` over the in-flight legs would tighten
299
+ * the schedule marginally and is disqualified for the same reason a clock read is: it makes the
300
+ * schedule a function of real time, so a relaunch reschedules differently.
301
+ *
302
+ * @param {Array} items - The scopes, in the order the waves put them.
303
+ * @param {Map<string,Set<string>>} edges - scope_id → the scope_ids it waits for.
304
+ * @param {number} width - Concurrency cap.
305
+ * @param {function(object): Promise<object>} launch - Dispatches one scope. May reject; may not be
306
+ * trusted to return anything.
307
+ * @returns {Promise<object[]>} Exactly one settled record per item, in input order. Never rejects.
308
+ */
309
+ async function scheduleScopes(items, edges, width, launch) {
310
+ let free = Math.max(1, Number(width) || 1);
311
+ const waiting = [];
312
+ const acquire = () => (free > 0 ? (free--, Promise.resolve()) : new Promise((r) => waiting.push(r)));
313
+ const release = () => { const next = waiting.shift(); if (next) next(); else free++; };
314
+
315
+ // TWO COLLECTIONS, and the split is the difference between "one entry per item" and "one entry per
316
+ // id". `runs` is POSITIONAL, so two scopes that somehow carry the same id are two legs and two
317
+ // records; keying the settle by id instead would silently run one of them and report it twice,
318
+ // which is a dropped scope wearing the right name. `started` is by id because that is how an edge
319
+ // names its dependency, and it keeps the first promise for an id — a duplicate cannot make a
320
+ // dependant wait on the wrong one.
321
+ const runs = [];
322
+ const started = new Map();
323
+
324
+ async function runOne(item) {
325
+ // THE DEPENDENCY WAIT HAPPENS BEFORE THE SLOT ACQUIRE, and that ordering is the whole reason
326
+ // the window cannot deadlock against the edges: a waiting scope occupies no capacity, so the
327
+ // window can never fill with scopes that are all waiting on one another.
328
+ const deps = [...(edges.get(item.scope_id) || [])].map((d) => started.get(d)).filter(Boolean);
329
+ if (deps.length) await Promise.all(deps);
330
+ await acquire();
331
+ try {
332
+ const res = await launch(item);
333
+ // The runtime drops an item whose stage returned no value and leaves an empty slot in the
334
+ // settled list. Name that by itself: an empty slot and a dead worker are different repairs.
335
+ return res || { scope_id: item.scope_id, __failed: "the runtime dropped this leg — a pipeline stage returned no value" };
336
+ } catch (e) {
337
+ // A DEAD BUILDER IS A SPENT ATTEMPT, NOT A DEAD RUN. Rethrowing would reject every other
338
+ // scope's promise through the settle below and discard a whole round of green work.
339
+ return { scope_id: item.scope_id, __failed: `${item.scope_id}: ${(e && e.message) || String(e)}` };
340
+ } finally {
341
+ // In `finally`, so a leg that dies before returning does not narrow the window permanently.
342
+ release();
343
+ }
368
344
  }
369
- return derived;
345
+
346
+ // TWO PHASES, because one phase is a silently dropped edge. A body reads its dependencies' promises
347
+ // out of `started` on its first line; built in a single pass, a dependency declared LATER in the
348
+ // list is not there yet, its edge is discarded as unknown, and the scope that edge was protecting
349
+ // starts early — the exact failure the edge exists to prevent, with no diagnostic anywhere.
350
+ const begin = [];
351
+ for (const item of items) {
352
+ let open;
353
+ const gate = new Promise((r) => { open = r; });
354
+ const leg = gate.then(() => runOne(item));
355
+ runs.push(leg);
356
+ if (!started.has(item.scope_id)) started.set(item.scope_id, leg);
357
+ begin.push(open);
358
+ }
359
+ for (const open of begin) open();
360
+
361
+ // ONE ENTRY PER ITEM, IN INPUT ORDER, structurally — no edge, no failure, no dial setting and no
362
+ // repeated id can drop a scope out of the round's census.
363
+ return Promise.all(runs);
364
+ }
365
+ // ---- SCHEDULER REGION END --------------------------------------------------------------------
366
+
367
+ // ---------------------------------------------------------------------------------------------
368
+ // SCHEMAS — the only contract between this control script and a sub-agent. The runtime forces the
369
+ // agent's final message to validate against these, which is the entire reason no stdout is parsed
370
+ // anywhere in this file.
371
+ //
372
+ // A SCHEMA HERE IS ONE HALF OF A CONTRACT, and the other half is a kernel subcommand's stdout. The
373
+ // runtime validates the agent's report against the shape declared BELOW, never against the shape the
374
+ // kernel actually prints, so the two can disagree indefinitely: the courier sub-agent sits between
375
+ // them and quietly coerces whatever it was handed into whatever it was asked for. That is not a
376
+ // hypothetical — `scope_files` was declared here as an array of strings against a kernel that emits
377
+ // `{scope_id, path}` objects, and it "worked" for the life of the file. The region markers are the
378
+ // seam the structural suite reads these declarations through, so the two halves can be compared by
379
+ // something other than a reader's eye.
380
+ // ---- SCHEMA REGION START -----------------------------------------------------------------------
381
+ const nullable = (t) => ({ type: [t, "null"] });
382
+
383
+ /** A kernel subcommand a sub-agent ran in its own shell, reported as data. */
384
+ const CMD = {
385
+ type: "object",
386
+ properties: {
387
+ exit_code: { type: "integer" },
388
+ ok: { type: "boolean" },
389
+ detail: { type: "string" },
390
+ // The machine value, copied CHARACTER FOR CHARACTER out of the command's own JSON. Separate
391
+ // from `detail` on purpose: `detail` is prose for a human to read, this is a token the control
392
+ // plane branches on, and collapsing the two is what made every gate comparison silently false.
393
+ decision: { type: "string" },
394
+ },
395
+ required: ["exit_code", "ok"],
396
+ };
397
+
398
+ /** `harness probe resume` — the fast-forward derivation, every field artifact-derived. */
399
+ const RESUME = {
400
+ type: "object",
401
+ properties: {
402
+ intake_path: nullable("string"), spec_folder: nullable("string"), orient_dir: nullable("string"),
403
+ project_profile_path: nullable("string"), status: nullable("string"),
404
+ lens: nullable("string"), stack: nullable("string"),
405
+ run_cmd: nullable("string"), app_url: nullable("string"),
406
+ eval_dimensions: { type: "array", items: { type: "string" } },
407
+ has_orient_artifacts: { type: "boolean" },
408
+ has_spec_tree: { type: "boolean" },
409
+ has_wiring_map: { type: "boolean" },
410
+ has_project_profile: { type: "boolean" },
411
+ // THE SHAPE THE KERNEL WRITES, not a convenient one. `probe resume` emits `{scope_id, path}` per
412
+ // contract and `ResumeState` declares exactly that; this declared an array of strings and the
413
+ // call site below split each entry as a path. Nothing failed and nothing could: the courier
414
+ // sub-agent sits between the kernel's stdout and this schema, so it coerced the objects into
415
+ // whatever it was asked for, on every run, for the life of the file. The failure that shape
416
+ // invites is silent in the other direction — a coercion that DROPS the entries reads as zero
417
+ // scopes, which this file cannot distinguish from a run whose contracts were never written, so
418
+ // MAP SCOPES is re-dispatched over contracts already on disk.
419
+ scope_files: {
420
+ type: "array",
421
+ items: {
422
+ type: "object",
423
+ properties: { scope_id: { type: "string" }, path: { type: "string" } },
424
+ required: ["scope_id", "path"],
425
+ },
426
+ },
427
+ // Additive: the same scopes grouped into dependency waves. Absent or unusable → one wave.
428
+ scope_waves: { type: "array", items: { type: "array", items: { type: "string" } } },
429
+ // The same relation as edges — `[dependant, dependency]` pairs. A wave says when a scope is
430
+ // definitely safe to start; an edge says when it BECAME safe. Absent or unusable → the waves'
431
+ // own release points, which is what a wave-stepped fan-out had.
432
+ scope_deps: { type: "array", items: { type: "array", items: { type: "string" } } },
433
+ // Pairs that may write the same declared-shared path, so they may not build at the same time.
434
+ // Not an ordering — either may go first. Absent or unusable → no exclusion, today's scheduling.
435
+ scope_exclusions: { type: "array", items: { type: "array", items: { type: "string" } } },
436
+ eval_rounds_done: { type: "array", items: { type: "integer" } },
437
+ next_phase: nullable("string"),
438
+ },
439
+ required: ["has_orient_artifacts", "has_spec_tree", "has_wiring_map", "scope_files", "eval_rounds_done"],
370
440
  };
371
- const ingestOrAbort = async (gate, resultPath, label) => {
372
- const why = ingestFailure(await ingest(resultPath, label), label);
373
- return why ? { status: "aborted", aborted_at: gate, reason: `${why} — the board and ledger do not reflect this phase, so nothing downstream can be trusted to read them` } : null;
441
+ // ---- SCHEMA REGION END -------------------------------------------------------------------------
442
+
443
+ /** `harness reduce graph --subgraph run` — the bounded read model a round opens with. */
444
+ const SUBGRAPH = {
445
+ type: "object",
446
+ properties: {
447
+ run: nullable("string"),
448
+ scopes: { type: "array", items: { type: "string" } },
449
+ requirements: { type: "array", items: { type: "string" } },
450
+ orders: { type: "integer" },
451
+ pending_orders: { type: "array", items: { type: "string" } },
452
+ rounds_with_green: { type: "array", items: { type: "integer" } },
453
+ green_scopes_by_round: { type: "object" },
454
+ trials: { type: "integer" },
455
+ edges: { type: "integer" },
456
+ },
457
+ required: ["scopes", "rounds_with_green", "green_scopes_by_round"],
374
458
  };
375
- // Same null contract as mech() above: a worker that is skipped or dies after retries yields null,
376
- // and `<result>.result_path` on null throws killing the workflow with `status: "failed"`, which
377
- // is not a RunReturn member. A dead worker is an ABORT with a name, not a crash: `__failed` is
378
- // checked at every call site below and converted into `{status:"aborted", aborted_at, reason}`,
379
- // so the PO always receives a union member that says which phase died.
380
- //
381
- // THE PROMPT NAMES THE RESULT PATH, because the ORDER does not. Measured across two consecutive
382
- // ORIENT dispatches showed two different failures with one cause:
383
- // leg 1a the worker wrote results/orient.json and reported a DIRECTORY as its path
384
- // leg 1b the worker wrote all four orient artifacts and no result file at all
385
- // A compiled WorkOrder carries `order_id`, `substrate`, and `payload` — and nothing that says where
386
- // the WorkResult goes. Every worker SKILL.md documents the convention ("`.${"shapeup"}/<slug>/results/
387
- // <order-suffix>.json`"), so the worker is left to derive a path from a convention while its own
388
- // order's `substrate.allowed` names a directory that does not contain it. Two workers guessed
389
- // differently and both legs died at phase one.
390
- //
391
- // This states it, in the one place a zero-memory subagent cannot miss: its dispatch prompt. It is
392
- // the same path `resultFor` ingests, so the two agree by construction. Putting `result_path` INTO
393
- // the WorkOrder is the deeper fix and it belongs to whoever next opens the envelope schema — it
394
- // touches compile-order.mjs, domain.schema.json and every worker's input contract, which is a wider
395
- // diff than this change should take. Recorded as a discovered defect, not silently
396
- // worked around.
397
- const dispatch = async (skill, orderPath, model, phase, label, schema, extra) => {
398
- const setOrderR = await mech(
399
- `node "${args.pluginRoot}/skills/tech-lead/scripts/resume-state.mjs" --slug ${slug} --set-active-order ${orderPath}`,
400
- `set-active-order:${baseOf(orderPath)}`,
401
- );
402
- if (setOrderR.exit_code !== 0) {
403
- const why = (parseMechJson(setOrderR.stdout)?.reason || setOrderR.stderr || `exit ${setOrderR.exit_code}`).toString().trim();
404
- log(`Warning: could not set active-order to ${orderPath}: ${why}`);
405
- }
406
- const r = await agent(
407
- `Call Skill(shapeup-sdlc-plugin:${skill}) --order ${orderPath}. ${extra || ""} ` +
408
- `Write your WorkResult to exactly this path: ${orderPath.replace("/orders/", "/results/")} — ` +
409
- "that file is what the pipeline ingests, and a phase whose result is missing is treated as a phase that did not run. " +
410
- "Report back exactly the fields requested — nothing else travels outside the order/result files.",
411
- { model, phase, label, schema },
412
- );
413
- return (r && typeof r === "object") ? r : { __failed: `${skill} dispatch returned no result (skipped, blocked, or died) at ${label}` };
459
+
460
+ /** `harness probe t0` has this scope already gone green in this round? */
461
+ const T0CHECK = {
462
+ type: "object",
463
+ properties: { green: { type: "boolean" }, path: nullable("string") },
464
+ required: ["green"],
414
465
  };
415
466
 
416
- /** The abort a dead worker earns: a named phase, never a crash. */
417
- const dispatchAborted = (gate, r) => ({ status: "aborted", aborted_at: gate, reason: r.__failed });
467
+ /** `probe leg` did this scope's result reach the board, or is it finished work nothing applied? */
468
+ const LEGCHECK = {
469
+ type: "object",
470
+ properties: {
471
+ closed: { type: "boolean" },
472
+ orders_total: { type: "integer" },
473
+ results_total: { type: "integer" },
474
+ applied_total: { type: "integer" },
475
+ unapplied: { type: "array", items: { type: "string" } },
476
+ },
477
+ required: ["closed"],
478
+ };
418
479
 
419
- const ORIENT_SCHEMA = {
480
+ const ORIENT = {
420
481
  type: "object",
421
482
  properties: {
422
- result_path: { type: "string" },
423
- spiked_area: { type: "string" },
424
- spike_result: { type: "string" },
483
+ ok: { type: "boolean" }, artifact_written: { type: "boolean" },
484
+ spiked_area: { type: "string" }, spike_result: { type: "string" },
425
485
  riskiest_unknowns: { type: "array", items: { type: "string" } },
426
486
  },
427
- required: ["result_path", "spiked_area", "spike_result"],
487
+ required: ["ok", "artifact_written", "spiked_area", "spike_result"],
488
+ };
489
+
490
+ /** analyze / wire — "did the artifact land?" is all a gate needs from them. */
491
+ const PHASE_OK = {
492
+ type: "object",
493
+ properties: { ok: { type: "boolean" }, artifact_written: { type: "boolean" }, detail: { type: "string" } },
494
+ required: ["ok", "artifact_written"],
495
+ };
496
+
497
+ const MAPSCOPES = {
498
+ type: "object",
499
+ properties: {
500
+ ok: { type: "boolean" }, artifact_written: { type: "boolean" },
501
+ scopes: {
502
+ type: "array",
503
+ items: {
504
+ type: "object",
505
+ properties: { scope_id: { type: "string" }, path: { type: "string" } },
506
+ required: ["scope_id", "path"],
507
+ },
508
+ },
509
+ },
510
+ required: ["ok", "artifact_written", "scopes"],
511
+ };
512
+
513
+ /** One scope's whole attempt ratchet, run inside the worker leg (see buildScope). */
514
+ const SCOPE_RESULT = {
515
+ type: "object",
516
+ properties: {
517
+ scope_id: { type: "string" },
518
+ green: { type: "boolean" },
519
+ t0_artifact: nullable("string"),
520
+ attempts_used: { type: "integer" },
521
+ breaker: { type: "string", enum: ["none", "stagnation", "attempt_budget"] },
522
+ reason: { type: "string" },
523
+ },
524
+ required: ["scope_id", "green", "attempts_used", "breaker"],
525
+ };
526
+
527
+ const EVAL = {
528
+ type: "object",
529
+ properties: {
530
+ ok: { type: "boolean" },
531
+ overall: { type: "string", enum: ["PASS", "FAIL"] },
532
+ findings: {
533
+ type: "array",
534
+ items: {
535
+ type: "object",
536
+ properties: { id: { type: "string" }, criterion: { type: "string" }, evidence: { type: "string" } },
537
+ required: ["id", "criterion", "evidence"],
538
+ },
539
+ },
540
+ },
541
+ required: ["ok", "overall"],
428
542
  };
429
- const RESULT_ONLY_SCHEMA = { type: "object", properties: { result_path: { type: "string" } }, required: ["result_path"] };
430
- const MAPSCOPES_SCHEMA = {
543
+
544
+ // The round-loop branch reads THIS, not `EVAL` above, for the pass/fail decision. `EVAL` shapes
545
+ // the dispatching agent's own end-of-turn summary of a 3-step dispatch (compile, dispatch,
546
+ // ingest) — schema-checked for SHAPE only, never re-verified against the WorkResult `reduce
547
+ // ingest` just wrote. Measured live (2026-08-19, todo-cli): that summary reported the round as
548
+ // passing while `results/evaluate-r1.json`'s own `verdict.overall` said `"FAIL"` (2 cited bugs),
549
+ // and the round loop broke straight to QA/GATE H on the strength of the summary alone — the same
550
+ // self-report-instead-of-artifact-read shape this file already fixed once for gate DECISIONS
551
+ // (see the comment above `crossGate`), just never extended to the EVAL verdict itself. `probe
552
+ // eval` is `harness probe eval`'s own output — a narrow, single-purpose "transcribe this JSON
553
+ // verbatim" query over the WorkResult on disk, the same pattern `probe t0`/`probe resume` already
554
+ // use everywhere else a fact has to cross the no-filesystem boundary.
555
+ const EVAL_VERDICT = {
431
556
  type: "object",
432
557
  properties: {
433
- result_path: { type: "string" },
434
- scopes: { type: "array", items: { type: "object", properties: { scope_id: { type: "string" }, path: { type: "string" } }, required: ["scope_id", "path"] } },
558
+ ok: { type: "boolean" },
559
+ overall: nullable("string"),
560
+ bug_count: nullable("integer"),
561
+ report_path: nullable("string"),
562
+ round: { type: "integer" },
435
563
  },
436
- required: ["result_path", "scopes"],
564
+ required: ["ok", "round"],
437
565
  };
438
- const QA_SCHEMA = {
566
+
567
+ const REFUTATION = {
439
568
  type: "object",
440
- properties: { result_path: { type: "string" }, findings_count: { type: "integer" } },
441
- required: ["result_path", "findings_count"],
569
+ properties: { id: { type: "string" }, refuted: { type: "boolean" }, why: { type: "string" } },
570
+ required: ["id", "refuted"],
442
571
  };
443
- const HAMMER_SCHEMA = {
572
+
573
+ const QA_REPORT = {
574
+ type: "object",
575
+ properties: { ok: { type: "boolean" }, findings_count: { type: "integer" } },
576
+ required: ["ok", "findings_count"],
577
+ };
578
+
579
+ const HAMMER = {
444
580
  type: "object",
445
581
  properties: {
446
- result_path: { type: "string" },
582
+ ok: { type: "boolean" },
447
583
  verdict: { type: "string", enum: ["ship-now", "ship-after-fixes", "cannot-ship"] },
448
584
  cut_list: { type: "array", items: { type: "string" } },
449
585
  },
450
- required: ["result_path", "verdict", "cut_list"],
586
+ required: ["ok", "verdict", "cut_list"],
451
587
  };
452
588
 
453
589
  // ---------------------------------------------------------------------------------------------
454
- // Gate resolution every gate crosses through gate-answers.mjs's exit code (0 cross / 4 pause /
455
- // 5 abort) — the same convention every gate in the harness uses.
590
+ // DISPATCHthree shapes, and none of them parses text.
591
+ //
592
+ // A sub-agent can be skipped by the operator or die on a terminal API error, in which case the
593
+ // runtime hands back `null`. A null is a NAMED failure here, never a crash: `status: "failed"` is
594
+ // not a member of the RunReturn union, so every call site converts it to one that is.
456
595
  // ---------------------------------------------------------------------------------------------
457
- const PRESET_NAMES = new Set(["ci", "guarded", "interactive"]);
458
- const answersFlag = (answers) => (!answers ? "" : (PRESET_NAMES.has(answers) ? `--preset ${answers}` : `--file ${answers}`));
459
-
460
- // gate-answers.mjs carries its verdict on TWO channels: the exit code says cross/pause/abort
461
- // (0/4/5), but the DECISION itself — loop|stop|run|skip|accept-cut-list — travels only in the
462
- // JSON it prints on stdout ({ gate, decision, source, note, status }). The mech() envelope is
463
- // {exit_code, stdout, stderr} and nothing else, so reading `.decision` straight off it yields
464
- // `undefined` and silently takes the else-branch at every decision-sensitive gate — QA would
465
- // never dispatch under a preset that answers it "run", and L3's "stop" arm would be dead code.
466
- // Parse it here, once, so every caller branches on a real value.
467
- const resolveGate = async (slug, answers, gate) => {
468
- const r = await mech(
469
- `node "${args.pluginRoot}/skills/tech-lead/scripts/gate-answers.mjs" --resolve ${gate} --slug ${slug} ${answersFlag(answers)}`.trim(),
470
- `gate:${gate}`,
596
+ const nullFail = (label) => ({ __failed: `${label}: sub-agent skipped, blocked, or died after retries` });
597
+
598
+ /**
599
+ * Run one kernel subcommand in a sub-agent's own shell and get its outcome as data.
600
+ *
601
+ * @param {string} verbs - Kernel verb words plus flags, e.g. `reduce hill --slug x`.
602
+ * @param {string} phaseName - Progress group.
603
+ * @param {string} label - Display label.
604
+ * @returns {Promise<{exit_code:number, ok:boolean, detail?:string}>} Never rejects.
605
+ */
606
+ async function cmd(verbs, phaseName, label) {
607
+ const r = await agent(
608
+ `Run exactly this command and nothing else:\n\n node "${KERNEL}" ${verbs}\n\n` +
609
+ `Report its exit code as exit_code, ok=true if and only if exit_code is 0, and one line of ` +
610
+ `detail. If the command printed JSON carrying a top-level "decision" key, copy that value into ` +
611
+ `decision EXACTLY as it appears — one bare token, no sentence, no quotes, no rephrasing. ` +
612
+ `Otherwise omit decision. Do not interpret, summarise or act on the command's output beyond that.`,
613
+ { model: "sonnet", effort: "low", phase: phaseName, label, schema: CMD },
471
614
  );
472
- // A gate that printed no JSON has no decision to read; a gate whose JSON the courier wrapped in
473
- // noise still does — parseMechJson keeps the second case from reading as the first.
474
- const decision = parseMechJson(r.stdout)?.decision ?? null;
475
- return { ...r, decision };
476
- };
477
- const GATE_TITLES = {
615
+ return (r && typeof r === "object") ? r : { exit_code: -1, ok: false, detail: `${label}: no result` };
616
+ }
617
+
618
+ /**
619
+ * Run a kernel subcommand whose outcome is advisory — a projection or a lint that informs but does
620
+ * not stop the run.
621
+ *
622
+ * It still LOOKS at the exit code. A command whose result nobody reads is a command whose failure
623
+ * is indistinguishable from its success, which is the defect class this whole file is arranged
624
+ * against; "advisory" means the run continues, not that nothing is recorded.
625
+ *
626
+ * @param {string} verbs - Kernel verb words plus flags.
627
+ * @param {string} phaseName - Progress group.
628
+ * @param {string} label - Display label.
629
+ * @returns {Promise<void>} Settles when the command has run.
630
+ */
631
+ async function advisory(verbs, phaseName, label) {
632
+ const r = await cmd(verbs, phaseName, label);
633
+ if (!r.ok) log(`${label} — did not complete (${r.detail || `exit ${r.exit_code}`}). The run continues; this output is a projection, not a gate.`);
634
+ }
635
+
636
+ /**
637
+ * Run a kernel subcommand whose stdout is a JSON document this script needs the fields of.
638
+ *
639
+ * The sub-agent re-reports the document against `schema`, so what reaches this file is validated by
640
+ * the runtime rather than parsed here.
641
+ *
642
+ * @param {string} verbs - Kernel verb words plus flags.
643
+ * @param {object} schema - The shape the caller branches on.
644
+ * @param {string} phaseName - Progress group.
645
+ * @param {string} label - Display label.
646
+ * @returns {Promise<(object|null)>} The validated document, or null when the agent produced none.
647
+ */
648
+ async function query(verbs, schema, phaseName, label) {
649
+ const r = await agent(
650
+ `Run exactly this command and nothing else:\n\n node "${KERNEL}" ${verbs}\n\n` +
651
+ `It prints one JSON document on stdout. Return that document's fields as the schema names ` +
652
+ `them, verbatim — do not add, rename, summarise or infer any value.`,
653
+ { model: "sonnet", effort: "low", phase: phaseName, label, schema },
654
+ );
655
+ return (r && typeof r === "object") ? r : null;
656
+ }
657
+
658
+ // A payload field that is not known yet is ABSENT, never `null`.
659
+ //
660
+ // The two contracts either side of this line disagree about how to say "unknown", and the
661
+ // disagreement is load-bearing: `probe resume` declares `stack`, `lens`, `run_cmd` and `app_url`
662
+ // nullable — null is its legitimate answer on a fresh run — while `WorkOrderPayload` types them
663
+ // `string` and marks every one of them OPTIONAL. So the contract already models unknown as an
664
+ // absent key, and forwarding the probe's `null` into it produces an order that fails its own schema
665
+ // before a worker ever sees it. Four of the seven operations this file dispatches — orient, analyze,
666
+ // evaluate, hunt — carry such a field, ORIENT among them, so the first dispatch of every fresh run
667
+ // was refused at compile. The three that carry none (wire, map-scopes, hammer) always compiled,
668
+ // which is why the fault looked intermittent rather than total.
669
+ //
670
+ // Dropping the key is the correct direction. Widening the schema to accept null would make
671
+ // `"stack": null` a valid order and push the null downstream into every worker's prompt, where each
672
+ // one would have to re-decide what a null stack means.
673
+ const compact = (payload) =>
674
+ Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== null && v !== undefined));
675
+
676
+ /**
677
+ * Dispatch one worker through the envelope port and get back the fields a gate needs.
678
+ *
679
+ * The sub-agent compiles its own WorkOrder with `harness compile`, calls the Skill against it, and
680
+ * applies its WorkResult with `harness reduce ingest` — all in its own shell. This script never
681
+ * couriers those commands, and the `verify envelope` hook still denies a dispatch whose order is
682
+ * missing or schema-invalid, because that hook fires on the Skill call the sub-agent makes.
683
+ *
684
+ * @param {object} spec - `{skill, operation, payload, schema, phase, label, model, extra}`.
685
+ * @returns {Promise<object>} The validated worker report, or a `__failed` marker.
686
+ */
687
+ async function worker({ skill, operation, payload, schema, phase: phaseName, label, model = execModel, extra = "", compile, round }) {
688
+ // The compile line is overridable because not every dispatch can be addressed the same way.
689
+ // `--operation <op> --slug <slug>` resolves its worker from `compile`'s OP_OWNER table, which
690
+ // covers the planner/judge/QA operations and NOT `execute`: a build order is addressed by scope,
691
+ // round and attempt, so the generic form exits 2 ("could not resolve --worker/--operation") for
692
+ // exactly the leg that runs most often. It used to be emitted for build legs anyway, contradicted
693
+ // one line later by `extra`'s correct `--scope …` instruction — a prompt arguing with itself.
694
+ // `--round` when the dispatch belongs to one. `compile` already suffixes the order id with it
695
+ // (`evaluate-r2`), and `probe resume` already reads `evaluate-r<N>.json` back to learn which EVAL
696
+ // rounds are done — writer and reader agreed all along and only this caller omitted the flag. The
697
+ // cost of that omission is two facts, both silent: every relaunch restarted the round counter at
698
+ // 1, because `eval_rounds_done` could never match `evaluate.json`; and round 2's evaluate result
699
+ // OVERWROTE round 1's, so a run kept only its last round's envelope.
700
+ const roundFlag = round ? ` --round ${round}` : "";
701
+ // A CALLER-SUPPLIED PAYLOAD ONLY REACHES THE ORDER ON THE GENERIC LINE. Overriding `compile`
702
+ // replaces the whole command, `--payload` included, so a caller that passes both is writing a
703
+ // field nobody will ever read — which is exactly how `payload.bugs` came to be built, filtered,
704
+ // and discarded on every fix round for the life of this file. Loud rather than fatal: the order
705
+ // is still valid, and killing a build leg over a discarded field would cost more than it saves.
706
+ if (compile && payload && Object.keys(compact(payload)).length) {
707
+ log(`DISPATCH — ${label}: payload dropped (${Object.keys(compact(payload)).join(", ")}). This ` +
708
+ `dispatch overrides its compile line, so --payload is not emitted; the order must derive ` +
709
+ `those fields itself (see harness compile).`);
710
+ }
711
+ const compileCmd = compile
712
+ || `compile --operation ${operation} --slug ${slug}${roundFlag} --payload '${JSON.stringify(compact(payload))}'`;
713
+ const r = await agent(
714
+ `You are running one step of an orchestrated build over feature slug "${slug}".\n\n` +
715
+ `1. Compile the WorkOrder:\n` +
716
+ ` node "${KERNEL}" ${compileCmd}\n` +
717
+ ` It prints the order path on stdout.\n` +
718
+ `2. Dispatch the worker against that order:\n` +
719
+ ` Skill(shapeup-sdlc-plugin:${skill}) --order "<the path from step 1>"\n` +
720
+ ` If the Skill dispatch fails or returns an error, STOP and report the error. Never work ` +
721
+ `around a failed dispatch by doing the craft yourself — the run has a receipt gate and an ` +
722
+ `improvised result is refused at step 3 anyway.\n` +
723
+ ` ${extra}\n` +
724
+ `3. Apply its WorkResult, naming the ORDER from step 1 — not any path the worker reports:\n` +
725
+ ` node "${KERNEL}" reduce ingest --order <the path from step 1>\n\n` +
726
+ `Then report ONLY the fields the schema names. Nothing else crosses this boundary — no ` +
727
+ `narration, no file contents, no summary of the work.`,
728
+ { model, phase: phaseName, label, schema, effort: "medium" },
729
+ );
730
+ return (r && typeof r === "object") ? r : nullFail(label);
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------------------------
734
+ // GATES — the kernel's exit-code convention, unchanged: 0 cross · 4 pause · 5 abort. The resolved
735
+ // decision travels in `decision`, copied verbatim out of the kernel's own JSON.
736
+ //
737
+ // IT USED TO BE READ OUT OF `detail`, which is free prose a sub-agent writes. What came back was a
738
+ // sentence — "Command exited 0; gate QA resolved decision=run from …" — and every comparison
739
+ // downstream is against a token, so `decision === "run"` and `decision === "stop"` were false on
740
+ // every run that has ever executed. The observable effect was a documented phase that silently
741
+ // never ran and a PO answer at L3 that did nothing, with no error and no artifact to notice the
742
+ // absence by. This file's own header says nothing here reads a model's prose; this was the one
743
+ // place that did, and every gate decision passed through it.
744
+ //
745
+ // An unreadable decision now ABORTS rather than defaulting. A default is what made the original
746
+ // defect invisible: "proceed" is a plausible answer at four of these gates, so the run continued
747
+ // and read as healthy. Stopping converts a silent skip into a first-run error.
748
+ // ---------------------------------------------------------------------------------------------
749
+ const PRESETS = new Set(["ci", "guarded", "interactive"]);
750
+ // The non-preset branch is an operator-supplied PATH, so it is quoted: every kernel call in this file
751
+ // is spelled into a sub-agent's prompt, and the PreToolUse envelope gate scans that prompt as well as
752
+ // a `Skill` call's args. A bare path is read together with the trailing quote of the literal around
753
+ // it, and the whole dispatch is denied.
754
+ const answersFlag = (a) => (!a ? "" : PRESETS.has(a) ? `--preset ${a}` : `--file "${a}"`);
755
+
756
+ const TITLES = {
478
757
  L1a: "Orient Review", "L1a.5": "Wiring Review", L1b: "Board Review",
479
758
  L2: "Build Round Complete", L3: "Verdict & Loop", QA: "QA Edge Hunt", H: "Decide When to Stop",
480
759
  };
481
- const gateBlock = (gate, ctx) => [
482
- `⏸ GATE ${gate} — ${GATE_TITLES[gate] || gate}`,
483
- ...Object.entries(ctx).map(([k, v]) => `${k}: ${JSON.stringify(v)}`),
484
- ].join("\n");
485
- const paused = (gate, valid_decisions, ctx) => ({ status: "paused", paused_at: gate, block: gateBlock(gate, ctx), valid_decisions, context: ctx });
486
- const abortedFrom = (gate, resolved, fallback) => {
487
- let reason = resolved.stderr.trim() || fallback;
488
- reason = parseMechJson(resolved.stdout)?.reason || reason;
489
- return { status: "aborted", aborted_at: gate, reason };
490
- };
760
+ const gateBlock = (g, ctx) =>
761
+ [`⏸ GATE ${g} — ${TITLES[g] || g}`, ...Object.entries(ctx).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)].join("\n");
762
+ const paused = (g, valid, ctx) => ({ status: "paused", paused_at: g, block: gateBlock(g, ctx), valid_decisions: valid, context: ctx });
763
+ const aborted = (g, why) => ({ status: "aborted", aborted_at: g, reason: why });
764
+ const diedAt = (g, r) => ({ status: "aborted", aborted_at: g, reason: r.__failed });
491
765
 
492
- // =================================================================================================
493
- // The run.
494
- // =================================================================================================
766
+ /**
767
+ * Resolve one gate: cross, pause, or abort.
768
+ *
769
+ * @param {string} gateId - Gate id (L1a, L2, …).
770
+ * @param {string} phaseName - Progress group.
771
+ * @param {string[]} validDecisions - What a human may answer if this pauses.
772
+ * @param {object} ctx - The facts the gate block shows.
773
+ * @returns {Promise<{stop?: object, decision?: string}>} `stop` carries a terminal RunReturn.
774
+ */
775
+ async function crossGate(gateId, phaseName, validDecisions, ctx) {
776
+ // The gate ledger keys a per-round crossing (L2, L3) on gate id + round, so it needs the round
777
+ // whenever the caller already has one to show in the block — the same value `ctx.round` carries
778
+ // for display, threaded through rather than re-derived.
779
+ const roundFlag = ctx?.round != null ? ` --round ${ctx.round}` : "";
780
+ const g = await cmd(`gate --resolve ${gateId} --slug ${slug}${roundFlag} ${answersFlag(args.answers)}`.trim(), phaseName, `gate:${gateId}`);
781
+ if (g.exit_code === 4) return { stop: paused(gateId, validDecisions, ctx) };
782
+ if (g.exit_code === 5) return { stop: aborted(gateId, g.detail || `GATE ${gateId} aborted`) };
783
+ const decision = String(g.decision ?? "").trim();
784
+ if (!validDecisions.includes(decision)) {
785
+ return {
786
+ stop: aborted(gateId,
787
+ `GATE ${gateId} exited 0 but its decision did not come back as one of ` +
788
+ `${validDecisions.join(" | ")} — got ${JSON.stringify(g.decision ?? null)}. The kernel prints ` +
789
+ `the decision as a top-level JSON key; a run must not guess it. Re-run this gate, or answer ` +
790
+ `it directly with --answers.`),
791
+ };
792
+ }
793
+ return { decision };
794
+ }
495
795
 
496
- const argProblems = validateArgs(args);
497
- if (argProblems.length) {
498
- return { status: "aborted", aborted_at: "args", reason: `shapeup-run: ${argProblems.join("; ")}` };
796
+ // Ask the completion oracle about ONE phase, as a leg in that phase's progress group. Declared
797
+ // ahead of both callers rather than between them: a `const` is not hoisted, and this file has paid
798
+ // for a temporal-dead-zone binding once already (see `findings` at the round loop).
799
+ const attest = (phaseKey, phaseName, label) =>
800
+ cmd(`probe resume --slug ${slug} --require ${phaseKey}`, phaseName, label);
801
+
802
+ /**
803
+ * The phase post-condition: the artifact is on disk, or the run stops here.
804
+ *
805
+ * Deliberately the SAME derivation the fast-forward uses, asked about one phase. Two readings of
806
+ * "is this phase done" that can disagree is the defect class, not a safeguard against it.
807
+ *
808
+ * @param {string} gate - The gate name to report the abort under.
809
+ * @param {string} phaseKey - The phase `probe resume --require` knows.
810
+ * @param {string} phaseName - Progress group.
811
+ * @returns {Promise<(object|null)>} An aborted RunReturn, or null when the artifact is there.
812
+ */
813
+ async function requirePhase(gate, phaseKey, phaseName) {
814
+ const r = await attest(phaseKey, phaseName, `require:${phaseKey}`);
815
+ if (r.exit_code === 0) return null;
816
+ return aborted(gate,
817
+ `${gate} produced no artifact: the ${phaseKey} artifact is not on disk after the phase ran and its ` +
818
+ `result was ingested. The phase did not complete — its worker most likely escalated (a WorkResult ` +
819
+ `may report "escalated" with an empty artifacts list) — and because completion is derived from the ` +
820
+ `artifact, every relaunch would re-dispatch this phase and escalate again. Read the phase's result ` +
821
+ `to see what it could not complete, resolve it, then relaunch.`);
499
822
  }
500
823
 
501
- const slug = args.slug;
502
- const qaModel = args.models.qa || args.models.exec;
503
- const allHammerProposals = [];
504
- const allGreenScopes = [];
505
- let dispatchedOrient = false, dispatchedAnalyze = false, dispatchedWire = false, dispatchedMapScopes = false;
824
+ /**
825
+ * A phase this launch did not run, said where the run is actually watched — and proved.
826
+ *
827
+ * A GATE PAUSE IS A RETURN, so the PO's answer is followed by a fresh launch of this file, and the
828
+ * runtime's progress panel is rebuilt from THAT launch's dispatches: this repo resumes off the
829
+ * artifacts under `.shapeup/`, deliberately, rather than off the runtime's own `resumeFromRunId`.
830
+ * A phase that fast-forwards used to dispatch nothing, so its box rendered `0 agents · Not started
831
+ * yet` — identical to a phase that never ran, on the one screen an operator has to decide whether a
832
+ * paused run came back correctly. Measured on a relaunch: ORIENT, WIRE and MAP SCOPES looked right
833
+ * only by accident, because a gate leg lands in each of their progress groups and earns them a tick,
834
+ * while ANALYZE — reviewed at L1b, in another group — showed as never started with its spec tree
835
+ * sitting on disk. There is no runtime call for "mark this phase complete"; a leg is the only thing
836
+ * that puts a phase in the record, so the skip has to cost one.
837
+ *
838
+ * WHICH MAKES THE LEG WORTH SPENDING ON THE ONE QUESTION THE SKIP NEVER ASKED. Every fast-forward
839
+ * decision in this file branches on a SINGLE `probe resume` snapshot taken at the top of the run,
840
+ * before ORIENT dispatches. Re-asking `--require` at the phase itself is the same oracle the
841
+ * post-condition uses, one phase over, and it converts "the state probe said so a few legs ago" into
842
+ * an attestation made where it is acted on.
843
+ *
844
+ * @param {string} gate - The gate name to report an abort under.
845
+ * @param {string} phaseKey - The phase `probe resume --require` knows.
846
+ * @param {string} phaseName - Progress group.
847
+ * @param {string} what - What is already on disk, for the narrator line.
848
+ * @returns {Promise<(object|null)>} An aborted RunReturn, or null when the artifact is really there.
849
+ */
850
+ async function fastForward(gate, phaseKey, phaseName, what) {
851
+ log(`${gate} — ${what}, fast-forwarding past it`);
852
+ const r = await attest(phaseKey, phaseName, `ff:${phaseKey}`);
853
+ if (r.exit_code === 0) return null;
854
+ return aborted(gate,
855
+ `${gate} was fast-forwarded on a state probe that reported its artifact present, and re-asking ` +
856
+ `\`probe resume --require ${phaseKey}\` at the phase itself says it is not on disk. Two readings ` +
857
+ `of "is this done" disagree, and the one taken AT the phase is the later of the two — so the run ` +
858
+ `stops here rather than building on a phase nothing can attest. Check whether the ${phaseKey} ` +
859
+ `artifact was moved or removed since this run last touched it, then relaunch.`);
860
+ }
506
861
 
507
- phase("Orient");
508
- const facts = await probe(slug);
509
- if (facts.__probe_failed) {
510
- return { status: "aborted", aborted_at: "probe", reason: `shapeup-run: ${facts.__probe_failed}` };
862
+ // The ledger's `status` field is bookkeeping, not this file's resume oracle — the fast-forward reads
863
+ // artifacts. It survives because `reduce snapshot` and the the ship report's census hook read it to tell
864
+ // a run in flight from a finished one. A lost write is a degraded digest, not a corrupted build, so
865
+ // it warns and continues — and the warning travels in the RunReturn, because a headless stdout
866
+ // carries only the final message and a diagnostic on a channel nobody reads is not a diagnostic.
867
+ const stateWarnings = [];
868
+ async function setRunStatus(status, phaseName) {
869
+ const r = await cmd(`probe resume --slug ${slug} --set-status ${status}`, phaseName, `status:${status}`);
870
+ if (!r.ok) {
871
+ const why = (r.detail || `exit ${r.exit_code}`).trim();
872
+ log(`RUN STATE — status="${status}" did not take: ${why}. The run continues (resume is derived from ` +
873
+ `artifacts, not from this field), but the snapshot and the the ship report's census hook will read ` +
874
+ `this run as unfinished.`);
875
+ stateWarnings.push(`status="${status}" did not take: ${why}`);
876
+ }
511
877
  }
878
+ const withWarnings = (ret) => (stateWarnings.length ? { ...ret, state_warnings: stateWarnings } : ret);
512
879
 
513
- // ---------------------------------------------------------------------------------------------
514
- // ORIENT (step 7) + GATE L1a — skipped when orient/ already produced its four artifacts for a
515
- // PRIOR call this run (fast-forward: never re-dispatch a phase whose artifacts already exist).
880
+ // =============================================================================================
881
+ // THE RUN
882
+ // =============================================================================================
883
+ phase("Preflight");
884
+
885
+ // CANARY — can THIS SESSION resolve a worker skill at all?
516
886
  //
517
- // This branch used to read `facts.status`, and that is the defect the kill/resume probe caught
518
- // stored state said "orienting" forever, because the
519
- // write that would have moved it produced no agent and nobody read its result so every
520
- // relaunch re-ran a phase whose artifacts were already on disk, rewriting them. The comment above
521
- // has always described the artifact test; now the code performs it, the same way WIRE reads
522
- // has_wiring_map and MAP SCOPES reads scope_files. AGENTS.md: progress is derived, never claimed.
523
- // ---------------------------------------------------------------------------------------------
524
- let spikedArea = "~", spikeResult = "~", riskiestUnknowns = [];
525
- if (!facts.has_orient_artifacts) {
887
+ // `init run` already refused if the SKILL.md files are not on disk. That is the cheap half and it
888
+ // is not the failure: in the session that produced this defect the files were sitting right there
889
+ // in the repo, and the dispatch still returned "Unknown skill" because the plugin was not loaded.
890
+ // A file check passes green on both states that actually happen installed-but-disabled, and a
891
+ // different version loaded because those copies have all ten SKILL.md files too.
892
+ //
893
+ // So one real dispatch, before any worker is paid for. Deliberately with NO `--order`: this is
894
+ // testing name resolution, not doing work, and an order would leave a compiled order with no result
895
+ // in `orders/` that every reader of that directory would then have to know about. A canary that
896
+ // perturbs the run it is clearing is not a preflight.
897
+ //
898
+ // The evidence is not the sub-agent's report — it is the hook layer's. A Skill call whose name does
899
+ // not resolve fires no hook at all, so a decision row naming the skill is proof the name resolved,
900
+ // and `verify dispatch` reads that row. The sub-agent cannot write it, so it cannot fake it.
901
+ const canarySkill = "orient";
902
+ await agent(
903
+ `Make exactly ONE tool call and nothing else: Skill(shapeup-sdlc-plugin:${canarySkill}).\n\n` +
904
+ `Pass no arguments. Do NOT act on anything the skill returns — this is a preflight that checks ` +
905
+ `the skill can be reached, not a request to do its work. Do not use any other tool.\n\n` +
906
+ `Then report whether the call returned or errored, in one line.`,
907
+ { model: "sonnet", effort: "low", phase: "Preflight", label: `canary:${canarySkill}` },
908
+ );
909
+ const canary = await cmd(`verify dispatch --skill ${canarySkill} --within 900`, "Preflight", "canary-evidence");
910
+ if (!canary.ok) {
911
+ return aborted("preflight",
912
+ `the ${canarySkill} skill did not resolve in this session — no dispatch reached the hook layer. ` +
913
+ `A run would report phases completing while the sub-agents improvised every worker's craft. ` +
914
+ `Load the plugin (\`claude --plugin-dir <repo>\`, or install and enable it) and relaunch. ` +
915
+ `(${canary.detail || `exit ${canary.exit_code}`})`);
916
+ }
917
+
918
+ phase("Orient");
919
+
920
+ const rs = await query(`probe resume --slug ${slug}`, RESUME, "Orient", "resume-state");
921
+ // A probe that produced nothing is not an EMPTY run — it is an unknown one. Treating it as empty
922
+ // would re-dispatch every phase from the top, over a run that may be in progress.
923
+ if (!rs) {
924
+ return aborted("probe", "the fast-forward derivation returned no state — refusing to re-dispatch a run that may already be in progress");
925
+ }
926
+
927
+ const specFolder = rs.spec_folder || `shapeup/${slug}/spec/`;
928
+ const evalDims = rs.eval_dimensions?.length ? rs.eval_dimensions : ["spec-conformance"];
929
+
930
+ // ---- ORIENT + GATE L1a ----------------------------------------------------------------------
931
+ let spikedArea = "~", spikeResult = "~", riskiest = [];
932
+ if (!rs.has_orient_artifacts) {
526
933
  log(`ORIENT — dispatching (slug ${slug})`);
527
- const orientOrder = await compile(
528
- `--operation orient --slug ${slug} --payload '${JSON.stringify({ pitch: facts.intake_path, spec_folder: facts.spec_folder, feature: slug, stack: facts.stack })}'`,
529
- "compile:orient",
530
- );
531
- const orientResult = await dispatch(
532
- "orient", orientOrder.stdout.trim(), args.models.exec, "Orient", "orient",
533
- ORIENT_SCHEMA, "Read/spike real code before any board exists; write the orient/ artifacts.",
534
- );
535
- if (orientResult.__failed) return dispatchAborted("ORIENT", orientResult);
536
- const orientIngest = await ingestOrAbort("ORIENT", resultFor(orientOrder.stdout.trim(), orientResult.result_path, "ingest:orient"), "ingest:orient");
537
- if (orientIngest) return orientIngest;
538
- const orientIncomplete = await requirePhase(slug, "ORIENT", "orient");
539
- if (orientIncomplete) return orientIncomplete;
540
- spikedArea = orientResult.spiked_area; spikeResult = orientResult.spike_result;
541
- riskiestUnknowns = orientResult.riskiest_unknowns || [];
542
- dispatchedOrient = true;
543
- await setRunStatus(slug, "mapping");
934
+ await setRunStatus("orienting", "Orient");
935
+ const o = await worker({
936
+ skill: "orient", operation: "orient", schema: ORIENT, phase: "Orient", label: "orient",
937
+ payload: { pitch: rs.intake_path, spec_folder: specFolder, feature: slug, stack: rs.stack },
938
+ // NAME THE FILES. "write the orient/ artifacts" was the whole instruction, while completion is
939
+ // decided by four exact filenames — so a leg that did the work and called its output
940
+ // `code-surface-map.md` and `discovered-tasks.md` aborted the run at the post-condition, having
941
+ // spiked real code and written four genuinely useful files. The skill doc states these names
942
+ // four times over; the dispatch prose stated them zero. Cheap to say, and the phase's completion
943
+ // contract belongs where the worker reads it, not only where it is enforced.
944
+ extra:
945
+ "Read and spike real code before any board exists. The phase is COMPLETE only when the run's " +
946
+ "orient directory the one this order's substrate permits — contains all four of these, " +
947
+ "named exactly: `code-surface.md`, " +
948
+ "`discovered-seed.md`, `hill-signal.md`, and one `spike-<area>.md` (or `spike-not-needed.md` " +
949
+ "when the risk scan came back rank 0). Any other filename leaves the phase incomplete and " +
950
+ "the run aborts, however good the contents are.",
951
+ });
952
+ if (o.__failed) return diedAt("ORIENT", o);
953
+ const post = await requirePhase("ORIENT", "orient", "Orient");
954
+ if (post) return withWarnings(post);
955
+ await advisory(`reduce graph --slug ${slug}`, "Orient", "graph:orient");
956
+ spikedArea = o.spiked_area; spikeResult = o.spike_result; riskiest = o.riskiest_unknowns || [];
544
957
  } else {
545
- log("ORIENT artifacts already present, skipping dispatch (fast-forward)");
958
+ const post = await fastForward("ORIENT", "orient", "Orient", "artifacts already on disk");
959
+ if (post) return withWarnings(post);
546
960
  }
547
961
 
548
- const l1a = await resolveGate(slug, args.answers, "L1a");
549
- if (l1a.exit_code === 4) return paused("L1a", ["proceed", "ask", "abort"], { spiked_area: spikedArea, spike_result: spikeResult, riskiest_unknowns: riskiestUnknowns, dispatched: dispatchedOrient });
550
- if (l1a.exit_code === 5) return abortedFrom("L1a", l1a, "GATE L1a aborted");
962
+ {
963
+ const g = await crossGate("L1a", "Orient", ["proceed", "ask", "abort"],
964
+ { spiked_area: spikedArea, spike_result: spikeResult, riskiest_unknowns: riskiest });
965
+ if (g.stop) return withWarnings(g.stop);
966
+ }
551
967
 
552
- // ---------------------------------------------------------------------------------------------
553
- // ANALYZE (spec tree + board) — ⟐ RUNS AHEAD OF WIRE.
554
- //
555
- // It used to be the first of MAP SCOPES' two dispatches, which put it AFTER WIRE. That order is
556
- // the one solution-architect's own input contract excludes: `wire` is defined as "author/refresh
557
- // the wiring map after `analyze`, before `map-scopes`" (SKILL.md:43), its payload names the spec
558
- // folder to "read `usecases/` for the UCs and the engine each one needs" (:44), and its
559
- // verification checklist requires one wiring-map entry PER use case (:108). `init-run.mjs`
560
- // scaffolds no spec tree, so on a greenfield run WIRE was handed an empty spec folder, had
561
- // nothing to wire, and escalated — deterministically, on every launch. Two committed authorities
562
- // disagreed and this file implemented the one the worker does not (
563
- // §1, finding 2).
564
- //
565
- // Gate positions are unchanged: gates.md's L1a.5 confirms "each UC has a declared seam BEFORE
566
- // slicing", and the slicer — scope-architect — still runs after WIRE.
567
- // ---------------------------------------------------------------------------------------------
968
+ // ---- ANALYZE (spec tree + board) — ahead of WIRE, which reads its use cases -------------------
568
969
  phase("Analyze");
569
- if (!facts.has_spec_tree) {
970
+ if (!rs.has_spec_tree) {
570
971
  log(`ANALYZE — dispatching (slug ${slug})`);
571
- const analyzeOrder = await compile(
572
- `--operation analyze --slug ${slug} --payload '${JSON.stringify({ pitch: facts.intake_path, spec_folder: facts.spec_folder, feature: slug, lens: facts.lens, orient_dir: facts.orient_dir })}'`,
573
- "compile:analyze",
574
- );
575
- const analyzeResult = await dispatch(
576
- "ba-pitch-analyzer", analyzeOrder.stdout.trim(), args.models.exec, "Analyze", "analyze",
577
- RESULT_ONLY_SCHEMA, "Write the spec tree + board from the orient artifacts (no re-scan).",
578
- );
579
- if (analyzeResult.__failed) return dispatchAborted("ANALYZE", analyzeResult);
580
- const analyzeIngest = await ingestOrAbort("ANALYZE", resultFor(analyzeOrder.stdout.trim(), analyzeResult.result_path, "ingest:analyze"), "ingest:analyze");
581
- if (analyzeIngest) return analyzeIngest;
582
- const analyzeIncomplete = await requirePhase(slug, "ANALYZE", "analyze");
583
- if (analyzeIncomplete) return analyzeIncomplete;
584
- dispatchedAnalyze = true;
972
+ // "mapping", not "analyzing": the kernel's RUN_STATUSES enum is deliberately COARSER than this
973
+ // file's phases one value covers ANALYZE through MAP SCOPES, the stretch where the run is
974
+ // working out the shape. `analyzing` is not a member and never was, so this call failed on every
975
+ // single run since the cutover. It is advisory, so nothing stopped; the ledger simply stayed on
976
+ // the previous phase and the snapshot under-reported where the run had got to.
977
+ await setRunStatus("mapping", "Analyze");
978
+ const a = await worker({
979
+ skill: "ba-pitch-analyzer", operation: "analyze", schema: PHASE_OK, phase: "Analyze", label: "analyze",
980
+ payload: { pitch: rs.intake_path, spec_folder: specFolder, feature: slug, lens: rs.lens, orient_dir: rs.orient_dir },
981
+ extra: "Write the spec tree and the board from the orient artifacts — do not re-scan the code.",
982
+ });
983
+ if (a.__failed) return diedAt("ANALYZE", a);
984
+ const post = await requirePhase("ANALYZE", "analyze", "Analyze");
985
+ if (post) return withWarnings(post);
986
+ await advisory(`reduce graph --slug ${slug}`, "Analyze", "graph:analyze");
585
987
  } else {
586
- log("ANALYZE spec tree already present, skipping dispatch (fast-forward)");
988
+ const post = await fastForward("ANALYZE", "analyze", "Analyze", "spec tree already on disk");
989
+ if (post) return withWarnings(post);
587
990
  }
588
991
 
589
- // ---------------------------------------------------------------------------------------------
590
- // WIRE (step 7.5) + GATE L1a.5 — project-profile.md is written by tech-lead itself at GATE L0,
591
- // BEFORE this launch (gates.md "WIRE" step 1: "you write it at L0 — compile-order stays
592
- // pipeline-blind"); this file only dispatches solution-architect.
593
- // ---------------------------------------------------------------------------------------------
992
+ // ---- WIRE + GATE L1a.5 ------------------------------------------------------------------------
594
993
  phase("Wire");
595
- if (!facts.has_wiring_map) {
994
+ if (!rs.has_wiring_map) {
995
+ // FAIL FAST, at the orchestrator: `rs` already carries has_project_profile from the resume
996
+ // snapshot taken before ORIENT — the SAME fact solution-architect would have to discover for
997
+ // itself. Dispatching without it spends a full worker turn only for the worker to hit its own
998
+ // documented rule ("profile absent ⇒ ESCALATE, do not invent an entry point" — solution-
999
+ // architect/SKILL.md) and escalate; an escalation writes no wiring-map.md, so `has_wiring_map`
1000
+ // stays false and every relaunch re-dispatches and re-escalates identically. The orchestrator
1001
+ // holds the state a gate needs; it should not hand the check to the LLM it is about to pay for.
1002
+ if (!rs.has_project_profile) {
1003
+ return withWarnings(aborted("WIRE",
1004
+ `missing SHARED project-profile.md at ${rs.project_profile_path} — GATE L0 writes it ` +
1005
+ `({schema_version:1, archetype, entry_point}; references/gates.md GATE L0 §PROFILE) before ` +
1006
+ `this workflow launches. WIRE cannot resolve an entry_call_site without an entry_point to ` +
1007
+ `resolve against. Write the profile, then relaunch.`));
1008
+ }
596
1009
  log(`WIRE — dispatching (slug ${slug})`);
597
- const wireOrder = await compile(
598
- `--operation wire --slug ${slug} --payload '${JSON.stringify({ feature: slug, spec_folder: facts.spec_folder, project_profile: facts.project_profile_path })}'`,
599
- "compile:wire",
600
- );
601
- const wireResult = await dispatch(
602
- "solution-architect", wireOrder.stdout.trim(), args.models.exec, "Wire", "wire",
603
- RESULT_ONLY_SCHEMA, "Write the wiring map: per-UC engine -> seam -> entry-point call site -> affordance.",
604
- );
605
- if (wireResult.__failed) return dispatchAborted("WIRE", wireResult);
606
- const wireIngest = await ingestOrAbort("WIRE", resultFor(wireOrder.stdout.trim(), wireResult.result_path, "ingest:wire"), "ingest:wire");
607
- if (wireIngest) return wireIngest;
608
- const wireIncomplete = await requirePhase(slug, "WIRE", "wire");
609
- if (wireIncomplete) return wireIncomplete;
610
- dispatchedWire = true;
1010
+ const w = await worker({
1011
+ skill: "solution-architect", operation: "wire", schema: PHASE_OK, phase: "Wire", label: "wire",
1012
+ payload: { feature: slug, spec_folder: specFolder, project_profile: rs.project_profile_path },
1013
+ extra: "Write the wiring map: per use case, engine → seam → entry-point call site → affordance.",
1014
+ });
1015
+ if (w.__failed) return diedAt("WIRE", w);
1016
+ const post = await requirePhase("WIRE", "wire", "Wire");
1017
+ if (post) return withWarnings(post);
1018
+ await advisory(`reduce graph --slug ${slug}`, "Wire", "graph:wire");
611
1019
  } else {
612
- log("WIRE wiring-map.md already present, skipping dispatch (fast-forward)");
1020
+ const post = await fastForward("WIRE", "wire", "Wire", "wiring map already on disk");
1021
+ if (post) return withWarnings(post);
613
1022
  }
614
1023
 
615
- const l1a5 = await resolveGate(slug, args.answers, "L1a.5");
616
- if (l1a5.exit_code === 4) return paused("L1a.5", ["proceed", "ask", "abort"], { wiring_map: "written", dispatched: dispatchedWire, analyze_dispatched: dispatchedAnalyze });
617
- if (l1a5.exit_code === 5) return abortedFrom("L1a.5", l1a5, "GATE L1a.5 aborted");
1024
+ {
1025
+ const g = await crossGate("L1a.5", "Wire", ["proceed", "ask", "abort"], { wiring_map: "written" });
1026
+ if (g.stop) return withWarnings(g.stop);
1027
+ }
618
1028
 
619
- // ---------------------------------------------------------------------------------------------
620
- // MAP SCOPES (step 8) + GATE L1b — the slicing half; `analyze` now runs above, before WIRE.
621
- // scope-architect reports back the riskiest-first sequence it computed (its own authority — this
622
- // file never re-derives ordering on a fresh run). On fast-forward past this phase, the ordering is
623
- // re-derived from the scope files already on disk (alphabetical — a documented approximation;
624
- // scope CORRECTNESS is unaffected, only sequencing quality on a resumed run).
625
- // ---------------------------------------------------------------------------------------------
1029
+ // ---- MAP SCOPES + GATE L1b --------------------------------------------------------------------
626
1030
  phase("MapScopes");
627
- let scopes = facts.scope_files || [];
1031
+ // Both ways this list is produced now carry the same shape: `probe resume`'s `scope_files` and
1032
+ // `MAPSCOPES.scopes` are each `{scope_id, path}`, so nothing downstream has to know which branch
1033
+ // filled it. An entry missing either half is dropped rather than turned into a scope whose path is
1034
+ // `undefined` — `compile --scope undefined` exits 2, and the attempt loop reads a non-zero exit as
1035
+ // the stagnation breaker, which is a dead scope reported as a hard one.
1036
+ let scopes = (rs.scope_files || []).filter((s) => s?.scope_id && s?.path)
1037
+ .map((s) => ({ path: s.path, scope_id: s.scope_id }));
1038
+ const mappedThisRun = scopes.length === 0;
628
1039
  if (scopes.length === 0) {
629
1040
  log(`MAP SCOPES — dispatching (slug ${slug})`);
630
- const mapScopesOrder = await compile(`--operation map-scopes --slug ${slug}`, "compile:map-scopes");
631
- const mapScopesResult = await dispatch(
632
- "scope-architect", mapScopesOrder.stdout.trim(), args.models.exec, "MapScopes", "map-scopes",
633
- MAPSCOPES_SCHEMA, "Write the scope contracts (substrate whitelists, fixtures) and report the riskiest-first build sequence.",
634
- );
635
- if (mapScopesResult.__failed) return dispatchAborted("MAP SCOPES", mapScopesResult);
636
- const mapScopesIngest = await ingestOrAbort("MAP SCOPES", resultFor(mapScopesOrder.stdout.trim(), mapScopesResult.result_path, "ingest:map-scopes"), "ingest:map-scopes");
637
- if (mapScopesIngest) return mapScopesIngest;
638
- const mapScopesIncomplete = await requirePhase(slug, "MAP SCOPES", "map-scopes");
639
- if (mapScopesIncomplete) return mapScopesIncomplete;
640
- scopes = mapScopesResult.scopes;
641
- dispatchedMapScopes = true;
1041
+ const m = await worker({
1042
+ skill: "scope-architect", operation: "map-scopes", schema: MAPSCOPES, phase: "MapScopes", label: "map-scopes",
1043
+ payload: { feature: slug },
1044
+ // SAY THE PASS RULE, for the same reason ORIENT's filenames are named above: the rule lives in
1045
+ // `verify t0` (a fixture passes iff it exits 0) and the architect never saw it. Given a contract
1046
+ // that said only "commands that drive this scope end-to-end", it wrote the scope's error paths
1047
+ // as bare invocations `todo done abc # E_INVALID_INDEX, exit 1` — which cannot pass by
1048
+ // construction, so four of six scopes could never go T0-green however correct their code was.
1049
+ // The two that did go green were the two whose fixtures happened to be `node --test …`.
1050
+ extra:
1051
+ "Write the scope contracts (substrate whitelists, verification fixtures) and report the " +
1052
+ "riskiest-first sequence. EVERY e2e_verification_fixture MUST EXIT 0 when the scope is " +
1053
+ "correct — T0 scores any non-zero exit as a failure, so a fixture written as a bare " +
1054
+ "error-path invocation can never pass and its scope can never go green. Put expected " +
1055
+ "non-zero exits INSIDE a test file that itself exits 0, and name that test file as the " +
1056
+ "fixture. Write `e2e_verification_fixtures` as a FRONTMATTER key holding bare command " +
1057
+ "strings — a `## e2e_verification_fixtures` markdown section is not parsed, and a command " +
1058
+ "with prose appended to it is not runnable. A scope whose fixtures do not parse has nothing " +
1059
+ "to verify it and is refused at the board review.",
1060
+ });
1061
+ if (m.__failed) return diedAt("MAP SCOPES", m);
1062
+ const post = await requirePhase("MAP SCOPES", "map-scopes", "MapScopes");
1063
+ if (post) return withWarnings(post);
1064
+ await advisory(`reduce graph --slug ${slug}`, "MapScopes", "graph:map-scopes");
1065
+ scopes = m.scopes;
642
1066
  } else {
643
- log(`MAP SCOPES ${scopes.length} scope contract(s) already present, skipping dispatch (fast-forward)`);
1067
+ const post = await fastForward("MAP SCOPES", "map-scopes", "MapScopes",
1068
+ `${scopes.length} scope contract(s) already on disk`);
1069
+ if (post) return withWarnings(post);
644
1070
  }
645
1071
 
646
- // trace-lint runs HERE, at L1b, where gates.md:154 puts it ("ADVISORY at L1b"). It used to run at
647
- // L1a.5 — before `analyze` had written a spec tree or a requirement registry, so both of its arms
648
- // self-skipped and the seam-coverage figure reported on nothing.
649
- const traceLint = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/trace-lint.mjs" --slug ${slug}`, "trace-lint");
650
- const seamCoverage = traceLint.stdout.includes("🟢 green") ? "green" : "red (advisory)";
651
-
652
- const specLint = await mech(`node "${args.pluginRoot}/skills/ba-pitch-analyzer/scripts/spec-lint.mjs" --slug ${slug}`, "spec-lint");
653
- if (specLint.exit_code !== 0) {
654
- return { status: "aborted", aborted_at: "L1b", reason: `spec-lint reported a disjointness/size problem before BUILD could start: ${specLint.stdout.trim() || specLint.stderr.trim()}` };
1072
+ // DEPENDENCY ORDER a scope is never built beside a scope it consumes.
1073
+ //
1074
+ // The fan-out used to chunk scopes by a fixed width over the directory's alphabetical order. On the
1075
+ // criterion-1 run that scheduled `cli-integration` the scope that wires every other scope's module
1076
+ // to the entry point — SECOND, beside the very scopes it consumes. It failed both rounds, and its
1077
+ // failure was the whole of the run's failure: five of six scopes went green, `bin/todo.js` stayed a
1078
+ // placeholder, and four individually T0-green command modules were unreachable.
1079
+ //
1080
+ // All three facts below are derived by the kernel from the contracts, so nothing here declares any
1081
+ // of them. The waves fix the ORDER scopes are considered in; the dependency edges fix when each one
1082
+ // is RELEASED; the exclusions forbid two scopes that may write the same declared-shared path from
1083
+ // running at the same time. Everything else fans out.
1084
+ const wavesFrom = (raw, list) => {
1085
+ const byId = new Map(list.map((s) => [s.scope_id, s]));
1086
+ const idOf = (p) => String(p).split("/").pop().replace(/\.(md|json)$/, "");
1087
+ const w = (raw || []).map((g) => g.map((p) => byId.get(idOf(p))).filter(Boolean)).filter((g) => g.length);
1088
+ // Every scope must appear exactly once, or the grouping is not trustworthy and one wave — today's
1089
+ // behavior — is the safe answer. A scheduler that drops a scope is worse than an unscheduled one.
1090
+ return w.length && w.reduce((a, g) => a + g.length, 0) === list.length ? w : [list];
1091
+ };
1092
+ let waves = wavesFrom(rs.scope_waves, scopes);
1093
+ let rawDeps = rs.scope_deps;
1094
+ let rawExclusions = rs.scope_exclusions;
1095
+ if (mappedThisRun && scopes.length > 1) {
1096
+ // The contracts were written THIS run, so the state probed before MAP SCOPES could not have seen
1097
+ // them. One cheap re-derivation, on the fresh-run path only.
1098
+ const rs2 = await query(`probe resume --slug ${slug}`, RESUME, "MapScopes", "scope-waves");
1099
+ if (rs2?.scope_waves?.length) {
1100
+ waves = wavesFrom(rs2.scope_waves, scopes);
1101
+ rawDeps = rs2.scope_deps;
1102
+ rawExclusions = rs2.scope_exclusions;
1103
+ }
1104
+ }
1105
+ // The waves flattened ARE the build order: dependencies first, input order within a level. At a dial
1106
+ // of 1 the window degrades to exactly this sequence, which is the sequential lane unchanged.
1107
+ const buildOrder = waves.flat();
1108
+ const excluded = withExclusions(scopeEdges(rawDeps, waves, scopes), rawExclusions, buildOrder);
1109
+ const scopeReleases = excluded.edges;
1110
+ if (excluded.added) {
1111
+ log(`BUILD order — ${excluded.added} pair(s) of scopes may write the same declared-shared path and are ` +
1112
+ `serialised against each other. Concurrent writers to one shared file lose each other's work, and ` +
1113
+ `nothing downstream reports it: every fixture, lint and hook stays green over the surviving copy.`);
1114
+ }
1115
+ // THE CEILING IS REPORTED WHETHER OR NOT IT IS THE DIAL, and it is reported BEFORE anything is
1116
+ // dispatched. A ceiling under the dial is a scope-cutting fact — the substrate the contracts declared
1117
+ // does not admit the concurrency the run is paying for — and it is fixed by re-cutting scopes at the
1118
+ // board review, not by anything BUILD can do. Said here, it costs a line; found later, it is a slow
1119
+ // round indistinguishable from slow workers.
1120
+ const ceiling = Math.min(releaseCeiling(scopeReleases, buildOrder), maxParallelScopes);
1121
+ if (waves.length > 1 || excluded.added || ceiling < maxParallelScopes) {
1122
+ const edgeCount = [...scopeReleases.values()].reduce((a, s) => a + s.size, 0);
1123
+ log(`BUILD order — ${waves.length} dependency wave(s): ${waves.map((w) => w.map((s) => s.scope_id).join("+")).join(" → ")}` +
1124
+ ` · ${edgeCount} release edge(s) · at most ${ceiling} scope(s) can be open at once` +
1125
+ `${ceiling < maxParallelScopes ? ` (the window is ${maxParallelScopes}; the substrate the contracts declared is what caps it, not the dial)` : ""}`);
655
1126
  }
656
1127
 
657
- const hillDeriveL1b = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/hill-derive.mjs" --slug ${slug}`, "hill-derive");
658
- if (hillDeriveL1b.exit_code !== 0) {
659
- log(`hill-derive failed at L1b: ${(hillDeriveL1b.stderr || hillDeriveL1b.stdout).trim()}`);
1128
+ // Advisory lints at L1b. spec-lint is hard — a substrate overlap makes parallel builds unsafe;
1129
+ // trace-lint stays advisory until `covers:` is populated; hill-derive is a projection.
1130
+ const specLint = await cmd(`verify spec --slug ${slug}`, "MapScopes", "spec-lint");
1131
+ if (!specLint.ok) {
1132
+ return aborted("L1b", `spec-lint reported a disjointness or size problem before BUILD: ${specLint.detail || `exit ${specLint.exit_code}`}`);
660
1133
  }
1134
+ await advisory(`verify trace --slug ${slug} --quiet`, "MapScopes", "trace-lint");
1135
+ await advisory(`reduce hill --slug ${slug}`, "MapScopes", "hill-derive");
661
1136
 
662
- const l1b = await resolveGate(slug, args.answers, "L1b");
663
- if (l1b.exit_code === 4) return paused("L1b", ["proceed", "ask", "abort"], { scopes: scopes.map((s) => s.scope_id), dispatched: dispatchedMapScopes, seam_coverage: seamCoverage });
664
- if (l1b.exit_code === 5) return abortedFrom("L1b", l1b, "GATE L1b aborted");
665
- if (dispatchedMapScopes) await setRunStatus(slug, "building");
1137
+ {
1138
+ const g = await crossGate("L1b", "MapScopes", ["proceed", "ask", "abort"], { scopes: scopes.map((s) => s.scope_id) });
1139
+ if (g.stop) return withWarnings(g.stop);
1140
+ }
666
1141
 
667
- // ---------------------------------------------------------------------------------------------
668
- // Rounds of BUILD -> GATE L2 -> EVAL -> GATE L3, bounded by budgets.maxRounds (the OUTER
669
- // breaker) with budget-check.mjs consulted at every round boundary (the DEADLINE breaker).
670
- // ---------------------------------------------------------------------------------------------
671
- phase("Build");
672
- let round = facts.eval_rounds_done.length ? Math.max(...facts.eval_rounds_done) + 1 : 1;
1142
+ // =============================================================================================
1143
+ // ROUNDS of BUILD GATE L2 EVAL GATE L3, bounded by maxRounds.
1144
+ //
1145
+ // The round loop is inlined here rather than dispatched round-by-round because a round dispatched
1146
+ // once can attempt every scope from attempt 1, while an outer loop must survive a mid-BUILD kill
1147
+ // and resume without re-work. So before opening a scope's attempt loop this file asks whether THIS
1148
+ // ROUND already has a green T0 verdict for it on disk, and skips the scope entirely when it does.
1149
+ // =============================================================================================
1150
+ const lastEval = rs.eval_rounds_done?.length ? Math.max(...rs.eval_rounds_done) : 0;
1151
+ let round = lastEval + 1;
673
1152
  let verdict = null;
1153
+ const allGreen = [];
1154
+ const allHammer = [];
1155
+ // OUTSIDE the loop, because its whole purpose is to cross a round boundary: round r's verdict is
1156
+ // what round r+1 has to act on. Declared inside, it was in the temporal dead zone at the BUILD that
1157
+ // needed it — a runtime error no static check can see, since nothing but a real second round ever
1158
+ // reaches that line.
1159
+ let findings = [];
1160
+
1161
+ // THE ROUND THE RELAUNCH IS RESUMING INTO MAY ALREADY HAVE PASSED, and until this leg existed the
1162
+ // run had no way to find out. `round` above opens at the round AFTER the last EVAL — correct when
1163
+ // that EVAL returned FAIL, and the only reading available. But a pause at GATE L3, at QA or at
1164
+ // GATE H happens strictly AFTER `results/evaluate-r<N>.json` is written, so the relaunch opened
1165
+ // round N+1, asked the graph which scopes were green IN ROUND N+1, was told none, and re-dispatched
1166
+ // every scope through the attempt ratchet before running a second EVAL — over a verdict of PASS
1167
+ // sitting on disk. It cost a round of `maxRounds`, a full build fan-out, and a second judgement free
1168
+ // to return FAIL where the first passed. The PO answered a gate and the run went backwards.
1169
+ //
1170
+ // The verdict is an ARTIFACT, and `probe eval` reads it out of the WorkResult `reduce ingest` wrote
1171
+ // — the same probe, and deliberately not the dispatching agent's account of it, that L3 branches on
1172
+ // below. This is the fast-forward the planning phases have always had, applied to the one phase that
1173
+ // did not have it: a completed EVAL is skipped exactly like a completed ANALYZE.
1174
+ if (lastEval) {
1175
+ phase("Eval");
1176
+ const prior = await query(`probe eval --slug ${slug} --round ${lastEval}`, EVAL_VERDICT, "Eval", `ff:eval-r${lastEval}`);
1177
+ if (prior?.ok && prior.overall === "PASS") {
1178
+ verdict = "pass";
1179
+ round = lastEval;
1180
+ log(`EVAL — round ${lastEval} already returned PASS on disk, fast-forwarding past the build/eval loop`);
1181
+ }
1182
+ }
674
1183
 
675
- while (round <= args.budgets.maxRounds) {
676
- const budget = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/budget-check.mjs" --slug ${slug} --strict`, `budget:r${round}`);
1184
+ // `verdict !== "pass"` is the same exit the `break` below takes when a round passes stated here as
1185
+ // well so a RESUMED run reaches it, since the break belongs to a loop iteration a relaunch never ran.
1186
+ while (verdict !== "pass" && round <= maxRounds) {
1187
+ phase("Build");
1188
+ await setRunStatus("building", "Build");
1189
+
1190
+ // The wall-clock breaker, opt-in, checked at each round boundary. Exit 6 is "tripped": route to
1191
+ // GATE H and ship what is green, never kill the run from outside.
1192
+ const budget = await cmd(`verify budget --slug ${slug} --strict`, "Build", `budget:r${round}`);
677
1193
  if (budget.exit_code === 6) {
678
- const hillDeriveH1 = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/hill-derive.mjs" --slug ${slug}`, "hill-derive");
679
- if (hillDeriveH1.exit_code !== 0) {
680
- log(`hill-derive failed at H (deadline): ${(hillDeriveH1.stderr || hillDeriveH1.stdout).trim()}`);
681
- }
682
- return withStateWarnings({ status: "gate_h", breaker: "deadline", hammer_proposals: allHammerProposals, green_scopes: allGreenScopes });
1194
+ await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
1195
+ return withWarnings({ status: "gate_h", breaker: "deadline", hammer_proposals: allHammer, green_scopes: allGreen });
683
1196
  }
684
1197
 
685
- log(`BUILD round ${round} — ${scopes.length} scope(s), attempt budget ${args.budgets.attemptBudget}`);
686
- const roundGreen = [];
687
- const roundHammer = [];
688
- const roundT0Artifacts = [];
689
-
690
- for (const scope of scopes) {
691
- const already = await checkScopeGreen(slug, scope.scope_id, round);
692
- if (already.green) {
693
- log(`scope ${scope.scope_id} — already T0-green this round (resumed from disk, no re-work)`);
694
- roundGreen.push(scope.scope_id);
695
- if (already.path) roundT0Artifacts.push(already.path);
696
- continue;
697
- }
1198
+ log(`BUILD round ${round} — ${scopes.length} scope(s), up to ${maxParallelScopes} at once, attempt budget ${attemptBudget}`);
1199
+ const roundGreen = [], roundHammer = [];
698
1200
 
699
- log(`scope ${scope.scope_id} starting attempt loop (budget ${args.budgets.attemptBudget})`);
700
- // Branch-per-scope isolation (design doc D3). A checkout that silently failed would build
701
- // this scope's code on the previous scope's branch the same "wrong map, no diagnostic" shape
702
- // as the substrate pointer below, so it takes the same policy.
703
- if (scope.branch) {
704
- const checkout = await mech(`git checkout ${scope.branch}`, `checkout:${scope.scope_id}`);
705
- if (checkout.exit_code !== 0) {
706
- return { status: "aborted", aborted_at: "BUILD", reason: `could not check out branch "${scope.branch}" for scope ${scope.scope_id}: ${(checkout.stderr || checkout.stdout || `exit ${checkout.exit_code}`).toString().trim()}` };
707
- }
708
- }
709
- // The substrate pointer, and the one write in this file that must NOT be survivable. It is
710
- // what hooks/sandbox-guard.mjs reads to decide which scope's write-whitelist the next worker
711
- // is held to — so a failed write does not degrade to "unguarded", it degrades to guarding the
712
- // WRONG scope, silently, which is invariant #3 enforcing the wrong thing. That is exactly what
713
- // the kill/resume probe measured: the pointer still named scope 1 while scope 2 was built,
714
- // twice, and nothing noticed because this call's result was discarded. It is read back now,
715
- // and a failure aborts the run at a named phase rather than building against the wrong map.
716
- const pointer = await mech(
717
- `node "${args.pluginRoot}/skills/tech-lead/scripts/resume-state.mjs" --slug ${slug} --set-active-scope ${scope.scope_id}`,
718
- `active-scope:${scope.scope_id}`,
719
- );
720
- if (pointer.exit_code !== 0) {
721
- const why = (parseMechJson(pointer.stdout)?.reason || pointer.stderr || `exit ${pointer.exit_code}`).toString().trim();
722
- return {
723
- status: "aborted",
724
- aborted_at: "BUILD",
725
- reason: `could not point the active-scope pointer at ${scope.scope_id}: ${why}. Refusing to build — the sandbox guard would hold this scope's worker to another scope's substrate.`,
726
- };
727
- }
1201
+ // ONE bounded query opens the round, instead of one probe per scope. The graph is a projection of
1202
+ // the same verdict artifacts `probe t0` reads, so this is the identical fact asked once — which is
1203
+ // the difference between a read model and a directory walk.
1204
+ const g = await query(`reduce graph --slug ${slug} --subgraph run`, SUBGRAPH, "Build", `graph:r${round}`);
1205
+ const alreadyGreen = new Set(g?.green_scopes_by_round?.[String(round)] || []);
1206
+ if (alreadyGreen.size) log(`BUILD r${round} ${alreadyGreen.size} scope(s) already green in the graph, skipping them`);
728
1207
 
729
- let green = false, stagnant = false, lastT0Path = null;
730
- for (let attempt = 1; attempt <= args.budgets.attemptBudget && !green; attempt++) {
731
- const compiled = await compile(`--scope ${scope.path} --round ${round} --attempt ${attempt}`, `compile:${scope.scope_id}-a${attempt}`);
732
- if (compiled.stderr.includes('"breaker":"stagnation"') || compiled.exit_code !== 0) {
733
- log(`scope ${scope.scope_id}inner breaker on attempt ${attempt}: ${(compiled.stderr || `exit ${compiled.exit_code}`).trim()}`);
734
- stagnant = true;
735
- break;
736
- }
737
- const orderPath = compiled.stdout.trim();
738
- // t0-verify.mjs's own default --out lands verdicts in the SHARED tree next to the scope
739
- // contract; the LOCAL ("." + "shapeup/") tree is derived from compile-order's own stdout, never
740
- // spelled out here.
741
- const localRoot = orderPath.slice(0, orderPath.lastIndexOf("/orders/"));
742
- const built = await dispatch(
743
- "task-executor", orderPath, args.models.exec, "Build", `build:${scope.scope_id}-a${attempt}`,
744
- RESULT_ONLY_SCHEMA, "Implement the order's acceptance criteria exactly.",
745
- );
746
- // A dead builder is a spent attempt, not a dead run — the attempt budget is exactly the
747
- // instrument for this, and the inner breaker still queues a GATE H proposal if they all die.
748
- if (built.__failed) { log(`scope ${scope.scope_id}attempt ${attempt} lost its worker: ${built.__failed}`); continue; }
749
- // A build result that did not apply is a spent attempt, not a dead run: the board never
750
- // recorded this attempt, so T0 would grade a scope whose task rows still read unstarted.
751
- const buildLabel = `ingest:${scope.scope_id}-a${attempt}`;
752
- const buildIngest = ingestFailure(await ingest(resultFor(orderPath, built.result_path, buildLabel), buildLabel), buildLabel);
753
- if (buildIngest) { log(`scope ${scope.scope_id} attempt ${attempt} discarded: ${buildIngest}`); continue; }
754
- const t0 = await mech(
755
- `node "${args.pluginRoot}/skills/tech-lead/scripts/t0-verify.mjs" ${scope.path} --round ${round} --attempt ${attempt} --out "${localRoot}" --no-seesaw`,
756
- `t0:${scope.scope_id}-a${attempt}`,
757
- );
758
- // An unparsable T0 report is a red, never a crash-the-loop but courier noise around a
759
- // GREEN report must not read as red either, or the attempt budget burns down a scope that
760
- // actually passed and GATE H gets a hammer proposal for it.
761
- const v = parseMechJson(t0.stdout);
762
- green = v?.overall === "green";
763
- if (v?.path) lastT0Path = v.path;
764
- }
1208
+ // SCOPES FAN OUT. A scope contract is the definition of an independent subtask — disjoint
1209
+ // substrate, own fixtures, own ratchet so the loop that ran them one at a time was leaving the
1210
+ // whole point of the contract on the floor. `pipeline()` has NO barrier between its stages: a
1211
+ // fast scope is being confirmed while a slow one is still on attempt 3. One pipeline per scope,
1212
+ // so that property is now GLOBAL rather than per group nothing waits on a group boundary, and
1213
+ // the window refills the instant any leg settles.
1214
+ //
1215
+ // Three stages, because each answers a different question about the same scope:
1216
+ // check — is it already green on disk from a killed round? (resume, no re-work)
1217
+ // build — the attempt ratchet, inside the worker's own shell
1218
+ // confirm MEASURED, NOT CLAIMED: the worker says green; the T0 artifact has to agree.
1219
+ // A green with no artifact on disk is a claim, and the evaluator that must cite that
1220
+ // artifact would find nothing.
1221
+ // NEVER RETURN `null` FROM A STAGE TO MEAN "carry on". The runtime reads a null stage result as
1222
+ // DROP THIS ITEM and skips its remaining stages — measured directly, not inferred:
1223
+ //
1224
+ // stage1 → null ⇒ stage2 ran 0/3 times, settled = [NULL, NULL, NULL]
1225
+ // stage1 {…} ⇒ stage2 ran 3/3 times
1226
+ //
1227
+ // This stage used to return `null` for "not green yet stage 2, please build it", which meant
1228
+ // every scope that was not ALREADY green was dropped before `buildScope` could run. On a fresh
1229
+ // run that is every scope, so the round ended with 0 green and 6 queued, the inner breaker tripped
1230
+ // and the run returned `gate_h` having dispatched no builder at all. BUILD could never dispatch;
1231
+ // the failure looked exactly like six genuinely hard scopes.
1232
+ const settled = await scheduleScopes(buildOrder, scopeReleases, maxParallelScopes, async (scope) => {
1233
+ const done = await pipeline(
1234
+ [scope],
1235
+ async (s) => (alreadyGreen.has(s.scope_id)
1236
+ ? { scope_id: s.scope_id, green: true, resumed: true }
1237
+ : { scope_id: s.scope_id, pending: true }), // not green yet stage 2 builds it
1238
+ async (pre, s) => (pre?.pending ? buildScope(s, round) : pre),
1239
+ async (res, s) => {
1240
+ if (!res || res.__failed) return res;
1241
+ if (res.resumed || !res.green) return res;
1242
+ const confirmed = await query(`probe t0 --slug ${slug} --scope ${s.scope_id} --round ${round}`,
1243
+ T0CHECK, "Build", `t0confirm:${s.scope_id}-r${round}`);
1244
+ if (!confirmed?.green) {
1245
+ log(`BUILD r${round} — ${s.scope_id} reported green but no T0 verdict is on disk for this ` +
1246
+ `round; treating it as not green (the evaluator cites that artifact, and it is not there).`);
1247
+ return { ...res, green: false, reason: "reported green with no T0 verdict artifact on disk" };
1248
+ }
1249
+ // AND ITS RESULT HAS TO HAVE REACHED THE BOARD. A green T0 says the worker's fixtures ran and
1250
+ // passed; it says nothing about whether the WorkResult was applied, and this stage used to ask
1251
+ // only the first question. Measured on a live run: a leg wrote its code, a green verdict, a
1252
+ // kept trial row and its WorkResult, then skipped step 3 of its own script. It reported green,
1253
+ // this stage re-verified the T0 artifact, agreed, and the round walked on with the scope's task
1254
+ // still `pending` and zero acceptance criteria ticked — the board GATE L2 reads as 100%
1255
+ // disagreeing with a scope that was genuinely finished.
1256
+ //
1257
+ // The evidence is the leg-completion row, because `reduce ingest` writes it: its presence
1258
+ // proves the writer ran, and it is not something the leg can assert about itself.
1259
+ const applied = await query(`probe leg --slug ${slug} --scope ${s.scope_id} --round ${round}`,
1260
+ LEGCHECK, "Build", `legcheck:${s.scope_id}-r${round}`);
1261
+ for (const orderPath of (applied?.closed ? [] : applied?.unapplied || [])) {
1262
+ // INGESTED HERE RATHER THAN FAILED. The result is on disk and valid — re-running the leg
1263
+ // would pay a whole attempt again for work already done. Only the single writer writes
1264
+ // shared state, and that writer is this command; which step invokes it is not the invariant.
1265
+ log(`BUILD r${round} — ${s.scope_id} finished without applying its own result. Ingesting it ` +
1266
+ `here: ${orderPath}. The leg skipped its ingest step, so the board did not see work that ` +
1267
+ `is on disk and T0-green.`);
1268
+ // QUOTED, and that is not cosmetic. The PreToolUse envelope gate scans the `Agent` prompt as
1269
+ // well as `Skill` args, so a bare order path interpolated into a sub-agent's prompt is read
1270
+ // together with the trailing quote of the surrounding literal and the whole dispatch is
1271
+ // denied — measured once already, on a path threaded through a prompt exactly like this one.
1272
+ await advisory(`reduce ingest --order "${orderPath}"`, "Build", `late-ingest:${s.scope_id}-r${round}`);
1273
+ }
1274
+ if (applied && !applied.closed && !(applied.unapplied || []).length && applied.results_total === 0) {
1275
+ // Green T0, no result envelope at all: the two records disagree about whether a leg ran, and
1276
+ // the round must not resolve that by preferring the one that says yes.
1277
+ log(`BUILD r${round} — ${s.scope_id} has a green T0 verdict and no WorkResult on disk for ` +
1278
+ `this round; treating it as not green.`);
1279
+ return { ...res, green: false, reason: "green T0 verdict with no WorkResult envelope" };
1280
+ }
1281
+ return res;
1282
+ },
1283
+ );
1284
+ return done[0];
1285
+ });
765
1286
 
766
- if (green) { roundGreen.push(scope.scope_id); if (lastT0Path) roundT0Artifacts.push(lastT0Path); log(`scope ${scope.scope_id} — T0 green`); }
767
- else {
768
- roundHammer.push({ scope_id: scope.scope_id, t0_artifact: lastT0Path, reason: stagnant ? "stagnation breaker" : "attempt_budget exhausted without a green T0" });
769
- log(`scope ${scope.scope_id} inner breaker tripped, queued a GATE H proposal`);
1287
+ for (const [i, res] of settled.entries()) {
1288
+ const scopeId = buildOrder[i].scope_id;
1289
+ // A dead builder is a SPENT ATTEMPT, not a dead run: the scope goes to GATE H's census and
1290
+ // the round continues. Killing the run here would discard every other scope's green work.
1291
+ if (!res || res.__failed) {
1292
+ log(`BUILD r${round} — ${scopeId} lost its worker: ${res?.__failed || "no result"}`);
1293
+ roundHammer.push(scopeId);
1294
+ } else if (res.green) {
1295
+ roundGreen.push(res.scope_id || scopeId);
1296
+ } else {
1297
+ roundHammer.push(res.scope_id || scopeId);
770
1298
  }
771
1299
  }
772
1300
 
773
- allGreenScopes.push(...roundGreen);
774
- allHammerProposals.push(...roundHammer.map((h) => h.scope_id));
1301
+ allGreen.push(...roundGreen);
1302
+ allHammer.push(...roundHammer);
775
1303
 
1304
+ // INNER breaker: nothing green and something queued → GATE H. The census is scope-hammer's job.
776
1305
  if (roundGreen.length === 0 && roundHammer.length > 0) {
777
- const hillDeriveH2 = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/hill-derive.mjs" --slug ${slug}`, "hill-derive");
778
- if (hillDeriveH2.exit_code !== 0) {
779
- log(`hill-derive failed at H (inner breaker): ${(hillDeriveH2.stderr || hillDeriveH2.stdout).trim()}`);
780
- }
781
- return withStateWarnings({ status: "gate_h", breaker: "inner", hammer_proposals: allHammerProposals, green_scopes: allGreenScopes });
1306
+ await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
1307
+ return withWarnings({ status: "gate_h", breaker: "inner", hammer_proposals: allHammer, green_scopes: allGreen });
782
1308
  }
783
1309
 
784
- const hillDeriveL2 = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/hill-derive.mjs" --slug ${slug}`, "hill-derive");
785
- if (hillDeriveL2.exit_code !== 0) {
786
- log(`hill-derive failed at L2: ${(hillDeriveL2.stderr || hillDeriveL2.stdout).trim()}`);
1310
+ await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
1311
+ {
1312
+ const g = await crossGate("L2", "Build", ["proceed", "ask", "abort"],
1313
+ { round, green_scopes: roundGreen, hammer_proposals: roundHammer });
1314
+ if (g.stop) return withWarnings(g.stop);
787
1315
  }
788
1316
 
789
- const l2 = await resolveGate(slug, args.answers, "L2");
790
- if (l2.exit_code === 4) return paused("L2", ["proceed", "ask", "abort"], { round, green_scopes: roundGreen, hammer_proposals: roundHammer.map((h) => h.scope_id) });
791
- if (l2.exit_code === 5) return abortedFrom("L2", l2, "GATE L2 aborted");
792
-
1317
+ // ---- EVAL exactly one feature-level pass per round (the single-judge invariant) ------------
793
1318
  phase("Eval");
1319
+ await setRunStatus("evaluating", "Eval");
794
1320
  if (args.noEval) {
795
1321
  log("EVAL — skipped (--no-eval)");
796
1322
  verdict = "pass";
797
1323
  } else {
798
- await setRunStatus(slug, "evaluating");
799
- const evalOrder = await compile(
800
- `--operation evaluate --slug ${slug} --round ${round} --payload '${JSON.stringify({ dimensions: facts.eval_dimensions, run_cmd: facts.run_cmd, t0_artifacts: roundT0Artifacts })}'`,
801
- `compile:evaluate-r${round}`,
802
- );
803
- const EVAL_SCHEMA = { type: "object", properties: { result_path: { type: "string" }, overall: { type: "string", enum: ["PASS", "FAIL"] } }, required: ["result_path", "overall"] };
804
- const evalResult = await dispatch(
805
- "spec-evaluator", evalOrder.stdout.trim(), args.models.eval, "Eval", `eval:r${round}`,
806
- EVAL_SCHEMA, "Evaluate the running feature against ALL acceptance criteria + Done-when. ONE feature-level pass.",
807
- );
808
- if (evalResult.__failed) return dispatchAborted("L3", evalResult);
809
- const evalLabel = `ingest:evaluate-r${round}`;
810
- const evalIngest = await ingestOrAbort("L3", resultFor(evalOrder.stdout.trim(), evalResult.result_path, evalLabel), evalLabel);
811
- if (evalIngest) return evalIngest;
812
- verdict = evalResult.overall === "PASS" ? "pass" : "fail";
1324
+ const e = await worker({
1325
+ skill: "spec-evaluator", operation: "evaluate", schema: EVAL, phase: "Eval", label: `eval:r${round}`,
1326
+ model: evalModel, round,
1327
+ payload: { dimensions: evalDims, run_cmd: rs.run_cmd, round },
1328
+ extra: "Evaluate the running feature against every acceptance criterion and Done-when. One feature-level pass; cite the T0 artifact you re-hash yourself.",
1329
+ });
1330
+ if (e.__failed) return diedAt("L3", e);
1331
+ // The pass/fail branch is decided from the WorkResult on disk, not from the dispatching
1332
+ // agent's own summary of it (`e.overall`) see EVAL_VERDICT's comment for why.
1333
+ const ev = await query(`probe eval --slug ${slug} --round ${round}`, EVAL_VERDICT, "Eval", `verdict:r${round}`);
1334
+ if (!ev || !ev.ok || !ev.overall) return diedAt("L3", nullFail(`verdict:r${round}`));
1335
+ verdict = ev.overall === "PASS" ? "pass" : "fail";
1336
+ findings = e.findings || [];
813
1337
  }
814
1338
 
815
- const hillDeriveL3 = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/hill-derive.mjs" --slug ${slug}`, "hill-derive");
816
- if (hillDeriveL3.exit_code !== 0) {
817
- log(`hill-derive failed at L3: ${(hillDeriveL3.stderr || hillDeriveL3.stdout).trim()}`);
1339
+ // ---- REFUTE WAVE (opt-in) one independent skeptic per FAIL finding, BEFORE it costs a whole
1340
+ // fix round. Perspective-diverse by prompt: the refuter is told to try to refute and to default to
1341
+ // "real" when it cannot. A pure addition; off unless args.adversarialVerify.
1342
+ if (verdict === "fail" && args.adversarialVerify && findings.length) {
1343
+ phase("Refute");
1344
+ const checks = await parallel(findings.map((f) => () =>
1345
+ agent(
1346
+ `A single judge marked this acceptance criterion FAILED for slug "${slug}":\n` +
1347
+ ` criterion: ${f.criterion}\n evidence: ${f.evidence}\n\n` +
1348
+ `Independently try to REFUTE the failure by exercising the running feature yourself. ` +
1349
+ `Return refuted=true ONLY if you can show it actually passes; otherwise refuted=false.`,
1350
+ { model: evalModel, phase: "Refute", label: `refute:${f.id}`, schema: REFUTATION },
1351
+ )));
1352
+ const overturned = new Set(checks.filter(Boolean).filter((c) => c.refuted).map((c) => c.id));
1353
+ if (overturned.size) log(`REFUTE — ${overturned.size}/${findings.length} FAIL finding(s) overturned by an independent skeptic`);
1354
+ if (overturned.size === findings.length) {
1355
+ verdict = "pass";
1356
+ log("REFUTE — every finding was overturned; treating this round as PASS");
1357
+ }
818
1358
  }
819
1359
 
820
- const l3 = await resolveGate(slug, args.answers, "L3");
821
- if (l3.exit_code === 4) return paused("L3", ["loop", "stop", "ask"], { round, verdict, hammer_proposals: allHammerProposals });
822
- if (l3.exit_code === 5) return abortedFrom("L3", l3, "GATE L3 aborted");
1360
+ await advisory(`reduce graph --slug ${slug}`, "Eval", `graph:eval-r${round}`);
1361
+ await advisory(`reduce hill --slug ${slug}`, "Eval", "hill-derive");
1362
+ const g3 = await crossGate("L3", "Eval", ["loop", "stop", "ask"], { round, verdict });
1363
+ if (g3.stop) return withWarnings(g3.stop);
823
1364
 
824
- if (verdict === "pass") break; // out of the round loop, into QA -> GATE H -> ship
825
- if (l3.decision === "stop" || round >= args.budgets.maxRounds) {
826
- return withStateWarnings({ status: "gate_h", breaker: "outer", hammer_proposals: allHammerProposals, green_scopes: allGreenScopes });
1365
+ if (verdict === "pass") break; // QA GATE H ship
1366
+ if (g3.decision === "stop" || round >= maxRounds) {
1367
+ return withWarnings({ status: "gate_h", breaker: "outer", hammer_proposals: allHammer, green_scopes: allGreen });
827
1368
  }
828
- await setRunStatus(slug, "building");
829
1369
  round += 1;
830
1370
  }
831
1371
 
832
1372
  if (verdict !== "pass") {
833
- // Fell out of the loop without a PASS and without hitting the explicit outer-breaker return
834
- // above — only reachable if budgets.maxRounds was already exceeded on entry (a resumed run).
835
- return withStateWarnings({ status: "gate_h", breaker: "outer", hammer_proposals: allHammerProposals, green_scopes: allGreenScopes });
1373
+ return withWarnings({ status: "gate_h", breaker: "outer", hammer_proposals: allHammer, green_scopes: allGreen });
836
1374
  }
837
1375
 
838
- // ---------------------------------------------------------------------------------------------
839
- // QA (post-PASS, pre-ship) — a level-up, never a gate that blocks; --no-qa is a preset that
840
- // answers "QA" with "skip" (round-protocol.md).
841
- // ---------------------------------------------------------------------------------------------
1376
+ // ---- QA (post-PASS, pre-ship) — a level-up, never a gate. `--no-qa` answers it "skip". --------
842
1377
  phase("QA");
843
1378
  let qaFindings = 0;
844
- const qaGate = await resolveGate(slug, args.answers, "QA");
845
- if (qaGate.exit_code === 4) return paused("QA", ["run", "skip", "ask"], { round, verdict });
846
- if (qaGate.exit_code === 5) return abortedFrom("QA", qaGate, "GATE QA aborted");
847
- if (qaGate.decision === "run") {
848
- const qaOrder = await compile(
849
- `--operation hunt --slug ${slug} --payload '${JSON.stringify({ feature: slug, spec_folder: facts.spec_folder, eval_report: `.${"shapeup"}/${slug}/results/evaluate-r${round}.json`, app_url: facts.app_url })}'`,
850
- "compile:hunt",
851
- );
852
- const qaResult = await dispatch(
853
- "qa-edge-hunter", qaOrder.stdout.trim(), qaModel, "QA", "hunt",
854
- QA_SCHEMA, "Exploratory hunt over the shipped feature. No verdict, no score findings only.",
855
- );
856
- // QA is a level-up, not a gate (AGENTS.md) — a dead hunter must not sink a run that already
857
- // passed EVAL. Record it and ship without QA rather than abort.
858
- if (qaResult.__failed) log(`QA hunt lost its worker: ${qaResult.__failed} — continuing without QA findings`);
859
- else {
860
- const qaIngest = ingestFailure(await ingest(resultFor(qaOrder.stdout.trim(), qaResult.result_path, "ingest:hunt"), "ingest:hunt"), "ingest:hunt");
861
- if (qaIngest) log(`QA findings were not applied: ${qaIngest} — shipping without them (QA is a level-up, not a gate)`);
862
- }
863
- qaFindings = qaResult.findings_count;
1379
+ const qaG = await crossGate("QA", "QA", ["run", "skip", "ask"], { round, verdict });
1380
+ if (qaG.stop) return withWarnings(qaG.stop);
1381
+ const qaRan = !args.noQa && qaG.decision === "run";
1382
+ if (qaRan) {
1383
+ const q = await worker({
1384
+ skill: "qa-edge-hunter", operation: "hunt", schema: QA_REPORT, phase: "QA", label: "hunt", model: qaModel,
1385
+ payload: { feature: slug, spec_folder: specFolder, app_url: rs.app_url, round },
1386
+ extra: "Exploratory hunt over the shipped feature. No verdict and no score — findings only, each with a repro.",
1387
+ });
1388
+ // QA is a level-up: losing its worker costs the findings, not the run.
1389
+ if (q.__failed) log(`QA the hunt lost its worker: ${q.__failed}. Shipping without QA findings.`);
1390
+ else qaFindings = q.findings_count;
864
1391
  }
865
1392
 
866
- // ---------------------------------------------------------------------------------------------
867
- // GATE H — delegated to scope-hammer (census, baseline comparison, cut list). Runs on the
868
- // normal-stop path here (a PASS); the two breaker paths return earlier, above, without a hammer
869
- // dispatch of their own — GATE H's census there is the caller's job (the thin skill relays
870
- // `hammer_proposals` to a fresh scope-hammer call, same as any other cut candidate).
871
- // ---------------------------------------------------------------------------------------------
1393
+ // ---- GATE H — delegated to scope-hammer (census, baseline comparison, cut list) ----------------
872
1394
  phase("Ship");
873
- const hammerOrder = await compile(
874
- `--operation hammer --slug ${slug} --payload '${JSON.stringify({ feature: slug })}'`,
875
- "compile:hammer",
876
- );
877
- const hammerResult = await dispatch(
878
- "scope-hammer", hammerOrder.stdout.trim(), args.models.exec, "Ship", "hammer",
879
- HAMMER_SCHEMA, "Run the H0/H1/H2 census, baseline comparison, and cut list.",
880
- );
881
- if (hammerResult.__failed) return dispatchAborted("H", hammerResult);
882
- const hammerIngest = await ingestOrAbort("H", resultFor(hammerOrder.stdout.trim(), hammerResult.result_path, "ingest:hammer"), "ingest:hammer");
883
- if (hammerIngest) return hammerIngest;
884
-
885
- if (hammerResult.verdict === "cannot-ship") {
886
- return { status: "aborted", aborted_at: "H", reason: `scope-hammer verdict: CANNOT SHIP — ${hammerResult.cut_list.join(", ") || "a must-have item failed"}` };
1395
+ const h = await worker({
1396
+ skill: "scope-hammer", operation: "hammer", schema: HAMMER, phase: "Ship", label: "hammer",
1397
+ payload: { feature: slug, qa_findings: qaFindings, hammer_proposals: allHammer },
1398
+ extra: "Run the census, compare against the BASELINE and never the ideal, and produce the cut list.",
1399
+ });
1400
+ if (h.__failed) return diedAt("H", h);
1401
+ if (h.verdict === "cannot-ship") {
1402
+ return withWarnings(aborted("H", `scope-hammer: CANNOT SHIP — ${h.cut_list.join(", ") || "a must-have failed"}`));
887
1403
  }
888
1404
 
889
- const hGate = await resolveGate(slug, args.answers, "H");
890
- if (hGate.exit_code === 4) return paused("H", ["accept-cut-list", "ship-all", "ask"], { verdict: hammerResult.verdict, cut_list: hammerResult.cut_list });
891
- if (hGate.exit_code === 5) return abortedFrom("H", hGate, "GATE H aborted");
1405
+ {
1406
+ const g = await crossGate("H", "Ship", ["accept-cut-list", "ship-all", "ask"], { verdict: h.verdict, cut_list: h.cut_list });
1407
+ if (g.stop) return withWarnings(g.stop);
1408
+ }
892
1409
 
893
- const shipReport = await mech(`node "${args.pluginRoot}/skills/tech-lead/scripts/ship-report.mjs" --slug ${slug} --verdict PASS --qa ${qaGate.decision === "run" ? "run" : "skipped"}`, "ship-report");
894
- await setRunStatus(slug, "shipped");
1410
+ const ship = await cmd(`reduce ship --slug ${slug} --verdict PASS --qa ${qaRan ? "run" : "skipped"}`, "Ship", "ship-report");
1411
+ await advisory(`report export --slug ${slug}`, "Ship", "export-run");
1412
+ // The run's own concurrency, printed once where the records are complete and before the next run
1413
+ // supersedes the trace. It is a projection over `receipts/dispatch.jsonl` and `legs.jsonl`, so it
1414
+ // asserts nothing and gates nothing — and when those records cannot support a figure it says so
1415
+ // rather than printing a plausible one.
1416
+ await advisory(`probe concurrency --slug ${slug} --format table`, "Ship", "concurrency");
1417
+ await setRunStatus("shipped", "Ship");
895
1418
 
896
- // The dimensions the evaluator ships, so GATE L4 can say what "shipped" did NOT cover. Must stay in
897
- // step with spec-evaluator/references/dimensions/_registry.md a workflow has no filesystem of its
898
- // own, so it cannot read that registry and this literal is the only place it can be stated. It
899
- // omitted tdd-surface, which made the one dimension most likely to be off the most likely to go
900
- // unmentioned. A dimension injected by the consumer is theirs to track; this list covers what ships.
901
- const allDims = ["spec-conformance", "tdd-surface", "integration", "completeness", "test-surface-conformance", "security", "performance"];
902
- const dims_not_evaluated = allDims.filter(d => !(facts.eval_dimensions || []).includes(d));
1419
+ // The dimensions this run did NOT evaluate, so GATE L4 can say what "shipped" does not cover.
1420
+ const ALL_DIMS = ["spec-conformance", "tdd-surface", "integration", "completeness",
1421
+ "test-surface-conformance", "security", "performance"];
903
1422
 
904
- return withStateWarnings({
1423
+ return withWarnings({
905
1424
  status: "shipped",
906
1425
  verdict: "pass",
907
1426
  rounds_used: round,
908
- dims_not_evaluated: dims_not_evaluated,
1427
+ dims_not_evaluated: ALL_DIMS.filter((d) => !evalDims.includes(d)),
909
1428
  qa_findings: qaFindings,
910
- report: shipReport.stdout.trim(),
1429
+ report: ship.detail || `shapeup/${slug}/REPORT.md`,
911
1430
  });
1431
+
1432
+ // =============================================================================================
1433
+ // buildScope — one scope's full attempt ratchet, as a single worker leg.
1434
+ //
1435
+ // The ratchet lives inside the worker's own shell rather than in this control script because each
1436
+ // attempt is implement → `harness verify t0`, and both halves need a real filesystem and a real
1437
+ // git. The worker reports only the outcome the round loop branches on.
1438
+ // =============================================================================================
1439
+ async function buildScope(scope, roundNo) {
1440
+ // THE VERDICT'S BUGS REACH THIS LEG THROUGH ITS ORDER, NOT THROUGH THIS FUNCTION.
1441
+ //
1442
+ // `WorkOrderPayload.bugs` is "the EVAL report's bug entries for this task — touch nothing else",
1443
+ // and AGENTS.md states the regression rule as "bugs + full Test Surface of touched UC". Nothing
1444
+ // populated it, so a round r+1 leg was dispatched with no idea the judge had cited anything: it
1445
+ // re-ran T0, found the scope still green, and reported done. Measured — EVAL returned FAIL naming
1446
+ // five defects at file:line; round 2 kept all six trees and changed none of them, and every bug
1447
+ // re-probed identically.
1448
+ //
1449
+ // Threading them from here was tried first and cannot work, twice over: a build order overrides
1450
+ // the compile line (below), and only the GENERIC line serialises `--payload`, so the field was
1451
+ // built and dropped; and the value lived in a variable, which a relaunch between two rounds
1452
+ // resets to empty. `harness compile` reads them off the ledgered verdict instead — see
1453
+ // `verdictBugs` — so a fresh round and a resumed one take the identical path.
1454
+ //
1455
+ // `digested_errors` could not cover this: it carries AEGIS triples from a RED T0, and a scope
1456
+ // whose fixtures pass while its behaviour contradicts the spec has no red trial to digest. That
1457
+ // gap is why EVAL is a separate layer, so the channel out of it has to be separate too.
1458
+ return worker({
1459
+ // No `operation` either, and for the same reason as the payload: the compile override below
1460
+ // replaces the whole command, so a declared operation here would be discarded — and it would
1461
+ // now be WRONG as well as dead. `harness compile` derives it: a round carrying cited defects
1462
+ // compiles as `fix`, which is the operation task-executor's own contract binds `payload.bugs`
1463
+ // to, and a round with nothing cited stays `execute`.
1464
+ skill: "task-executor", schema: SCOPE_RESULT, phase: "Build",
1465
+ label: `build:${scope.scope_id}-r${roundNo}`,
1466
+ // A build order is addressed by scope + round + attempt, never by operation: the attempt number
1467
+ // is part of its identity, so there is one order per attempt and the generic slug form cannot
1468
+ // express it. NOTE this override is why no `payload` is passed — it would be discarded.
1469
+ compile: `compile --scope "${scope.path}" --round ${roundNo} --attempt 1`,
1470
+ extra:
1471
+ (roundNo > 1
1472
+ ? `THIS MAY BE A FIX ROUND. If your compiled order carries \`payload.bugs\`, the evaluator ` +
1473
+ `returned FAIL last round and those are the defects it cited against files you own — each ` +
1474
+ `with the criterion it broke and a file:line. Fix exactly those and touch nothing else. ` +
1475
+ `They are spec-conformance defects, so T0 already passes and will keep passing whether or ` +
1476
+ `not you fix them: a green T0 is NOT evidence you are done this round, and re-running the ` +
1477
+ `fixtures cannot tell you. Read the cited lines against the committed spec, change them, ` +
1478
+ `and keep T0 green. An entry marked \`unowned\` cites no file any scope owns — fix it only ` +
1479
+ `if it falls inside your substrate. `
1480
+ : "") +
1481
+ `Re-compile the order for every attempt after the first, with --attempt <n>. ` +
1482
+ `Run the attempt ratchet for THIS scope only: up to ${attemptBudget} attempts of implement → ` +
1483
+ `\`node "${KERNEL}" verify t0 "${scope.path}" --round ${roundNo} --attempt <n>\`, each scored against ` +
1484
+ `the last kept trial. Stop on the first green T0, or when the attempt budget or the stagnation ` +
1485
+ `breaker trips. Write only inside this scope's substrate whitelist — the sandbox hook enforces it. ` +
1486
+ `Report green, attempts_used, which breaker (if any) tripped, and the T0 artifact path.`,
1487
+ });
1488
+ }