shapeup-sdlc 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +36 -103
- package/README.md +60 -67
- package/SECURITY.md +31 -16
- package/bin/init.mjs +24 -69
- package/commands/build.md +19 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +37 -4
- package/hooks/anti-rationalization.mjs +7 -13
- package/hooks/compact-snapshot.mjs +5 -5
- package/hooks/gate-deadline.mjs +12 -16
- package/hooks/gate-intake.mjs +13 -14
- package/hooks/gate-l2.mjs +3 -3
- package/hooks/gate-zerowork.mjs +97 -16
- package/hooks/lib/decision.mjs +21 -13
- package/hooks/safety-spine.mjs +1 -1
- package/hooks/sandbox-guard.mjs +83 -47
- package/hooks/session-rehydrate.mjs +8 -9
- package/hooks/slop-cleaner.mjs +1 -1
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +1 -1
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +1 -1
- package/oracles/snapshot-oracle.mjs +1 -1
- package/oracles/test-oracle.mjs +1 -1
- package/package.json +3 -7
- package/skills/ba-pitch-analyzer/SKILL.md +20 -15
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +1 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +6 -6
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +5 -5
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +2 -2
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +9 -39
- package/skills/scope-architect/SKILL.md +17 -12
- package/skills/scope-hammer/SKILL.md +5 -5
- package/skills/shapeup/SKILL.md +3 -3
- package/skills/shapeup/resources/context-compaction.md +4 -3
- package/skills/solution-architect/SKILL.md +18 -11
- package/skills/spec-evaluator/SKILL.md +12 -5
- package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
- package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
- package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
- package/skills/spec-evaluator/references/probing.md +42 -23
- package/skills/spec-evaluator/references/report-schema.md +2 -2
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +1 -1
- package/skills/task-executor/SKILL.md +25 -12
- package/skills/tech-lead/SKILL.md +115 -438
- package/skills/tech-lead/references/delegation.md +38 -31
- package/skills/tech-lead/references/gates.md +75 -33
- package/skills/tech-lead/references/hard-rules.md +34 -0
- package/skills/tech-lead/references/invocation.md +2 -2
- package/skills/tech-lead/references/ledger-schema.md +17 -18
- package/skills/tech-lead/references/round-protocol.md +77 -27
- package/skills/tech-lead/references/state-model.md +1 -1
- package/skills/tech-lead/schemas/domain.schema.json +320 -148
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-result.schema.json +55 -17
- package/skills/tech-lead/scripts/aegis-digest.mjs +1 -1
- package/skills/tech-lead/scripts/budget-check.mjs +7 -13
- package/skills/tech-lead/scripts/compile-order.mjs +69 -22
- package/skills/tech-lead/scripts/fit-check.mjs +10 -10
- package/skills/tech-lead/scripts/gate-answers.mjs +11 -12
- package/skills/tech-lead/scripts/hill-derive.mjs +133 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +7 -19
- package/skills/tech-lead/scripts/init-run.mjs +60 -18
- package/skills/tech-lead/scripts/lib/argv.mjs +1 -1
- package/skills/tech-lead/scripts/lib/contract-md.mjs +10 -10
- package/skills/tech-lead/scripts/lib/is-main.mjs +11 -12
- package/skills/tech-lead/scripts/lib/paths.mjs +15 -13
- package/skills/tech-lead/scripts/resume-state.mjs +387 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +1 -6
- package/skills/tech-lead/scripts/run-workflow.mjs +381 -0
- package/skills/tech-lead/scripts/ship-report.mjs +33 -2
- package/skills/tech-lead/scripts/stats.mjs +9 -10
- package/skills/tech-lead/scripts/t0-verify.mjs +4 -4
- package/skills/tech-lead/scripts/trace-lint.mjs +2 -2
- package/skills/tech-lead/workflows/shapeup-run.js +911 -0
- package/skills/translator/SKILL.md +2 -2
- package/skills/advisor-protocol/SKILL.md +0 -171
|
@@ -0,0 +1,911 @@
|
|
|
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.
|
|
5
|
+
//
|
|
6
|
+
// 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.
|
|
18
|
+
//
|
|
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.
|
|
26
|
+
//
|
|
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.
|
|
44
|
+
//
|
|
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)
|
|
55
|
+
//
|
|
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 }
|
|
59
|
+
// { 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).
|
|
63
|
+
|
|
64
|
+
export const meta = {
|
|
65
|
+
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.",
|
|
67
|
+
phases: [
|
|
68
|
+
{ title: "Orient" }, { title: "Analyze" }, { title: "Wire" }, { title: "MapScopes" },
|
|
69
|
+
{ title: "Build" }, { title: "Eval" }, { title: "QA" }, { title: "Ship" },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
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
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------------------------
|
|
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.
|
|
82
|
+
// ---------------------------------------------------------------------------------------------
|
|
83
|
+
const MODEL_FLOOR_ALLOWED = new Set(["sonnet", "opus"]);
|
|
84
|
+
const belowFloor = (m) => !MODEL_FLOOR_ALLOWED.has(String(m || "").toLowerCase());
|
|
85
|
+
|
|
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
|
+
function validateArgs(a) {
|
|
91
|
+
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}`);
|
|
94
|
+
}
|
|
95
|
+
if (!["interactive", "auto", "unattended"].includes(a.autoLevel)) {
|
|
96
|
+
problems.push(`args.autoLevel="${a.autoLevel}" must be interactive|auto|unattended`);
|
|
97
|
+
}
|
|
98
|
+
if (a.budgets && (!a.budgets.maxRounds || !a.budgets.attemptBudget)) {
|
|
99
|
+
problems.push("args.budgets must carry maxRounds and attemptBudget");
|
|
100
|
+
}
|
|
101
|
+
if (a.models) {
|
|
102
|
+
for (const role of ["exec", "eval"]) {
|
|
103
|
+
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`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return problems;
|
|
113
|
+
}
|
|
114
|
+
|
|
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
|
+
};
|
|
128
|
+
|
|
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
|
+
);
|
|
142
|
+
|
|
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
|
+
);
|
|
200
|
+
|
|
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.
|
|
205
|
+
//
|
|
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.
|
|
247
|
+
//
|
|
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.
|
|
256
|
+
//
|
|
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.
|
|
260
|
+
//
|
|
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.",
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
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}`);
|
|
311
|
+
}
|
|
312
|
+
return r;
|
|
313
|
+
};
|
|
314
|
+
|
|
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);
|
|
318
|
+
|
|
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
|
+
);
|
|
334
|
+
|
|
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
|
+
);
|
|
347
|
+
|
|
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.`);
|
|
368
|
+
}
|
|
369
|
+
return derived;
|
|
370
|
+
};
|
|
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;
|
|
374
|
+
};
|
|
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}` };
|
|
414
|
+
};
|
|
415
|
+
|
|
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 });
|
|
418
|
+
|
|
419
|
+
const ORIENT_SCHEMA = {
|
|
420
|
+
type: "object",
|
|
421
|
+
properties: {
|
|
422
|
+
result_path: { type: "string" },
|
|
423
|
+
spiked_area: { type: "string" },
|
|
424
|
+
spike_result: { type: "string" },
|
|
425
|
+
riskiest_unknowns: { type: "array", items: { type: "string" } },
|
|
426
|
+
},
|
|
427
|
+
required: ["result_path", "spiked_area", "spike_result"],
|
|
428
|
+
};
|
|
429
|
+
const RESULT_ONLY_SCHEMA = { type: "object", properties: { result_path: { type: "string" } }, required: ["result_path"] };
|
|
430
|
+
const MAPSCOPES_SCHEMA = {
|
|
431
|
+
type: "object",
|
|
432
|
+
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"] } },
|
|
435
|
+
},
|
|
436
|
+
required: ["result_path", "scopes"],
|
|
437
|
+
};
|
|
438
|
+
const QA_SCHEMA = {
|
|
439
|
+
type: "object",
|
|
440
|
+
properties: { result_path: { type: "string" }, findings_count: { type: "integer" } },
|
|
441
|
+
required: ["result_path", "findings_count"],
|
|
442
|
+
};
|
|
443
|
+
const HAMMER_SCHEMA = {
|
|
444
|
+
type: "object",
|
|
445
|
+
properties: {
|
|
446
|
+
result_path: { type: "string" },
|
|
447
|
+
verdict: { type: "string", enum: ["ship-now", "ship-after-fixes", "cannot-ship"] },
|
|
448
|
+
cut_list: { type: "array", items: { type: "string" } },
|
|
449
|
+
},
|
|
450
|
+
required: ["result_path", "verdict", "cut_list"],
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------------------------
|
|
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.
|
|
456
|
+
// ---------------------------------------------------------------------------------------------
|
|
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}`,
|
|
471
|
+
);
|
|
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 = {
|
|
478
|
+
L1a: "Orient Review", "L1a.5": "Wiring Review", L1b: "Board Review",
|
|
479
|
+
L2: "Build Round Complete", L3: "Verdict & Loop", QA: "QA Edge Hunt", H: "Decide When to Stop",
|
|
480
|
+
};
|
|
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
|
+
};
|
|
491
|
+
|
|
492
|
+
// =================================================================================================
|
|
493
|
+
// The run.
|
|
494
|
+
// =================================================================================================
|
|
495
|
+
|
|
496
|
+
const argProblems = validateArgs(args);
|
|
497
|
+
if (argProblems.length) {
|
|
498
|
+
return { status: "aborted", aborted_at: "args", reason: `shapeup-run: ${argProblems.join("; ")}` };
|
|
499
|
+
}
|
|
500
|
+
|
|
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;
|
|
506
|
+
|
|
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}` };
|
|
511
|
+
}
|
|
512
|
+
|
|
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).
|
|
516
|
+
//
|
|
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) {
|
|
526
|
+
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");
|
|
544
|
+
} else {
|
|
545
|
+
log("ORIENT — artifacts already present, skipping dispatch (fast-forward)");
|
|
546
|
+
}
|
|
547
|
+
|
|
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");
|
|
551
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------------------------
|
|
568
|
+
phase("Analyze");
|
|
569
|
+
if (!facts.has_spec_tree) {
|
|
570
|
+
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;
|
|
585
|
+
} else {
|
|
586
|
+
log("ANALYZE — spec tree already present, skipping dispatch (fast-forward)");
|
|
587
|
+
}
|
|
588
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------------------------
|
|
594
|
+
phase("Wire");
|
|
595
|
+
if (!facts.has_wiring_map) {
|
|
596
|
+
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;
|
|
611
|
+
} else {
|
|
612
|
+
log("WIRE — wiring-map.md already present, skipping dispatch (fast-forward)");
|
|
613
|
+
}
|
|
614
|
+
|
|
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");
|
|
618
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------------------------
|
|
626
|
+
phase("MapScopes");
|
|
627
|
+
let scopes = facts.scope_files || [];
|
|
628
|
+
if (scopes.length === 0) {
|
|
629
|
+
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;
|
|
642
|
+
} else {
|
|
643
|
+
log(`MAP SCOPES — ${scopes.length} scope contract(s) already present, skipping dispatch (fast-forward)`);
|
|
644
|
+
}
|
|
645
|
+
|
|
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()}` };
|
|
655
|
+
}
|
|
656
|
+
|
|
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()}`);
|
|
660
|
+
}
|
|
661
|
+
|
|
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");
|
|
666
|
+
|
|
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;
|
|
673
|
+
let verdict = null;
|
|
674
|
+
|
|
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}`);
|
|
677
|
+
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 });
|
|
683
|
+
}
|
|
684
|
+
|
|
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
|
+
}
|
|
698
|
+
|
|
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
|
+
}
|
|
728
|
+
|
|
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
|
+
}
|
|
765
|
+
|
|
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`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
allGreenScopes.push(...roundGreen);
|
|
774
|
+
allHammerProposals.push(...roundHammer.map((h) => h.scope_id));
|
|
775
|
+
|
|
776
|
+
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 });
|
|
782
|
+
}
|
|
783
|
+
|
|
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()}`);
|
|
787
|
+
}
|
|
788
|
+
|
|
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
|
+
|
|
793
|
+
phase("Eval");
|
|
794
|
+
if (args.noEval) {
|
|
795
|
+
log("EVAL — skipped (--no-eval)");
|
|
796
|
+
verdict = "pass";
|
|
797
|
+
} 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";
|
|
813
|
+
}
|
|
814
|
+
|
|
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()}`);
|
|
818
|
+
}
|
|
819
|
+
|
|
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");
|
|
823
|
+
|
|
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 });
|
|
827
|
+
}
|
|
828
|
+
await setRunStatus(slug, "building");
|
|
829
|
+
round += 1;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
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 });
|
|
836
|
+
}
|
|
837
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------------------------
|
|
842
|
+
phase("QA");
|
|
843
|
+
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;
|
|
864
|
+
}
|
|
865
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------------------------
|
|
872
|
+
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"}` };
|
|
887
|
+
}
|
|
888
|
+
|
|
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");
|
|
892
|
+
|
|
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");
|
|
895
|
+
|
|
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));
|
|
903
|
+
|
|
904
|
+
return withStateWarnings({
|
|
905
|
+
status: "shipped",
|
|
906
|
+
verdict: "pass",
|
|
907
|
+
rounds_used: round,
|
|
908
|
+
dims_not_evaluated: dims_not_evaluated,
|
|
909
|
+
qa_findings: qaFindings,
|
|
910
|
+
report: shipReport.stdout.trim(),
|
|
911
|
+
});
|