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.
- package/.claude/settings.local.example.json +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +17 -3
- package/README.md +124 -103
- package/SECURITY.md +37 -30
- package/bin/init.mjs +77 -47
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +7 -6
- package/commands/ship.md +29 -16
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +3 -3
- package/hooks/gate-zerowork.mjs +25 -24
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +19 -8
- package/hooks/safety-spine.mjs +3 -3
- package/hooks/sandbox-guard.mjs +84 -40
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
- package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +14 -9
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
- package/kernel/reduce/graph.mjs +437 -0
- package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
- package/kernel/report/export.mjs +321 -0
- package/kernel/report/facts.mjs +209 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
- package/oracles/http-oracle.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 +11 -9
- package/skills/ba-pitch-analyzer/SKILL.md +5 -5
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
- package/skills/qa-edge-hunter/SKILL.md +7 -3
- package/skills/scope-architect/SKILL.md +40 -6
- package/skills/solution-architect/SKILL.md +19 -4
- package/skills/spec-evaluator/SKILL.md +12 -2
- package/skills/task-executor/SKILL.md +7 -6
- package/skills/tech-lead/SKILL.md +50 -38
- package/skills/tech-lead/references/gates.md +71 -20
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +341 -66
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +1 -1
- package/skills/tech-lead/workflows/shapeup-run.js +1327 -750
- package/hooks/anti-rationalization.mjs +0 -238
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -147
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -108
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -261
- package/skills/tech-lead/references/hard-rules.md +0 -34
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -213
- package/skills/tech-lead/references/round-protocol.md +0 -234
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
- package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
- package/skills/translator/README.md +0 -66
|
@@ -1,911 +1,1488 @@
|
|
|
1
|
-
// shapeup-run — the
|
|
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
|
|
8
|
-
// rounds of (BUILD
|
|
9
|
-
// QA
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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",
|
|
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: "
|
|
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
|
|
74
|
-
|
|
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
|
-
//
|
|
81
|
-
//
|
|
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
|
-
|
|
84
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
249
|
-
//
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
/**
|
|
417
|
-
const
|
|
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
|
|
480
|
+
const ORIENT = {
|
|
420
481
|
type: "object",
|
|
421
482
|
properties: {
|
|
422
|
-
|
|
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: ["
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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: ["
|
|
564
|
+
required: ["ok", "round"],
|
|
437
565
|
};
|
|
438
|
-
|
|
566
|
+
|
|
567
|
+
const REFUTATION = {
|
|
439
568
|
type: "object",
|
|
440
|
-
properties: {
|
|
441
|
-
required: ["
|
|
569
|
+
properties: { id: { type: "string" }, refuted: { type: "boolean" }, why: { type: "string" } },
|
|
570
|
+
required: ["id", "refuted"],
|
|
442
571
|
};
|
|
443
|
-
|
|
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
|
-
|
|
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: ["
|
|
586
|
+
required: ["ok", "verdict", "cut_list"],
|
|
451
587
|
};
|
|
452
588
|
|
|
453
589
|
// ---------------------------------------------------------------------------------------------
|
|
454
|
-
//
|
|
455
|
-
//
|
|
590
|
+
// DISPATCH — three 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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const r = await
|
|
469
|
-
`node "${
|
|
470
|
-
`
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 = (
|
|
482
|
-
`⏸ GATE ${
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
//
|
|
515
|
-
//
|
|
880
|
+
// =============================================================================================
|
|
881
|
+
// THE RUN
|
|
882
|
+
// =============================================================================================
|
|
883
|
+
phase("Preflight");
|
|
884
|
+
|
|
885
|
+
// CANARY — can THIS SESSION resolve a worker skill at all?
|
|
516
886
|
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
"
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
958
|
+
const post = await fastForward("ORIENT", "orient", "Orient", "artifacts already on disk");
|
|
959
|
+
if (post) return withWarnings(post);
|
|
546
960
|
}
|
|
547
961
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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 (!
|
|
970
|
+
if (!rs.has_spec_tree) {
|
|
570
971
|
log(`ANALYZE — dispatching (slug ${slug})`);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
);
|
|
605
|
-
|
|
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
|
-
|
|
1020
|
+
const post = await fastForward("WIRE", "wire", "Wire", "wiring map already on disk");
|
|
1021
|
+
if (post) return withWarnings(post);
|
|
613
1022
|
}
|
|
614
1023
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (
|
|
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
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
if (
|
|
665
|
-
|
|
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
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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 ${
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
);
|
|
808
|
-
if (
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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;
|
|
825
|
-
if (
|
|
826
|
-
return
|
|
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
|
-
|
|
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
|
|
845
|
-
if (
|
|
846
|
-
|
|
847
|
-
if (
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
if (
|
|
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
|
|
894
|
-
await
|
|
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
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
1423
|
+
return withWarnings({
|
|
905
1424
|
status: "shipped",
|
|
906
1425
|
verdict: "pass",
|
|
907
1426
|
rounds_used: round,
|
|
908
|
-
dims_not_evaluated:
|
|
1427
|
+
dims_not_evaluated: ALL_DIMS.filter((d) => !evalDims.includes(d)),
|
|
909
1428
|
qa_findings: qaFindings,
|
|
910
|
-
report:
|
|
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
|
+
}
|