shapeup-sdlc 3.18.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.example.json +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +14 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
|
@@ -1,2187 +0,0 @@
|
|
|
1
|
-
// shapeup-run — the BUILD-phase pipeline as one script on the native Dynamic Workflow runtime.
|
|
2
|
-
//
|
|
3
|
-
// WHAT THIS FILE OWNS.
|
|
4
|
-
// ORIENT → GATE L1a → ANALYZE → WIRE → GATE L1a.5 → MAP SCOPES → GATE L1b →
|
|
5
|
-
// rounds of (BUILD → build gate → GATE L2 → EVAL → GATE L3) bounded by budgets.maxRounds →
|
|
6
|
-
// QA → GATE H → ship report → RunReturn.
|
|
7
|
-
//
|
|
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.
|
|
16
|
-
//
|
|
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.
|
|
20
|
-
//
|
|
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.
|
|
25
|
-
//
|
|
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}, 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, qa, report }
|
|
42
|
-
// { status: "paused", paused_at, block, valid_decisions, context }
|
|
43
|
-
// { status: "aborted", aborted_at, reason }
|
|
44
|
-
// { status: "gate_h", breaker: "outer"|"attempt_budget"|"none"|"deadline", hammer_proposals, green_scopes,
|
|
45
|
-
// tripped_scopes?, unapplied_results?, census?, cut_list?, l4? }
|
|
46
|
-
// — returned only when the census said cannot-ship, L4 said hold, or the ship report was
|
|
47
|
-
// refused; a breaker whose census clears the run ends as `shipped` with `after: "gate_h"`.
|
|
48
|
-
|
|
49
|
-
// meta must be a PURE LITERAL — the runtime parses it statically, before the body ever runs, and
|
|
50
|
-
// rejects the whole script on anything it has to evaluate. A `+`-joined description is a
|
|
51
|
-
// BinaryExpression, so the concatenation that reads better in source costs the file its ability to
|
|
52
|
-
// load at all. Keep every value here a plain literal, however long the line gets.
|
|
53
|
-
export const meta = {
|
|
54
|
-
name: "shapeup-run",
|
|
55
|
-
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.",
|
|
56
|
-
phases: [
|
|
57
|
-
{ title: "Preflight", detail: "one canary dispatch — can this session resolve a worker skill; then the project's build and launch probes, run once and recorded nowhere" },
|
|
58
|
-
{ title: "Orient" }, { title: "Analyze" }, { title: "Wire" }, { title: "MapScopes" },
|
|
59
|
-
{ title: "Build" }, { title: "Eval" }, { title: "Refute" }, { title: "QA" }, { title: "Ship" },
|
|
60
|
-
],
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// Some callers hand args as a JSON string.
|
|
64
|
-
if (typeof args === "string") { try { args = JSON.parse(args); } catch { args = {}; } }
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------------------------
|
|
67
|
-
// ARGS — validated here because nothing validates the C1 boundary for us. A run that starts with a
|
|
68
|
-
// below-floor model or a missing budget produces work nobody can trust, and finding that out at
|
|
69
|
-
// GATE L3 costs the whole run.
|
|
70
|
-
// ---------------------------------------------------------------------------------------------
|
|
71
|
-
// ---- MODEL FLOOR REGION START ------------------------------------------------------------------
|
|
72
|
-
// This used to be an ALLOWLIST — exact spelling against {"sonnet","opus"} — which is backwards
|
|
73
|
-
// from fail-open: every real model id this repo actually ships with (`claude-opus-5`,
|
|
74
|
-
// `claude-sonnet-4-5`, `opusplan`) failed the exact-spelling test and aborted the run before
|
|
75
|
-
// Preflight. Fixed as a DENYLIST of tiers this repo never certifies eval or build with: anything
|
|
76
|
-
// NOT provably below the floor passes, matching this repo's own fail-open rule (an unrecognized
|
|
77
|
-
// string is evidence this repo hasn't seen it, not evidence it's too cheap). Only an empty/absent
|
|
78
|
-
// model string is rejected outright — that is a missing model, not an unrecognized one.
|
|
79
|
-
//
|
|
80
|
-
// The cheap/fast Claude tier is matched by PATTERN, never spelled out as a literal string here —
|
|
81
|
-
// a literal name in this file would trip the structural suite's own floor check, which scans every
|
|
82
|
-
// workflow script for exactly that spelling and is deliberately phrased the same indirect way.
|
|
83
|
-
const BELOW_FLOOR_PATTERNS = [/ha[i1]ku/i, /\binstant\b/i, /\bmini\b/i, /\bnano\b/i, /\blite\b/i, /\bsmall\b/i];
|
|
84
|
-
const belowFloor = (m) => {
|
|
85
|
-
const s = String(m || "").trim();
|
|
86
|
-
if (!s) return true; // no model named at all — missing, not merely unrecognized
|
|
87
|
-
return BELOW_FLOOR_PATTERNS.some((re) => re.test(s));
|
|
88
|
-
};
|
|
89
|
-
// ---- MODEL FLOOR REGION END --------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
function validateArgs(a) {
|
|
92
|
-
const problems = [];
|
|
93
|
-
for (const k of ["slug", "autoLevel", "models", "budgets", "pluginRoot"]) {
|
|
94
|
-
if (a?.[k] === undefined || a[k] === null || a[k] === "") problems.push(`missing args.${k}`);
|
|
95
|
-
}
|
|
96
|
-
if (a && !["interactive", "auto", "unattended"].includes(a.autoLevel)) {
|
|
97
|
-
problems.push(`args.autoLevel="${a.autoLevel}" must be interactive|auto|unattended`);
|
|
98
|
-
}
|
|
99
|
-
if (a?.budgets && (!a.budgets.maxRounds || !a.budgets.attemptBudget)) {
|
|
100
|
-
problems.push("args.budgets must carry maxRounds and attemptBudget");
|
|
101
|
-
}
|
|
102
|
-
if (a?.models) {
|
|
103
|
-
for (const role of ["exec", "eval"]) {
|
|
104
|
-
if (role === "eval" && a.noEval) continue;
|
|
105
|
-
if (belowFloor(a.models[role])) problems.push(`args.models.${role} is below the model floor (sonnet or above)`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return problems;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const argProblems = validateArgs(args);
|
|
112
|
-
if (argProblems.length) return { status: "aborted", aborted_at: "args", reason: argProblems.join("; ") };
|
|
113
|
-
|
|
114
|
-
const slug = args.slug;
|
|
115
|
-
// The report's path, never the ship command's one-line `detail`: that is a sub-agent's sentence, and
|
|
116
|
-
// it reached the RunReturn's `report` field in place of the path the orchestrator opens. The
|
|
117
|
-
// committed root is a fixed name, so the path is known without reading anything.
|
|
118
|
-
const REPORT_PATH = `shapeup/${slug}/REPORT.md`;
|
|
119
|
-
const KERNEL = `${args.pluginRoot}/kernel/harness.mjs`;
|
|
120
|
-
const execModel = args.models.exec;
|
|
121
|
-
const evalModel = args.models.eval;
|
|
122
|
-
const qaModel = args.models.qa || args.models.exec;
|
|
123
|
-
const maxRounds = args.budgets.maxRounds;
|
|
124
|
-
const attemptBudget = args.budgets.attemptBudget;
|
|
125
|
-
|
|
126
|
-
// How many scopes may build at once. A dial rather than a constant because concurrency is a COST
|
|
127
|
-
// question before it is a speed one: every extra leg is another worker's full context. 4 is the
|
|
128
|
-
// default because a feature is rarely cut into more independent slices than that; 1 restores the
|
|
129
|
-
// sequential behaviour for a project whose workers are not safe to run side by side.
|
|
130
|
-
//
|
|
131
|
-
// It is a CAP, not a group size. The window refills the instant any leg settles, so the dial bounds
|
|
132
|
-
// spend without ever quantising the schedule — see the scheduler below for what the difference cost.
|
|
133
|
-
const maxParallelScopes = Math.max(1, Number(args.maxParallelScopes ?? 4) || 1);
|
|
134
|
-
|
|
135
|
-
// ---- SCHEDULER REGION START ------------------------------------------------------------------
|
|
136
|
-
// BUILD's fan-out, as three separable decisions instead of one list operation:
|
|
137
|
-
//
|
|
138
|
-
// WAVES ORDER the scopes. EDGES RELEASE them. THE DIAL CAPS them.
|
|
139
|
-
//
|
|
140
|
-
// Fusing the three into `waves.flatMap((w) => chunk(w, maxParallelScopes))` cost wall-clock twice
|
|
141
|
-
// over, and both costs are structural rather than incidental:
|
|
142
|
-
//
|
|
143
|
-
// 1. `chunk` turns the dial from a CAP into a QUANTUM. A wave of six at a dial of four runs four,
|
|
144
|
-
// then idles three workers until the SLOWEST of those four lands, then runs two. Nothing about
|
|
145
|
-
// the scopes asked for that; it falls out of splitting a list instead of scheduling it.
|
|
146
|
-
// 2. A wave is a LEVEL, and a level says when a scope is definitely safe to start, never when it
|
|
147
|
-
// BECAME safe. The scope that wires the others to the entry point waits for the slowest command
|
|
148
|
-
// scope even when the one scope it actually consumes went green minutes earlier.
|
|
149
|
-
//
|
|
150
|
-
// Everything the previous loop bought with blood survives, and each is named at the line that keeps
|
|
151
|
-
// it: a scope never starts beside a scope it consumes; no scope is ever dropped; a dead builder is a
|
|
152
|
-
// spent attempt rather than a dead run; the dial is honoured exactly.
|
|
153
|
-
//
|
|
154
|
-
// This region is self-contained on purpose. The scheduling decision is PURE — no dispatch, no clock,
|
|
155
|
-
// no randomness — so it is the one part of this file that can be executed against a fixture instead
|
|
156
|
-
// of reasoned about, and the round loop below injects the real dispatcher as `launch`.
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* The dependency relation the fan-out releases on, as `scope_id → the scope_ids it waits for`.
|
|
160
|
-
*
|
|
161
|
-
* Three rungs, each a strict non-regression on the next. The edge list is used only when it
|
|
162
|
-
* re-levels to the SAME waves: both fields are projections of one graph the kernel parsed once, so
|
|
163
|
-
* each validates the other, and an edge dropped or invented in transit lands on the wave rung rather
|
|
164
|
-
* than releasing a scope early — which is the one direction this must never fail in.
|
|
165
|
-
*
|
|
166
|
-
* @param {*} rawDeps - `probe resume`'s `scope_deps`: `[dependant, dependency]` path pairs.
|
|
167
|
-
* @param {Array[]} waves - Validated dependency waves, arrays of scope objects.
|
|
168
|
-
* @param {Array} list - Every scope in the round.
|
|
169
|
-
* @returns {Map<string,Set<string>>} One entry per scope; an empty set means "ready immediately".
|
|
170
|
-
*/
|
|
171
|
-
function scopeEdges(rawDeps, waves, list) {
|
|
172
|
-
const known = new Set(list.map((s) => s.scope_id));
|
|
173
|
-
const idOf = (p) => String(p).split("/").pop().replace(/\.(md|json)$/, "");
|
|
174
|
-
const blank = () => new Map(list.map((s) => [s.scope_id, new Set()]));
|
|
175
|
-
|
|
176
|
-
// Kahn. A CYCLE IS THE ONE INPUT THAT TURNS THIS SCHEDULER INTO A HANG rather than a failure: two
|
|
177
|
-
// scopes awaiting each other produce no error, no log line and no artifact, which a list-chunking
|
|
178
|
-
// loop could not do. So an edge set is used only when every node peels.
|
|
179
|
-
const levelsOf = (m) => {
|
|
180
|
-
const done = new Set(), out = [];
|
|
181
|
-
for (;;) {
|
|
182
|
-
const ready = list.filter((s) => !done.has(s.scope_id) && [...m.get(s.scope_id)].every((d) => done.has(d)));
|
|
183
|
-
if (!ready.length) break;
|
|
184
|
-
out.push(ready.map((s) => s.scope_id).sort());
|
|
185
|
-
for (const s of ready) done.add(s.scope_id);
|
|
186
|
-
}
|
|
187
|
-
return done.size === list.length ? out : undefined;
|
|
188
|
-
};
|
|
189
|
-
const shape = (levels) => (levels ? JSON.stringify(levels) : "");
|
|
190
|
-
|
|
191
|
-
if (Array.isArray(rawDeps) && rawDeps.length) {
|
|
192
|
-
const m = blank();
|
|
193
|
-
let edges = 0;
|
|
194
|
-
for (const pair of rawDeps) {
|
|
195
|
-
if (!Array.isArray(pair) || pair.length < 2) continue;
|
|
196
|
-
const to = idOf(pair[0]), from = idOf(pair[1]);
|
|
197
|
-
if (to === from || !known.has(to) || !known.has(from)) continue;
|
|
198
|
-
m.get(to).add(from);
|
|
199
|
-
edges++;
|
|
200
|
-
}
|
|
201
|
-
const want = shape(waves.map((w) => w.map((s) => s.scope_id).sort()));
|
|
202
|
-
if (edges && shape(levelsOf(m)) === want) return m;
|
|
203
|
-
log(`BUILD order — the dependency edge list does not re-derive the wave order (${edges} usable ` +
|
|
204
|
-
`edge(s)); releasing per wave instead, which is what a wave-stepped fan-out did.`);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// WAVE RUNG. "Everything in wave i waits for everything in wave i-1" — the exact release points a
|
|
208
|
-
// wave-stepped loop had, and acyclic by construction. An absent or unusable edge list costs the
|
|
209
|
-
// per-edge release and nothing else; a single wave yields no edges at all, which is the fully
|
|
210
|
-
// unscheduled fan-out. A scheduler that refuses to run is worse than one that runs unscheduled.
|
|
211
|
-
const m = blank();
|
|
212
|
-
for (let i = 1; i < waves.length; i++) {
|
|
213
|
-
for (const to of waves[i]) for (const from of waves[i - 1]) m.get(to.scope_id).add(from.scope_id);
|
|
214
|
-
}
|
|
215
|
-
return m;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Forbid two scopes that may write the same path from building at the same time.
|
|
220
|
-
*
|
|
221
|
-
* WHY THIS IS THE SCHEDULER'S JOB AND NOBODY ELSE'S. `shared_substrate` is the declared escape hatch
|
|
222
|
-
* from the disjointness rule: the spec lint passes an overlap both contracts declare, and the
|
|
223
|
-
* sandbox guard permits that path to every live order naming it. Both layers are individually
|
|
224
|
-
* correct and the join is wrong — concurrent writers to one shared entry point lose each other's
|
|
225
|
-
* work, measured in every trial, with every check green. No lint can catch it because nothing is
|
|
226
|
-
* mis-declared; the only place the fact "these two are running RIGHT NOW" exists is here.
|
|
227
|
-
*
|
|
228
|
-
* AN EXCLUSION IS NOT A DEPENDENCY. Neither scope has to go first — they only have to not overlap —
|
|
229
|
-
* so each pair is oriented by BUILD POSITION, later waits for earlier. That orientation is what
|
|
230
|
-
* makes it safe to mix with real dependency edges: every dependency edge already points backwards
|
|
231
|
-
* in this list (it is the waves flattened, so a scope's dependencies are strictly earlier), and an
|
|
232
|
-
* exclusion oriented the same way keeps the whole set pointing backwards, which cannot cycle.
|
|
233
|
-
*
|
|
234
|
-
* DEGRADES LIKE EVERYTHING ELSE HERE, but note which way. Absent or unreadable pairs mean no
|
|
235
|
-
* exclusions and today's scheduling — a scheduler that refuses to run is worse than one that runs
|
|
236
|
-
* unscheduled. What is NOT acceptable is the opposite reading: when the pairs ARE there, the edge is
|
|
237
|
-
* enforced rather than warned about, because a run that quietly loses work is worse than both.
|
|
238
|
-
*
|
|
239
|
-
* @param {Map<string,Set<string>>} edges - Release edges so far; mutated and returned.
|
|
240
|
-
* @param {*} rawPairs - `probe resume`'s `scope_exclusions`: unordered path pairs.
|
|
241
|
-
* @param {Array} order - The build order. Position in it decides which side of a pair waits.
|
|
242
|
-
* @returns {{edges: Map<string,Set<string>>, added: number}} The composed set and how many landed.
|
|
243
|
-
*/
|
|
244
|
-
function withExclusions(edges, rawPairs, order) {
|
|
245
|
-
if (!Array.isArray(rawPairs) || !rawPairs.length) return { edges, added: 0 };
|
|
246
|
-
const idOf = (p) => String(p).split("/").pop().replace(/\.(md|json)$/, "");
|
|
247
|
-
const at = new Map(order.map((s, i) => [s.scope_id, i]));
|
|
248
|
-
let added = 0;
|
|
249
|
-
for (const pair of rawPairs) {
|
|
250
|
-
if (!Array.isArray(pair) || pair.length < 2) continue;
|
|
251
|
-
const a = idOf(pair[0]), b = idOf(pair[1]);
|
|
252
|
-
if (a === b || !at.has(a) || !at.has(b)) continue;
|
|
253
|
-
const [first, later] = at.get(a) < at.get(b) ? [a, b] : [b, a];
|
|
254
|
-
if (edges.get(later)?.has(first)) continue; // already ordered by a real edge
|
|
255
|
-
edges.get(later).add(first);
|
|
256
|
-
added++;
|
|
257
|
-
}
|
|
258
|
-
return { edges, added };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* The widest set of scopes this release graph can have open at one moment.
|
|
263
|
-
*
|
|
264
|
-
* WHY A RUN NEEDS THIS BEFORE IT DISPATCHES ANYTHING. The dial says how many legs are PAID FOR; this
|
|
265
|
-
* says how many the constraints actually permit, and the two are not the same number. A feature
|
|
266
|
-
* whose entry point sits in five scopes' substrate has a ceiling of ONE however wide the dial is
|
|
267
|
-
* opened — that is a scope-cutting fact, decided at the board review, and without this line it shows
|
|
268
|
-
* up hours later as a slow round that reads exactly like slow workers.
|
|
269
|
-
*
|
|
270
|
-
* It also splits the concurrency shortfall into its two causes, which no single observation can. A
|
|
271
|
-
* peak below the CEILING is a dispatch or runtime limit; a ceiling below the DIAL is the scope cut.
|
|
272
|
-
* One number each, and the repair is different.
|
|
273
|
-
*
|
|
274
|
-
* The measure is the widest level of the release DAG, which is a guarantee rather than an estimate:
|
|
275
|
-
* every member of a level has all its predecessors in earlier levels, so a level's whole width is
|
|
276
|
-
* simultaneously admissible.
|
|
277
|
-
*
|
|
278
|
-
* @param {Map<string,Set<string>>} edges - The composed release set.
|
|
279
|
-
* @param {Array} items - The scopes in the round.
|
|
280
|
-
* @returns {number} Widest simultaneously-admissible set; 1 when nothing may overlap.
|
|
281
|
-
*/
|
|
282
|
-
function releaseCeiling(edges, items) {
|
|
283
|
-
const done = new Set();
|
|
284
|
-
let widest = 1;
|
|
285
|
-
for (;;) {
|
|
286
|
-
const ready = items.filter((s) => !done.has(s.scope_id) && [...(edges.get(s.scope_id) || [])].every((d) => done.has(d)));
|
|
287
|
-
if (!ready.length) break;
|
|
288
|
-
widest = Math.max(widest, ready.length);
|
|
289
|
-
for (const s of ready) done.add(s.scope_id);
|
|
290
|
-
}
|
|
291
|
-
return widest;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Run `launch` over every item, at most `width` at once, releasing each item as soon as ITS OWN
|
|
296
|
-
* dependencies have settled rather than when its whole wave has.
|
|
297
|
-
*
|
|
298
|
-
* Release is on SETTLED, not on green, which is deliberate and matches what a wave-stepped loop did.
|
|
299
|
-
* Gating on green is strictly stronger and costs more than it buys: a dependency that fails would
|
|
300
|
-
* starve every dependant of any attempt at all, and those scopes would leave the round's census
|
|
301
|
-
* entirely — an outcome class the gate, the hill projection and the ledger have no reading for. What
|
|
302
|
-
* must never happen is a scope building CONCURRENTLY with a scope it consumes, and that is what the
|
|
303
|
-
* dependency await forbids.
|
|
304
|
-
*
|
|
305
|
-
* No race, no timer, no clock — deliberately. `Promise.race` over the in-flight legs would tighten
|
|
306
|
-
* the schedule marginally and is disqualified for the same reason a clock read is: it makes the
|
|
307
|
-
* schedule a function of real time, so a relaunch reschedules differently.
|
|
308
|
-
*
|
|
309
|
-
* @param {Array} items - The scopes, in the order the waves put them.
|
|
310
|
-
* @param {Map<string,Set<string>>} edges - scope_id → the scope_ids it waits for.
|
|
311
|
-
* @param {number} width - Concurrency cap.
|
|
312
|
-
* @param {function(object): Promise<object>} launch - Dispatches one scope. May reject; may not be
|
|
313
|
-
* trusted to return anything.
|
|
314
|
-
* @returns {Promise<object[]>} Exactly one settled record per item, in input order. Never rejects.
|
|
315
|
-
*/
|
|
316
|
-
async function scheduleScopes(items, edges, width, launch) {
|
|
317
|
-
let free = Math.max(1, Number(width) || 1);
|
|
318
|
-
const waiting = [];
|
|
319
|
-
const acquire = () => (free > 0 ? (free--, Promise.resolve()) : new Promise((r) => waiting.push(r)));
|
|
320
|
-
const release = () => { const next = waiting.shift(); if (next) next(); else free++; };
|
|
321
|
-
|
|
322
|
-
// TWO COLLECTIONS, and the split is the difference between "one entry per item" and "one entry per
|
|
323
|
-
// id". `runs` is POSITIONAL, so two scopes that somehow carry the same id are two legs and two
|
|
324
|
-
// records; keying the settle by id instead would silently run one of them and report it twice,
|
|
325
|
-
// which is a dropped scope wearing the right name. `started` is by id because that is how an edge
|
|
326
|
-
// names its dependency, and it keeps the first promise for an id — a duplicate cannot make a
|
|
327
|
-
// dependant wait on the wrong one.
|
|
328
|
-
const runs = [];
|
|
329
|
-
const started = new Map();
|
|
330
|
-
|
|
331
|
-
async function runOne(item) {
|
|
332
|
-
// THE DEPENDENCY WAIT HAPPENS BEFORE THE SLOT ACQUIRE, and that ordering is the whole reason
|
|
333
|
-
// the window cannot deadlock against the edges: a waiting scope occupies no capacity, so the
|
|
334
|
-
// window can never fill with scopes that are all waiting on one another.
|
|
335
|
-
const deps = [...(edges.get(item.scope_id) || [])].map((d) => started.get(d)).filter(Boolean);
|
|
336
|
-
if (deps.length) await Promise.all(deps);
|
|
337
|
-
await acquire();
|
|
338
|
-
try {
|
|
339
|
-
const res = await launch(item);
|
|
340
|
-
// The runtime drops an item whose stage returned no value and leaves an empty slot in the
|
|
341
|
-
// settled list. Name that by itself: an empty slot and a dead worker are different repairs.
|
|
342
|
-
return res || { scope_id: item.scope_id, __failed: "the runtime dropped this leg — a pipeline stage returned no value" };
|
|
343
|
-
} catch (e) {
|
|
344
|
-
// A DEAD BUILDER IS A SPENT ATTEMPT, NOT A DEAD RUN. Rethrowing would reject every other
|
|
345
|
-
// scope's promise through the settle below and discard a whole round of green work.
|
|
346
|
-
return { scope_id: item.scope_id, __failed: `${item.scope_id}: ${(e && e.message) || String(e)}` };
|
|
347
|
-
} finally {
|
|
348
|
-
// In `finally`, so a leg that dies before returning does not narrow the window permanently.
|
|
349
|
-
release();
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// TWO PHASES, because one phase is a silently dropped edge. A body reads its dependencies' promises
|
|
354
|
-
// out of `started` on its first line; built in a single pass, a dependency declared LATER in the
|
|
355
|
-
// list is not there yet, its edge is discarded as unknown, and the scope that edge was protecting
|
|
356
|
-
// starts early — the exact failure the edge exists to prevent, with no diagnostic anywhere.
|
|
357
|
-
const begin = [];
|
|
358
|
-
for (const item of items) {
|
|
359
|
-
let open;
|
|
360
|
-
const gate = new Promise((r) => { open = r; });
|
|
361
|
-
const leg = gate.then(() => runOne(item));
|
|
362
|
-
runs.push(leg);
|
|
363
|
-
if (!started.has(item.scope_id)) started.set(item.scope_id, leg);
|
|
364
|
-
begin.push(open);
|
|
365
|
-
}
|
|
366
|
-
for (const open of begin) open();
|
|
367
|
-
|
|
368
|
-
// ONE ENTRY PER ITEM, IN INPUT ORDER, structurally — no edge, no failure, no dial setting and no
|
|
369
|
-
// repeated id can drop a scope out of the round's census.
|
|
370
|
-
return Promise.all(runs);
|
|
371
|
-
}
|
|
372
|
-
// ---- SCHEDULER REGION END --------------------------------------------------------------------
|
|
373
|
-
|
|
374
|
-
// ---------------------------------------------------------------------------------------------
|
|
375
|
-
// SCHEMAS — the only contract between this control script and a sub-agent. The runtime forces the
|
|
376
|
-
// agent's final message to validate against these, which is the entire reason no stdout is parsed
|
|
377
|
-
// anywhere in this file.
|
|
378
|
-
//
|
|
379
|
-
// A SCHEMA HERE IS ONE HALF OF A CONTRACT, and the other half is a kernel subcommand's stdout. The
|
|
380
|
-
// runtime validates the agent's report against the shape declared BELOW, never against the shape the
|
|
381
|
-
// kernel actually prints, so the two can disagree indefinitely: the courier sub-agent sits between
|
|
382
|
-
// them and quietly coerces whatever it was handed into whatever it was asked for. That is not a
|
|
383
|
-
// hypothetical — `scope_files` was declared here as an array of strings against a kernel that emits
|
|
384
|
-
// `{scope_id, path}` objects, and it "worked" for the life of the file. The region markers are the
|
|
385
|
-
// seam the structural suite reads these declarations through, so the two halves can be compared by
|
|
386
|
-
// something other than a reader's eye.
|
|
387
|
-
// ---- SCHEMA REGION START -----------------------------------------------------------------------
|
|
388
|
-
const nullable = (t) => ({ type: [t, "null"] });
|
|
389
|
-
|
|
390
|
-
/** A kernel subcommand a sub-agent ran in its own shell, reported as data. */
|
|
391
|
-
const CMD = {
|
|
392
|
-
type: "object",
|
|
393
|
-
properties: {
|
|
394
|
-
exit_code: { type: "integer" },
|
|
395
|
-
ok: { type: "boolean" },
|
|
396
|
-
detail: { type: "string" },
|
|
397
|
-
// The machine value, copied CHARACTER FOR CHARACTER out of the command's own JSON. Separate
|
|
398
|
-
// from `detail` on purpose: `detail` is prose for a human to read, this is a token the control
|
|
399
|
-
// plane branches on, and collapsing the two is what made every gate comparison silently false.
|
|
400
|
-
decision: { type: "string" },
|
|
401
|
-
// A close's own advisory failure (kernel/probe/resume.mjs's `exportOnClose`) — copied verbatim, the same discipline as `decision`, so "the export failed" is a
|
|
402
|
-
// fact `closeIfTerminal` can act on rather than a line buried inside free-text `detail`.
|
|
403
|
-
export_warning: { type: "string" },
|
|
404
|
-
},
|
|
405
|
-
required: ["exit_code", "ok"],
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
/** `harness probe resume` — the fast-forward derivation, every field artifact-derived. */
|
|
409
|
-
const RESUME = {
|
|
410
|
-
type: "object",
|
|
411
|
-
properties: {
|
|
412
|
-
intake_path: nullable("string"), spec_folder: nullable("string"), orient_dir: nullable("string"),
|
|
413
|
-
breadboard_path: nullable("string"), breadboard_source: nullable("string"),
|
|
414
|
-
project_profile_path: nullable("string"), status: nullable("string"), closed_status: nullable("string"),
|
|
415
|
-
lens: nullable("string"), stack: nullable("string"),
|
|
416
|
-
run_cmd: nullable("string"), app_url: nullable("string"),
|
|
417
|
-
eval_dimensions: { type: "array", items: { type: "string" } },
|
|
418
|
-
has_orient_artifacts: { type: "boolean" },
|
|
419
|
-
has_spec_tree: { type: "boolean" },
|
|
420
|
-
has_board: { type: "boolean" },
|
|
421
|
-
staged_workflow: {
|
|
422
|
-
type: "object",
|
|
423
|
-
properties: {
|
|
424
|
-
checked: { type: "boolean" },
|
|
425
|
-
drift: { type: "array", items: { type: "string" } },
|
|
426
|
-
installed_version: nullable("string"),
|
|
427
|
-
run_version: nullable("string"),
|
|
428
|
-
warning: { type: "string" },
|
|
429
|
-
},
|
|
430
|
-
},
|
|
431
|
-
// The requirements registry — a fact, not a phase. See the COVERAGE block below for why it is
|
|
432
|
-
// guarded on this bare boolean and never asked about through `probe resume --require`.
|
|
433
|
-
has_requirements: { type: "boolean" },
|
|
434
|
-
has_wiring_map: { type: "boolean" },
|
|
435
|
-
has_project_profile: { type: "boolean" },
|
|
436
|
-
// THE SHAPE THE KERNEL WRITES, not a convenient one. `probe resume` emits `{scope_id, path}` per
|
|
437
|
-
// contract and `ResumeState` declares exactly that; this declared an array of strings and the
|
|
438
|
-
// call site below split each entry as a path. Nothing failed and nothing could: the courier
|
|
439
|
-
// sub-agent sits between the kernel's stdout and this schema, so it coerced the objects into
|
|
440
|
-
// whatever it was asked for, on every run, for the life of the file. The failure that shape
|
|
441
|
-
// invites is silent in the other direction — a coercion that DROPS the entries reads as zero
|
|
442
|
-
// scopes, which this file cannot distinguish from a run whose contracts were never written, so
|
|
443
|
-
// MAP SCOPES is re-dispatched over contracts already on disk.
|
|
444
|
-
scope_files: {
|
|
445
|
-
type: "array",
|
|
446
|
-
items: {
|
|
447
|
-
type: "object",
|
|
448
|
-
properties: { scope_id: { type: "string" }, path: { type: "string" } },
|
|
449
|
-
required: ["scope_id", "path"],
|
|
450
|
-
},
|
|
451
|
-
},
|
|
452
|
-
// Additive: the same scopes grouped into dependency waves. Absent or unusable → one wave.
|
|
453
|
-
scope_waves: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
454
|
-
// The same relation as edges — `[dependant, dependency]` pairs. A wave says when a scope is
|
|
455
|
-
// definitely safe to start; an edge says when it BECAME safe. Absent or unusable → the waves'
|
|
456
|
-
// own release points, which is what a wave-stepped fan-out had.
|
|
457
|
-
scope_deps: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
458
|
-
// Pairs that may write the same declared-shared path, so they may not build at the same time.
|
|
459
|
-
// Not an ordering — either may go first. Absent or unusable → no exclusion, today's scheduling.
|
|
460
|
-
scope_exclusions: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
461
|
-
eval_rounds_done: { type: "array", items: { type: "integer" } },
|
|
462
|
-
next_phase: nullable("string"),
|
|
463
|
-
},
|
|
464
|
-
required: ["has_orient_artifacts", "has_spec_tree", "has_wiring_map", "scope_files", "eval_rounds_done"],
|
|
465
|
-
};
|
|
466
|
-
// ---- SCHEMA REGION END -------------------------------------------------------------------------
|
|
467
|
-
|
|
468
|
-
/** `harness reduce graph --subgraph run` — the bounded read model a round opens with. */
|
|
469
|
-
const SUBGRAPH = {
|
|
470
|
-
type: "object",
|
|
471
|
-
properties: {
|
|
472
|
-
run: nullable("string"),
|
|
473
|
-
scopes: { type: "array", items: { type: "string" } },
|
|
474
|
-
requirements: { type: "array", items: { type: "string" } },
|
|
475
|
-
orders: { type: "integer" },
|
|
476
|
-
pending_orders: { type: "array", items: { type: "string" } },
|
|
477
|
-
rounds_with_green: { type: "array", items: { type: "integer" } },
|
|
478
|
-
green_scopes_by_round: { type: "object" },
|
|
479
|
-
trials: { type: "integer" },
|
|
480
|
-
edges: { type: "integer" },
|
|
481
|
-
},
|
|
482
|
-
required: ["scopes", "rounds_with_green", "green_scopes_by_round"],
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
/** `harness probe t0` — has this scope already gone green in this round? */
|
|
486
|
-
const T0CHECK = {
|
|
487
|
-
type: "object",
|
|
488
|
-
properties: { green: { type: "boolean" }, path: nullable("string") },
|
|
489
|
-
required: ["green"],
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
/** `probe leg --order` — did one named order's result reach the single writer? Any phase. */
|
|
493
|
-
const ORDERLEG = {
|
|
494
|
-
type: "object",
|
|
495
|
-
properties: {
|
|
496
|
-
closed: { type: "boolean" },
|
|
497
|
-
found: { type: "boolean" },
|
|
498
|
-
order: nullable("string"),
|
|
499
|
-
has_receipt: { type: "boolean" },
|
|
500
|
-
has_result: { type: "boolean" },
|
|
501
|
-
applied: { type: "boolean" },
|
|
502
|
-
},
|
|
503
|
-
required: ["closed", "found", "has_result", "applied"],
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
/** `probe attempts` — the attested census: what was spent, and whether the breaker really tripped. */
|
|
507
|
-
const ATTEMPTS = {
|
|
508
|
-
type: "object",
|
|
509
|
-
properties: {
|
|
510
|
-
scope_id: { type: "string" },
|
|
511
|
-
spent: { type: "integer" },
|
|
512
|
-
in_flight: { type: "integer" },
|
|
513
|
-
green: { type: "boolean" },
|
|
514
|
-
tripped: { type: "boolean" },
|
|
515
|
-
},
|
|
516
|
-
required: ["scope_id", "spent", "tripped"],
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
/** `probe leg` — did this scope's result reach the board, or is it finished work nothing applied? */
|
|
520
|
-
const LEGCHECK = {
|
|
521
|
-
type: "object",
|
|
522
|
-
properties: {
|
|
523
|
-
closed: { type: "boolean" },
|
|
524
|
-
orders_total: { type: "integer" },
|
|
525
|
-
results_total: { type: "integer" },
|
|
526
|
-
applied_total: { type: "integer" },
|
|
527
|
-
unapplied: { type: "array", items: { type: "string" } },
|
|
528
|
-
},
|
|
529
|
-
required: ["closed"],
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const ORIENT = {
|
|
533
|
-
type: "object",
|
|
534
|
-
properties: {
|
|
535
|
-
ok: { type: "boolean" }, artifact_written: { type: "boolean" },
|
|
536
|
-
spiked_area: { type: "string" }, spike_result: { type: "string" },
|
|
537
|
-
riskiest_unknowns: { type: "array", items: { type: "string" } },
|
|
538
|
-
},
|
|
539
|
-
required: ["ok", "artifact_written", "spiked_area", "spike_result"],
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
/** coverage / analyze / wire — "did the artifact land?" is all a gate needs from them. */
|
|
543
|
-
const PHASE_OK = {
|
|
544
|
-
type: "object",
|
|
545
|
-
properties: { ok: { type: "boolean" }, artifact_written: { type: "boolean" }, detail: { type: "string" } },
|
|
546
|
-
required: ["ok", "artifact_written"],
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
const MAPSCOPES = {
|
|
550
|
-
type: "object",
|
|
551
|
-
properties: {
|
|
552
|
-
ok: { type: "boolean" }, artifact_written: { type: "boolean" },
|
|
553
|
-
scopes: {
|
|
554
|
-
type: "array",
|
|
555
|
-
items: {
|
|
556
|
-
type: "object",
|
|
557
|
-
properties: { scope_id: { type: "string" }, path: { type: "string" } },
|
|
558
|
-
required: ["scope_id", "path"],
|
|
559
|
-
},
|
|
560
|
-
},
|
|
561
|
-
},
|
|
562
|
-
required: ["ok", "artifact_written", "scopes"],
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
/** One scope's whole attempt ratchet, run inside the worker leg (see buildScope). */
|
|
566
|
-
const SCOPE_RESULT = {
|
|
567
|
-
type: "object",
|
|
568
|
-
properties: {
|
|
569
|
-
scope_id: { type: "string" },
|
|
570
|
-
green: { type: "boolean" },
|
|
571
|
-
t0_artifact: nullable("string"),
|
|
572
|
-
attempts_used: { type: "integer" },
|
|
573
|
-
breaker: { type: "string", enum: ["none", "stagnation", "attempt_budget"] },
|
|
574
|
-
reason: { type: "string" },
|
|
575
|
-
},
|
|
576
|
-
required: ["scope_id", "green", "attempts_used", "breaker"],
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
const EVAL = {
|
|
580
|
-
type: "object",
|
|
581
|
-
properties: {
|
|
582
|
-
ok: { type: "boolean" },
|
|
583
|
-
overall: { type: "string", enum: ["PASS", "FAIL"] },
|
|
584
|
-
findings: {
|
|
585
|
-
type: "array",
|
|
586
|
-
items: {
|
|
587
|
-
type: "object",
|
|
588
|
-
properties: { id: { type: "string" }, criterion: { type: "string" }, evidence: { type: "string" } },
|
|
589
|
-
required: ["id", "criterion", "evidence"],
|
|
590
|
-
},
|
|
591
|
-
},
|
|
592
|
-
},
|
|
593
|
-
required: ["ok", "overall"],
|
|
594
|
-
};
|
|
595
|
-
|
|
596
|
-
// The round-loop branch reads THIS, not `EVAL` above, for the pass/fail decision. `EVAL` shapes
|
|
597
|
-
// the dispatching agent's own end-of-turn summary of a 3-step dispatch (compile, dispatch,
|
|
598
|
-
// ingest) — schema-checked for SHAPE only, never re-verified against the WorkResult `reduce
|
|
599
|
-
// ingest` just wrote. Measured live (2026-08-19, todo-cli): that summary reported the round as
|
|
600
|
-
// passing while `results/evaluate-r1.json`'s own `verdict.overall` said `"FAIL"` (2 cited bugs),
|
|
601
|
-
// and the round loop broke straight to QA/GATE H on the strength of the summary alone — the same
|
|
602
|
-
// self-report-instead-of-artifact-read shape this file already fixed once for gate DECISIONS
|
|
603
|
-
// (see the comment above `crossGate`), just never extended to the EVAL verdict itself. `probe
|
|
604
|
-
// eval` is `harness probe eval`'s own output — a narrow, single-purpose "transcribe this JSON
|
|
605
|
-
// verbatim" query over the WorkResult on disk, the same pattern `probe t0`/`probe resume` already
|
|
606
|
-
// use everywhere else a fact has to cross the no-filesystem boundary.
|
|
607
|
-
const EVAL_VERDICT = {
|
|
608
|
-
type: "object",
|
|
609
|
-
properties: {
|
|
610
|
-
ok: { type: "boolean" },
|
|
611
|
-
overall: nullable("string"),
|
|
612
|
-
bug_count: nullable("integer"),
|
|
613
|
-
report_path: nullable("string"),
|
|
614
|
-
round: { type: "integer" },
|
|
615
|
-
// The dimensions round N's evaluate order named — what "PASS" actually covers.
|
|
616
|
-
dimensions: { type: ["array", "null"], items: { type: "string" } },
|
|
617
|
-
// Why the round holds no verdict it may act on — the evaluator's own first deviation when it
|
|
618
|
-
// refused, or what is structurally wrong with the verdict it returned. Null when `ok`.
|
|
619
|
-
status: nullable("string"),
|
|
620
|
-
reason: nullable("string"),
|
|
621
|
-
},
|
|
622
|
-
required: ["ok", "round"],
|
|
623
|
-
};
|
|
624
|
-
|
|
625
|
-
const REFUTATION = {
|
|
626
|
-
type: "object",
|
|
627
|
-
properties: { id: { type: "string" }, refuted: { type: "boolean" }, why: { type: "string" } },
|
|
628
|
-
required: ["id", "refuted"],
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
const QA_REPORT = {
|
|
632
|
-
type: "object",
|
|
633
|
-
properties: { ok: { type: "boolean" }, findings_count: { type: "integer" } },
|
|
634
|
-
required: ["ok", "findings_count"],
|
|
635
|
-
};
|
|
636
|
-
|
|
637
|
-
const BOARD_CHECK = {
|
|
638
|
-
type: "object",
|
|
639
|
-
properties: { ok: { type: "boolean" }, reason: { type: ["string", "null"] } },
|
|
640
|
-
required: ["ok"],
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
const HUNT_OUTCOME = {
|
|
644
|
-
type: "object",
|
|
645
|
-
properties: {
|
|
646
|
-
ok: { type: "boolean" }, status: { type: ["string", "null"] },
|
|
647
|
-
qa: { type: "string", enum: ["run", "not-hunted", "missing"] },
|
|
648
|
-
charters_run: { type: ["integer", "null"] }, findings: { type: "integer" }, reason: { type: ["string", "null"] },
|
|
649
|
-
},
|
|
650
|
-
required: ["ok", "qa"],
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
const HAMMER = {
|
|
654
|
-
type: "object",
|
|
655
|
-
properties: {
|
|
656
|
-
ok: { type: "boolean" },
|
|
657
|
-
verdict: { type: "string", enum: ["ship-now", "ship-after-fixes", "cannot-ship"] },
|
|
658
|
-
cut_list: { type: "array", items: { type: "string" } },
|
|
659
|
-
},
|
|
660
|
-
required: ["ok", "verdict", "cut_list"],
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
/** What every hammer dispatch is told, on the PASS path and the breaker path alike. */
|
|
664
|
-
const HAMMER_EXTRA =
|
|
665
|
-
"Run the census, compare against the BASELINE and never the ideal, and produce the cut list. " +
|
|
666
|
-
"Write the census as data to `reports/hammer-census.json` under the run's local root — the " +
|
|
667
|
-
"`reports/**` entry in your order's substrate names the directory — with schema_version 1, " +
|
|
668
|
-
"order_id, verdict (ship-now | ship-after-fixes | cannot-ship), cut_list, ship_blocking, breaker, " +
|
|
669
|
-
"baseline — beside the human-readable report. GATE L4's resolver reads that file and nothing " +
|
|
670
|
-
"else before it lets any answer set say `ship`; a census that lives only in your printed blocks " +
|
|
671
|
-
"cannot be read by a gate.";
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* A breaker routed the run to GATE H. This used to be a terminal return: the loop handed back
|
|
675
|
-
* `{status: "gate_h"}`, the close-out stamped the ledger `escalated` and retired the pointers, and
|
|
676
|
-
* the census, GATE H, the ship report and GATE L4 all happened AFTER the close, in the tech
|
|
677
|
-
* lead's prose — so the census reached no artifact, `gates.jsonl` held no H and no L4, and a later
|
|
678
|
-
* `--close shipped` was refused over the `escalated` fact already on the ledger. Measured on three
|
|
679
|
-
* consumer runs. AGENTS.md has always said what a breaker means: "ship what's green, never kill the
|
|
680
|
-
* run from outside". So the run does that itself, in this launch: the hammer census (an artifact),
|
|
681
|
-
* GATE H (a row), the ship report (with the verdict as it is, FAIL or not-evaluated included), and
|
|
682
|
-
* GATE L4 (a row) — and only then a close, `shipped` or `escalated`, that names the census.
|
|
683
|
-
*
|
|
684
|
-
* QA never ran on this path (it sits after a PASS), so the census is told so plainly. `qaFindings`
|
|
685
|
-
* is declared after the round loop and must not be read from here.
|
|
686
|
-
*
|
|
687
|
-
* @param {object} ret - The gate_h return the round loop built.
|
|
688
|
-
* @returns {Promise<object>} The RunReturn that actually ends the run.
|
|
689
|
-
*/
|
|
690
|
-
async function settleAtGateH(ret) {
|
|
691
|
-
phase("Ship");
|
|
692
|
-
const h = await worker({
|
|
693
|
-
skill: "scope-hammer", operation: "hammer", schema: HAMMER, phase: "Ship", label: "hammer",
|
|
694
|
-
payload: { feature: slug, qa_findings: 0, hammer_proposals: ret.hammer_proposals || [], breaker: ret.breaker ?? null },
|
|
695
|
-
extra: HAMMER_EXTRA,
|
|
696
|
-
});
|
|
697
|
-
if (h.__failed) return await withWarnings(diedAt("H", h));
|
|
698
|
-
{
|
|
699
|
-
const g = await crossGate("H", "Ship", ["accept-cut-list", "ship-all", "ask"],
|
|
700
|
-
{ verdict: h.verdict, cut_list: h.cut_list, breaker: ret.breaker ?? null, green_scopes: ret.green_scopes, hammer_proposals: ret.hammer_proposals });
|
|
701
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
702
|
-
}
|
|
703
|
-
if (h.verdict === "cannot-ship") {
|
|
704
|
-
return await withWarnings({ ...ret, census: "cannot-ship", cut_list: h.cut_list });
|
|
705
|
-
}
|
|
706
|
-
const hVerdict = verdict === "pass" ? "PASS" : verdict === "fail" ? "FAIL" : "not-evaluated";
|
|
707
|
-
const ship = await cmd(`reduce ship --slug ${slug} --verdict ${hVerdict} --qa skipped`, "Ship", "ship-report");
|
|
708
|
-
if (!ship.ok) {
|
|
709
|
-
return await withWarnings({ ...ret, census: h.verdict, cut_list: h.cut_list, ship_report: `refused: ${ship.detail || `exit ${ship.exit_code}`}` });
|
|
710
|
-
}
|
|
711
|
-
{
|
|
712
|
-
const g = await crossGate("L4", "Ship", ["ship", "hold", "ask"], { verdict: hVerdict, census: h.verdict, cut_list: h.cut_list, breaker: ret.breaker ?? null });
|
|
713
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
714
|
-
if (g.decision === "hold") return await withWarnings({ ...ret, census: h.verdict, cut_list: h.cut_list, l4: "hold" });
|
|
715
|
-
}
|
|
716
|
-
await advisory(`report export --slug ${slug}`, "Ship", "export-run");
|
|
717
|
-
await setRunStatus("shipped", "Ship");
|
|
718
|
-
return await withWarnings({
|
|
719
|
-
status: "shipped", verdict, rounds_used: round, after: "gate_h", breaker: ret.breaker ?? null,
|
|
720
|
-
census: h.verdict, cut_list: h.cut_list, green_scopes: ret.green_scopes,
|
|
721
|
-
unapplied_results: ret.unapplied_results || [], qa_findings: 0,
|
|
722
|
-
report: REPORT_PATH,
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// ---------------------------------------------------------------------------------------------
|
|
727
|
-
// DISPATCH — three shapes, and none of them parses text.
|
|
728
|
-
//
|
|
729
|
-
// A sub-agent can be skipped by the operator or die on a terminal API error, in which case the
|
|
730
|
-
// runtime hands back `null`. A null is a NAMED failure here, never a crash: `status: "failed"` is
|
|
731
|
-
// not a member of the RunReturn union, so every call site converts it to one that is.
|
|
732
|
-
// ---------------------------------------------------------------------------------------------
|
|
733
|
-
const nullFail = (label) => ({ __failed: `${label}: sub-agent skipped, blocked, or died after retries` });
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Run one kernel subcommand in a sub-agent's own shell and get its outcome as data.
|
|
737
|
-
*
|
|
738
|
-
* @param {string} verbs - Kernel verb words plus flags, e.g. `reduce hill --slug x`.
|
|
739
|
-
* @param {string} phaseName - Progress group.
|
|
740
|
-
* @param {string} label - Display label.
|
|
741
|
-
* @returns {Promise<{exit_code:number, ok:boolean, detail?:string}>} Never rejects.
|
|
742
|
-
*/
|
|
743
|
-
async function cmd(verbs, phaseName, label) {
|
|
744
|
-
const r = await agent(
|
|
745
|
-
`Run exactly this command and nothing else:\n\n node "${KERNEL}" ${verbs}\n\n` +
|
|
746
|
-
`Report its exit code as exit_code, ok=true if and only if exit_code is 0, and one line of ` +
|
|
747
|
-
`detail. If the command printed JSON carrying a top-level "decision" key, copy that value into ` +
|
|
748
|
-
`decision EXACTLY as it appears — one bare token, no sentence, no quotes, no rephrasing. ` +
|
|
749
|
-
`Otherwise omit decision. If the command printed JSON carrying a top-level "export_warning" ` +
|
|
750
|
-
`key with a non-empty string value, copy that string into export_warning verbatim. Otherwise ` +
|
|
751
|
-
`omit export_warning. Do not interpret, summarise or act on the command's output beyond that.\n\n` +
|
|
752
|
-
`If the tool call itself is refused or blocked before the command ever runs — a permission or ` +
|
|
753
|
-
`policy denial, not the command's own exit — that is NOT an exit code, and you must never invent ` +
|
|
754
|
-
`one to fill the field: report exit_code as -1 and put the denial's own wording verbatim in ` +
|
|
755
|
-
`detail. -1 is not a real process exit and this file treats it as "the check could not run", never ` +
|
|
756
|
-
`as evidence about what the command would have said.`,
|
|
757
|
-
{ model: "sonnet", effort: "low", phase: phaseName, label, schema: CMD },
|
|
758
|
-
);
|
|
759
|
-
return (r && typeof r === "object") ? r : { exit_code: -1, ok: false, detail: `${label}: no result` };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* Run a kernel subcommand whose outcome is advisory — a projection or a lint that informs but does
|
|
764
|
-
* not stop the run.
|
|
765
|
-
*
|
|
766
|
-
* It still LOOKS at the exit code. A command whose result nobody reads is a command whose failure
|
|
767
|
-
* is indistinguishable from its success, which is the defect class this whole file is arranged
|
|
768
|
-
* against; "advisory" means the run continues, not that nothing is recorded.
|
|
769
|
-
*
|
|
770
|
-
* @param {string} verbs - Kernel verb words plus flags.
|
|
771
|
-
* @param {string} phaseName - Progress group.
|
|
772
|
-
* @param {string} label - Display label.
|
|
773
|
-
* @returns {Promise<void>} Settles when the command has run.
|
|
774
|
-
*/
|
|
775
|
-
async function advisory(verbs, phaseName, label) {
|
|
776
|
-
const r = await cmd(verbs, phaseName, label);
|
|
777
|
-
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.`);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/**
|
|
781
|
-
* Run a kernel subcommand whose stdout is a JSON document this script needs the fields of.
|
|
782
|
-
*
|
|
783
|
-
* The sub-agent re-reports the document against `schema`, so what reaches this file is validated by
|
|
784
|
-
* the runtime rather than parsed here.
|
|
785
|
-
*
|
|
786
|
-
* @param {string} verbs - Kernel verb words plus flags.
|
|
787
|
-
* @param {object} schema - The shape the caller branches on.
|
|
788
|
-
* @param {string} phaseName - Progress group.
|
|
789
|
-
* @param {string} label - Display label.
|
|
790
|
-
* @returns {Promise<(object|null)>} The validated document, or null when the agent produced none.
|
|
791
|
-
*/
|
|
792
|
-
async function query(verbs, schema, phaseName, label) {
|
|
793
|
-
const r = await agent(
|
|
794
|
-
`Run exactly this command and nothing else:\n\n node "${KERNEL}" ${verbs}\n\n` +
|
|
795
|
-
`It prints one JSON document on stdout. Return that document's fields as the schema names ` +
|
|
796
|
-
`them, verbatim — do not add, rename, summarise or infer any value.`,
|
|
797
|
-
{ model: "sonnet", effort: "low", phase: phaseName, label, schema },
|
|
798
|
-
);
|
|
799
|
-
return (r && typeof r === "object") ? r : null;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// A payload field that is not known yet is ABSENT, never `null`.
|
|
803
|
-
//
|
|
804
|
-
// The two contracts either side of this line disagree about how to say "unknown", and the
|
|
805
|
-
// disagreement is load-bearing: `probe resume` declares `stack`, `lens`, `run_cmd` and `app_url`
|
|
806
|
-
// nullable — null is its legitimate answer on a fresh run — while `WorkOrderPayload` types them
|
|
807
|
-
// `string` and marks every one of them OPTIONAL. So the contract already models unknown as an
|
|
808
|
-
// absent key, and forwarding the probe's `null` into it produces an order that fails its own schema
|
|
809
|
-
// before a worker ever sees it. Four of the seven operations this file dispatches — orient, analyze,
|
|
810
|
-
// evaluate, hunt — carry such a field, ORIENT among them, so the first dispatch of every fresh run
|
|
811
|
-
// was refused at compile. The three that carry none (wire, map-scopes, hammer) always compiled,
|
|
812
|
-
// which is why the fault looked intermittent rather than total.
|
|
813
|
-
//
|
|
814
|
-
// Dropping the key is the correct direction. Widening the schema to accept null would make
|
|
815
|
-
// `"stack": null` a valid order and push the null downstream into every worker's prompt, where each
|
|
816
|
-
// one would have to re-decide what a null stack means.
|
|
817
|
-
const compact = (payload) =>
|
|
818
|
-
Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== null && v !== undefined));
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* Dispatch one worker through the envelope port and get back the fields a gate needs.
|
|
822
|
-
*
|
|
823
|
-
* The sub-agent compiles its own WorkOrder with `harness compile`, calls the Skill against it, and
|
|
824
|
-
* applies its WorkResult with `harness reduce ingest` — all in its own shell. This script never
|
|
825
|
-
* couriers those commands, and the `verify envelope` hook still denies a dispatch whose order is
|
|
826
|
-
* missing or schema-invalid, because that hook fires on the Skill call the sub-agent makes.
|
|
827
|
-
*
|
|
828
|
-
* @param {object} spec - `{skill, operation, payload, schema, phase, label, model, extra}`.
|
|
829
|
-
* @returns {Promise<object>} The validated worker report, or a `__failed` marker.
|
|
830
|
-
*/
|
|
831
|
-
async function worker({ skill, operation, payload, schema, phase: phaseName, label, model = execModel, extra = "", compile, round }) {
|
|
832
|
-
// The compile line is overridable because not every dispatch can be addressed the same way.
|
|
833
|
-
// `--operation <op> --slug <slug>` resolves its worker from `compile`'s OP_OWNER table, which
|
|
834
|
-
// covers the planner/judge/QA operations and NOT `execute`: a build order is addressed by scope,
|
|
835
|
-
// round and attempt, so the generic form exits 2 ("could not resolve --worker/--operation") for
|
|
836
|
-
// exactly the leg that runs most often. It used to be emitted for build legs anyway, contradicted
|
|
837
|
-
// one line later by `extra`'s correct `--scope …` instruction — a prompt arguing with itself.
|
|
838
|
-
// `--round` when the dispatch belongs to one. `compile` already suffixes the order id with it
|
|
839
|
-
// (`evaluate-r2`), and `probe resume` already reads `evaluate-r<N>.json` back to learn which EVAL
|
|
840
|
-
// rounds are done — writer and reader agreed all along and only this caller omitted the flag. The
|
|
841
|
-
// cost of that omission is two facts, both silent: every relaunch restarted the round counter at
|
|
842
|
-
// 1, because `eval_rounds_done` could never match `evaluate.json`; and round 2's evaluate result
|
|
843
|
-
// OVERWROTE round 1's, so a run kept only its last round's envelope.
|
|
844
|
-
const roundFlag = round ? ` --round ${round}` : "";
|
|
845
|
-
// A CALLER-SUPPLIED PAYLOAD ONLY REACHES THE ORDER ON THE GENERIC LINE. Overriding `compile`
|
|
846
|
-
// replaces the whole command, `--payload` included, so a caller that passes both is writing a
|
|
847
|
-
// field nobody will ever read — which is exactly how `payload.bugs` came to be built, filtered,
|
|
848
|
-
// and discarded on every fix round for the life of this file. Loud rather than fatal: the order
|
|
849
|
-
// is still valid, and killing a build leg over a discarded field would cost more than it saves.
|
|
850
|
-
if (compile && payload && Object.keys(compact(payload)).length) {
|
|
851
|
-
log(`DISPATCH — ${label}: payload dropped (${Object.keys(compact(payload)).join(", ")}). This ` +
|
|
852
|
-
`dispatch overrides its compile line, so --payload is not emitted; the order must derive ` +
|
|
853
|
-
`those fields itself (see harness compile).`);
|
|
854
|
-
}
|
|
855
|
-
const compileCmd = compile
|
|
856
|
-
|| `compile --operation ${operation} --slug ${slug}${roundFlag} --payload '${JSON.stringify(compact(payload))}'`;
|
|
857
|
-
const r = await agent(
|
|
858
|
-
`You are running one step of an orchestrated build over feature slug "${slug}".\n\n` +
|
|
859
|
-
`1. Compile the WorkOrder:\n` +
|
|
860
|
-
` node "${KERNEL}" ${compileCmd}\n` +
|
|
861
|
-
` It prints the order path on stdout.\n` +
|
|
862
|
-
`2. Dispatch the worker against that order:\n` +
|
|
863
|
-
` Skill(shapeup-sdlc-plugin:${skill}) --order "<the path from step 1>"\n` +
|
|
864
|
-
` If the Skill dispatch fails or returns an error, STOP and report the error. Never work ` +
|
|
865
|
-
`around a failed dispatch by doing the craft yourself — the run has a receipt gate and an ` +
|
|
866
|
-
`improvised result is refused at step 3 anyway.\n` +
|
|
867
|
-
` ${extra}\n` +
|
|
868
|
-
`3. Apply its WorkResult, naming the ORDER from step 1 — not any path the worker reports:\n` +
|
|
869
|
-
` node "${KERNEL}" reduce ingest --order <the path from step 1>\n\n` +
|
|
870
|
-
`Then report ONLY the fields the schema names. Nothing else crosses this boundary — no ` +
|
|
871
|
-
`narration, no file contents, no summary of the work.`,
|
|
872
|
-
{ model, phase: phaseName, label, schema, effort: "medium" },
|
|
873
|
-
);
|
|
874
|
-
return (r && typeof r === "object") ? r : nullFail(label);
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Dispatch a worker, check its result mechanically, and send it back ONCE with the refusal.
|
|
879
|
-
*
|
|
880
|
-
* A worker's result can be well-formed and still not what the run asked for — a verdict that grades
|
|
881
|
-
* the surface as a group, a hunt that reached the app and ran no charter. The kernel says so; this
|
|
882
|
-
* turns that into one more dispatch carrying the kernel's own reason, and only a second refusal
|
|
883
|
-
* stands. Measured: a rule in the skill made the judge grade row by row on its first dispatch, and
|
|
884
|
-
* the refusal is what holds when the model takes the loose path anyway.
|
|
885
|
-
*
|
|
886
|
-
* @param {function(string): Promise<object>} dispatch - Dispatches the worker; takes a note to append.
|
|
887
|
-
* @param {function(string): Promise<(object|null)>} check - Runs the kernel's check; takes a label suffix.
|
|
888
|
-
* @param {function(object): boolean} refused - Whether a check's answer is a correctable refusal.
|
|
889
|
-
* @param {string} what - What is being checked, for the log line.
|
|
890
|
-
* @returns {Promise<{r: object, v: (object|null)}>} The last dispatch and the last check.
|
|
891
|
-
*/
|
|
892
|
-
async function sendBackOnce(dispatch, check, refused, what) {
|
|
893
|
-
let r = await dispatch("");
|
|
894
|
-
if (r.__failed) return { r, v: null };
|
|
895
|
-
let v = await check("");
|
|
896
|
-
if (v && refused(v)) {
|
|
897
|
-
log(`${what} — refused (${v.reason}); sent back once`);
|
|
898
|
-
r = await dispatch(` Your previous result was refused: ${v.reason}. Correct that and return again.`);
|
|
899
|
-
if (r.__failed) return { r, v: null };
|
|
900
|
-
v = await check(":again");
|
|
901
|
-
}
|
|
902
|
-
return { r, v };
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* Dispatch a board writer (analyze, or the board-only regeneration) and send it back once when the
|
|
907
|
-
* board carries no `covers:` clause while a requirements registry exists.
|
|
908
|
-
*
|
|
909
|
-
* Advisory past the second dispatch: the requirements matrix never blocks a ship, so a board that
|
|
910
|
-
* still carries none continues with a state warning naming what the matrix will read, rather than
|
|
911
|
-
* aborting a run whose plan is otherwise sound.
|
|
912
|
-
*
|
|
913
|
-
* @param {function(string): Promise<object>} dispatch - The board writer's dispatch; takes a note.
|
|
914
|
-
* @param {string} what - The operation, for the log and the warning.
|
|
915
|
-
* @returns {Promise<{r: object, v: (object|null)}>} The last dispatch and the last check.
|
|
916
|
-
*/
|
|
917
|
-
async function boardSentBack(dispatch, what) {
|
|
918
|
-
const out = await sendBackOnce(dispatch,
|
|
919
|
-
(suffix) => query(`probe requirements --slug ${slug} --board-check`, BOARD_CHECK, "Analyze", `board-covers${suffix}`),
|
|
920
|
-
(v) => !v.ok && !!v.reason, `ANALYZE ${what}`);
|
|
921
|
-
if (!out.r.__failed && out.v && !out.v.ok && out.v.reason) {
|
|
922
|
-
const msg = `ANALYZE ${what}: ${out.v.reason} (after one send-back; the requirements matrix will read no evidence)`;
|
|
923
|
-
log(msg);
|
|
924
|
-
stateWarnings.push(msg);
|
|
925
|
-
}
|
|
926
|
-
return out;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// ---------------------------------------------------------------------------------------------
|
|
930
|
-
// GATES — the kernel's exit-code convention, unchanged: 0 cross · 4 pause · 5 abort. The resolved
|
|
931
|
-
// decision travels in `decision`, copied verbatim out of the kernel's own JSON.
|
|
932
|
-
//
|
|
933
|
-
// IT USED TO BE READ OUT OF `detail`, which is free prose a sub-agent writes. What came back was a
|
|
934
|
-
// sentence — "Command exited 0; gate QA resolved decision=run from …" — and every comparison
|
|
935
|
-
// downstream is against a token, so `decision === "run"` and `decision === "stop"` were false on
|
|
936
|
-
// every run that has ever executed. The observable effect was a documented phase that silently
|
|
937
|
-
// never ran and a PO answer at L3 that did nothing, with no error and no artifact to notice the
|
|
938
|
-
// absence by. This file's own header says nothing here reads a model's prose; this was the one
|
|
939
|
-
// place that did, and every gate decision passed through it.
|
|
940
|
-
//
|
|
941
|
-
// An unreadable decision now ABORTS rather than defaulting. A default is what made the original
|
|
942
|
-
// defect invisible: "proceed" is a plausible answer at four of these gates, so the run continued
|
|
943
|
-
// and read as healthy. Stopping converts a silent skip into a first-run error.
|
|
944
|
-
// ---------------------------------------------------------------------------------------------
|
|
945
|
-
const PRESETS = new Set(["ci", "guarded", "interactive"]);
|
|
946
|
-
// The non-preset branch is an operator-supplied PATH, so it is quoted: every kernel call in this file
|
|
947
|
-
// is spelled into a sub-agent's prompt, and the PreToolUse envelope gate scans that prompt as well as
|
|
948
|
-
// a `Skill` call's args. A bare path is read together with the trailing quote of the literal around
|
|
949
|
-
// it, and the whole dispatch is denied.
|
|
950
|
-
const answersFlag = (a) => (!a ? "" : PRESETS.has(a) ? `--preset ${a}` : `--file "${a}"`);
|
|
951
|
-
|
|
952
|
-
const TITLES = {
|
|
953
|
-
L1a: "Orient Review", "L1a.5": "Wiring Review", L1b: "Board Review",
|
|
954
|
-
L2: "Build Round Complete", L3: "Verdict & Loop", QA: "QA Edge Hunt", H: "Decide When to Stop",
|
|
955
|
-
};
|
|
956
|
-
const gateBlock = (g, ctx) =>
|
|
957
|
-
[`⏸ GATE ${g} — ${TITLES[g] || g}`, ...Object.entries(ctx).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)].join("\n");
|
|
958
|
-
const paused = (g, valid, ctx) => ({ status: "paused", paused_at: g, block: gateBlock(g, ctx), valid_decisions: valid, context: ctx });
|
|
959
|
-
const aborted = (g, why) => ({ status: "aborted", aborted_at: g, reason: why });
|
|
960
|
-
const diedAt = (g, r) => ({ status: "aborted", aborted_at: g, reason: r.__failed });
|
|
961
|
-
|
|
962
|
-
/**
|
|
963
|
-
* Resolve one gate: cross, pause, or abort.
|
|
964
|
-
*
|
|
965
|
-
* @param {string} gateId - Gate id (L1a, L2, …).
|
|
966
|
-
* @param {string} phaseName - Progress group.
|
|
967
|
-
* @param {string[]} validDecisions - What a human may answer if this pauses.
|
|
968
|
-
* @param {object} ctx - The facts the gate block shows.
|
|
969
|
-
* @returns {Promise<{stop?: object, decision?: string}>} `stop` carries a terminal RunReturn.
|
|
970
|
-
*/
|
|
971
|
-
async function crossGate(gateId, phaseName, validDecisions, ctx) {
|
|
972
|
-
// The gate ledger keys a per-round crossing (L2, L3) on gate id + round, so it needs the round
|
|
973
|
-
// whenever the caller already has one to show in the block — the same value `ctx.round` carries
|
|
974
|
-
// for display, threaded through rather than re-derived.
|
|
975
|
-
const roundFlag = (ctx?.round != null ? ` --round ${ctx.round}` : "") + (ctx?.verdict ? ` --verdict ${ctx.verdict}` : "");
|
|
976
|
-
const g = await cmd(`gate --resolve ${gateId} --slug ${slug}${roundFlag} ${answersFlag(args.answers)}`.trim(), phaseName, `gate:${gateId}`);
|
|
977
|
-
if (g.exit_code === 4) return { stop: paused(gateId, validDecisions, ctx) };
|
|
978
|
-
if (g.exit_code === 5) return { stop: aborted(gateId, g.detail || `GATE ${gateId} aborted`) };
|
|
979
|
-
const decision = String(g.decision ?? "").trim();
|
|
980
|
-
if (!validDecisions.includes(decision)) {
|
|
981
|
-
return {
|
|
982
|
-
stop: aborted(gateId,
|
|
983
|
-
`GATE ${gateId} exited 0 but its decision did not come back as one of ` +
|
|
984
|
-
`${validDecisions.join(" | ")} — got ${JSON.stringify(g.decision ?? null)}. The kernel prints ` +
|
|
985
|
-
`the decision as a top-level JSON key; a run must not guess it. Re-run this gate, or answer ` +
|
|
986
|
-
`it directly with --answers.`),
|
|
987
|
-
};
|
|
988
|
-
}
|
|
989
|
-
return { decision };
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
// Ask the completion oracle about ONE phase, as a leg in that phase's progress group. Declared
|
|
993
|
-
// ahead of both callers rather than between them: a `const` is not hoisted, and this file has paid
|
|
994
|
-
// for a temporal-dead-zone binding once already (see `findings` at the round loop).
|
|
995
|
-
const attest = (phaseKey, phaseName, label) =>
|
|
996
|
-
cmd(`probe resume --slug ${slug} --require ${phaseKey}`, phaseName, label);
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* The other half of a phase post-condition: the single writer ran.
|
|
1000
|
-
*
|
|
1001
|
-
* The artifact check asks whether the worker wrote its product. It cannot ask whether the
|
|
1002
|
-
* WorkResult was applied — the leg row, the discoveries, the board — because the product is written
|
|
1003
|
-
* by the worker directly and the envelope is applied by `reduce ingest`, a separate act the leg's
|
|
1004
|
-
* script names as its last step. Measured on a live run: a planning phase landed a result naming
|
|
1005
|
-
* seventeen artifacts and no leg row, the artifact check passed, and the run walked on with the
|
|
1006
|
-
* result's discoveries never reaching the ledger. So this asks the leg ledger by order name. A
|
|
1007
|
-
* result on disk that nothing applied is ingested here — the same repair the build round makes,
|
|
1008
|
-
* for the same reason: the single writer is the invariant, not which step invokes it — and if it
|
|
1009
|
-
* still is not applied after that, the run stops with a cause naming the writer that did not run,
|
|
1010
|
-
* rather than folding the gap into a phase that "completed".
|
|
1011
|
-
*
|
|
1012
|
-
* @param {string} gate - The gate name to report the abort under.
|
|
1013
|
-
* @param {string} phaseKey - The phase.
|
|
1014
|
-
* @param {string} phaseName - Progress group.
|
|
1015
|
-
* @param {string} [orderStem=phaseKey] - The order's file stem, when the phase was dispatched
|
|
1016
|
-
* under a different operation (a board-only ANALYZE compiles `board.json`).
|
|
1017
|
-
* @returns {Promise<(object|null)>} An aborted RunReturn, or null when the leg closed (or the
|
|
1018
|
-
* question could not be asked — a probe that did not run proves nothing, and is logged as such).
|
|
1019
|
-
*/
|
|
1020
|
-
async function requireLeg(gate, phaseKey, phaseName, orderStem = phaseKey, again = null) {
|
|
1021
|
-
const ask = () => query(`probe leg --slug ${slug} --order "${orderStem}"`, ORDERLEG, phaseName, `legcheck:${orderStem}`);
|
|
1022
|
-
let leg = await ask();
|
|
1023
|
-
if (!leg || !leg.found) { log(`${gate} — could not ask the leg ledger about "${phaseKey}" (probe returned ${leg ? "no order" : "nothing"}); proceeding on the artifact alone.`); return null; }
|
|
1024
|
-
// SENT BACK ONCE FOR THE ENVELOPE. A dispatched leg that wrote its artifacts and no WorkResult has
|
|
1025
|
-
// done the craft and skipped the last step, and it recurs on the same phase run after run. The
|
|
1026
|
-
// worker is dispatched again with the reason, told its artifacts stand; only a second miss is named
|
|
1027
|
-
// at the close.
|
|
1028
|
-
if (!leg.has_result && leg.has_receipt && again) {
|
|
1029
|
-
log(`${gate} — "${phaseKey}" was dispatched and returned no WorkResult; sending it back once for the envelope.`);
|
|
1030
|
-
await again(` Your previous dispatch of this order wrote the phase's artifacts and returned NO WorkResult. ` +
|
|
1031
|
-
`The artifacts are on disk and stand: do not redo them. Write the WorkResult the order names ` +
|
|
1032
|
-
`(its substrate's own results path), then ingest it as the steps above say.`);
|
|
1033
|
-
leg = await ask();
|
|
1034
|
-
if (!leg || !leg.found) return null;
|
|
1035
|
-
}
|
|
1036
|
-
if (!leg.has_result) {
|
|
1037
|
-
// The artifact is on disk and the envelope never came back. Measured live: the first dispatch of
|
|
1038
|
-
// a run wrote its four artifacts and no WorkResult, every later phase read the artifacts, and
|
|
1039
|
-
// the run closed `shipped` over an order still open by construction. The phase stands on its
|
|
1040
|
-
// artifact — that is what the post-condition checks — but the order is named at the close, and
|
|
1041
|
-
// the next run over this slug will find it unanswered rather than be surprised by it.
|
|
1042
|
-
log(`${gate} — "${phaseKey}" ${leg.has_receipt ? "was dispatched (receipt on disk) and" : "has an order but no receipt, and"} never answered: no WorkResult at all. ` +
|
|
1043
|
-
`Its artifacts are on disk and the phase proceeds on them; the order stays unanswered and is named at the close.`);
|
|
1044
|
-
if (leg.order && !unansweredOrders.includes(leg.order)) unansweredOrders.push(leg.order);
|
|
1045
|
-
return null;
|
|
1046
|
-
}
|
|
1047
|
-
if (leg.applied) return null;
|
|
1048
|
-
log(`${gate} — "${phaseKey}" came back with a result nothing applied (no leg row). Ingesting it here: ${leg.order}.`);
|
|
1049
|
-
await advisory(`reduce ingest --order "${leg.order}"`, phaseName, `late-ingest:${phaseKey}`);
|
|
1050
|
-
leg = await ask();
|
|
1051
|
-
if (leg?.applied) return null;
|
|
1052
|
-
return aborted(gate,
|
|
1053
|
-
`${gate}: the single writer did not run for "${phaseKey}" — its WorkResult is on disk and no leg row ` +
|
|
1054
|
-
`records it being applied, and a late \`reduce ingest\` did not take either. The phase's product exists; ` +
|
|
1055
|
-
`what it discovered and reported never reached the ledger or the board. Read the result, run ` +
|
|
1056
|
-
`\`reduce ingest --order "${leg?.order ?? "<its order>"}"\` by hand to see why it refuses, then relaunch.`);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
/**
|
|
1060
|
-
* The phase post-condition: the artifact is on disk, or the run stops here.
|
|
1061
|
-
*
|
|
1062
|
-
* Deliberately the SAME derivation the fast-forward uses, asked about one phase. Two readings of
|
|
1063
|
-
* "is this phase done" that can disagree is the defect class, not a safeguard against it.
|
|
1064
|
-
*
|
|
1065
|
-
* @param {string} gate - The gate name to report the abort under.
|
|
1066
|
-
* @param {string} phaseKey - The phase `probe resume --require` knows.
|
|
1067
|
-
* @param {string} phaseName - Progress group.
|
|
1068
|
-
* @returns {Promise<(object|null)>} An aborted RunReturn, or null when the artifact is there.
|
|
1069
|
-
*/
|
|
1070
|
-
async function requirePhase(gate, phaseKey, phaseName, orderStem = phaseKey, again = null) {
|
|
1071
|
-
const r = await attest(phaseKey, phaseName, `require:${phaseKey}`);
|
|
1072
|
-
if (r.exit_code === 0) return await requireLeg(gate, phaseKey, phaseName, orderStem, again);
|
|
1073
|
-
// Exit 6 is `probe resume --require`'s OWN documented code for "the artifact really is not on
|
|
1074
|
-
// disk" (kernel/probe/resume.mjs banner). Any other value — including -1, the courier's sentinel
|
|
1075
|
-
// for a tool call that never ran — is not that predicate answering "no"; it is the predicate never
|
|
1076
|
-
// being asked. Collapsing both into one message manufactured a false "worker escalated" diagnosis
|
|
1077
|
-
// the one time this was measured live: the artifacts existed, and the check itself had been denied
|
|
1078
|
-
// by a layer above this plugin (workspace trust, or Claude Code's own auto-mode classifier —
|
|
1079
|
-
// neither leaves a row in decisions.jsonl, because neither is this plugin's hook).
|
|
1080
|
-
if (r.exit_code === 6) {
|
|
1081
|
-
return aborted(gate,
|
|
1082
|
-
`${gate} produced no artifact: the ${phaseKey} artifact is not on disk after the phase ran and its ` +
|
|
1083
|
-
`result was ingested. The phase did not complete — its worker most likely escalated (a WorkResult ` +
|
|
1084
|
-
`may report "escalated" with an empty artifacts list) — and because completion is derived from the ` +
|
|
1085
|
-
`artifact, every relaunch would re-dispatch this phase and escalate again. Read the phase's result ` +
|
|
1086
|
-
`to see what it could not complete, resolve it, then relaunch.`);
|
|
1087
|
-
}
|
|
1088
|
-
return aborted(gate,
|
|
1089
|
-
`${gate}'s completion check for "${phaseKey}" did not run to a verdict — exit_code ${r.exit_code}, ` +
|
|
1090
|
-
`not the 0 (satisfied) or 6 (artifact absent) \`probe resume --require\` documents.` +
|
|
1091
|
-
`${r.detail ? ` Courier reported: ${r.detail}.` : ""} This is NOT evidence the phase failed — it means ` +
|
|
1092
|
-
`the check itself could not run, most often a Bash call denied above this plugin's own hooks and ` +
|
|
1093
|
-
`permission grant (an untrusted workspace, or Claude Code's auto-mode classifier). Verify the ` +
|
|
1094
|
-
`${phaseKey} artifact by hand before assuming it is missing, then either relaunch or drive the ` +
|
|
1095
|
-
`remaining phase's kernel commands directly.`);
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* A phase this launch did not run, said where the run is actually watched — and proved.
|
|
1100
|
-
*
|
|
1101
|
-
* A GATE PAUSE IS A RETURN, so the PO's answer is followed by a fresh launch of this file, and the
|
|
1102
|
-
* runtime's progress panel is rebuilt from THAT launch's dispatches: this repo resumes off the
|
|
1103
|
-
* artifacts under `.shapeup/`, deliberately, rather than off the runtime's own `resumeFromRunId`.
|
|
1104
|
-
* A phase that fast-forwards used to dispatch nothing, so its box rendered `0 agents · Not started
|
|
1105
|
-
* yet` — identical to a phase that never ran, on the one screen an operator has to decide whether a
|
|
1106
|
-
* paused run came back correctly. Measured on a relaunch: ORIENT, WIRE and MAP SCOPES looked right
|
|
1107
|
-
* only by accident, because a gate leg lands in each of their progress groups and earns them a tick,
|
|
1108
|
-
* while ANALYZE — reviewed at L1b, in another group — showed as never started with its spec tree
|
|
1109
|
-
* sitting on disk. There is no runtime call for "mark this phase complete"; a leg is the only thing
|
|
1110
|
-
* that puts a phase in the record, so the skip has to cost one.
|
|
1111
|
-
*
|
|
1112
|
-
* WHICH MAKES THE LEG WORTH SPENDING ON THE ONE QUESTION THE SKIP NEVER ASKED. Every fast-forward
|
|
1113
|
-
* decision in this file branches on a SINGLE `probe resume` snapshot taken at the top of the run,
|
|
1114
|
-
* before ORIENT dispatches. Re-asking `--require` at the phase itself is the same oracle the
|
|
1115
|
-
* post-condition uses, one phase over, and it converts "the state probe said so a few legs ago" into
|
|
1116
|
-
* an attestation made where it is acted on.
|
|
1117
|
-
*
|
|
1118
|
-
* @param {string} gate - The gate name to report an abort under.
|
|
1119
|
-
* @param {string} phaseKey - The phase `probe resume --require` knows.
|
|
1120
|
-
* @param {string} phaseName - Progress group.
|
|
1121
|
-
* @param {string} what - What is already on disk, for the narrator line.
|
|
1122
|
-
* @returns {Promise<(object|null)>} An aborted RunReturn, or null when the artifact is really there.
|
|
1123
|
-
*/
|
|
1124
|
-
async function fastForward(gate, phaseKey, phaseName, what) {
|
|
1125
|
-
log(`${gate} — ${what}, fast-forwarding past it`);
|
|
1126
|
-
const r = await attest(phaseKey, phaseName, `ff:${phaseKey}`);
|
|
1127
|
-
if (r.exit_code === 0) return null;
|
|
1128
|
-
// Same distinction as requirePhase: only exit 6 is the predicate genuinely answering "not there".
|
|
1129
|
-
// Anything else means this re-attestation call itself never executed, most likely denied above
|
|
1130
|
-
// this plugin's hooks and permission grant — not a real disagreement between the two probes.
|
|
1131
|
-
if (r.exit_code === 6) {
|
|
1132
|
-
return aborted(gate,
|
|
1133
|
-
`${gate} was fast-forwarded on a state probe that reported its artifact present, and re-asking ` +
|
|
1134
|
-
`\`probe resume --require ${phaseKey}\` at the phase itself says it is not on disk. Two readings ` +
|
|
1135
|
-
`of "is this done" disagree, and the one taken AT the phase is the later of the two — so the run ` +
|
|
1136
|
-
`stops here rather than building on a phase nothing can attest. Check whether the ${phaseKey} ` +
|
|
1137
|
-
`artifact was moved or removed since this run last touched it, then relaunch.`);
|
|
1138
|
-
}
|
|
1139
|
-
return aborted(gate,
|
|
1140
|
-
`${gate}'s re-attestation of "${phaseKey}" did not run to a verdict — exit_code ${r.exit_code}, not ` +
|
|
1141
|
-
`the 0 (satisfied) or 6 (artifact absent) \`probe resume --require\` documents.` +
|
|
1142
|
-
`${r.detail ? ` Courier reported: ${r.detail}.` : ""} The two probes are not actually in disagreement — ` +
|
|
1143
|
-
`this one never executed, most often a Bash call denied above this plugin's own hooks and ` +
|
|
1144
|
-
`permission grant (an untrusted workspace, or Claude Code's auto-mode classifier). Verify the ` +
|
|
1145
|
-
`${phaseKey} artifact by hand before assuming it regressed, then relaunch.`);
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* The positive enforcer for GATE L0.9b's launch record: the RunArgs object this run was
|
|
1150
|
-
* configured with, kernel-written to the run's own local root, is on disk before this file
|
|
1151
|
-
* dispatches anything past Preflight.
|
|
1152
|
-
*
|
|
1153
|
-
* Nothing upstream of this call can be trusted to have written it. `SKILL.md` Step 2 tells the
|
|
1154
|
-
* orchestrating session to run `harness init run-args` right before `Workflow(...)` is invoked, but
|
|
1155
|
-
* that is prose the session reads, not a check anything runs — and a session that launches this
|
|
1156
|
-
* script without having done it left no trace anywhere else: the receipt, `intake.md` and
|
|
1157
|
-
* `harness-run.md` all exist regardless, and `dialFrom()` (`kernel/probe/concurrency.mjs`) answers
|
|
1158
|
-
* every reader with a silent default rather than an error. So the record's existence would depend
|
|
1159
|
-
* entirely on whether a prior step of prose was followed — exactly the shape this file's own header
|
|
1160
|
-
* exists to retire (no rule with no enforcer). `probe concurrency --require-run-args` is a presence
|
|
1161
|
-
* check, not a measurement: it shares `dialFrom()`'s run-root resolution and file path so there is
|
|
1162
|
-
* one definition of where the record lives, not two.
|
|
1163
|
-
*
|
|
1164
|
-
* @returns {Promise<(object|null)>} An aborted RunReturn, or null when the record is there.
|
|
1165
|
-
*/
|
|
1166
|
-
async function requireLaunchRecord() {
|
|
1167
|
-
const r = await cmd(`probe concurrency --slug ${slug} --require-run-args`, "Preflight", "launch-record");
|
|
1168
|
-
if (r.exit_code === 0) return null;
|
|
1169
|
-
// Same distinction requirePhase/fastForward draw: only exit 6 is the predicate genuinely
|
|
1170
|
-
// answering "not there". Anything else means the check itself did not run.
|
|
1171
|
-
if (r.exit_code === 6) {
|
|
1172
|
-
return aborted("preflight",
|
|
1173
|
-
`the run's launch record does not exist — GATE L0.9b's RunArgs (the model matrix, budgets and ` +
|
|
1174
|
-
`every operator switch this run was typed with) was never written to disk before this launch, ` +
|
|
1175
|
-
`so nothing downstream can be attested against what the operator actually configured. Run ` +
|
|
1176
|
-
`\`harness init run-args\` (references/gates.md L0.9b names every flag it resolves — \`SKILL.md\` ` +
|
|
1177
|
-
`Step 2 runs it right before this workflow launches) and relaunch.`);
|
|
1178
|
-
}
|
|
1179
|
-
return aborted("preflight",
|
|
1180
|
-
`the launch-record check did not run to a verdict — exit_code ${r.exit_code}, not the 0 (present) ` +
|
|
1181
|
-
`or 6 (absent) \`probe concurrency --require-run-args\` documents.` +
|
|
1182
|
-
`${r.detail ? ` Courier reported: ${r.detail}.` : ""} This is NOT evidence the record is missing — ` +
|
|
1183
|
-
`most often a Bash call denied above this plugin's own hooks and permission grant (an untrusted ` +
|
|
1184
|
-
`workspace, or Claude Code's auto-mode classifier). Verify the run's launch record by hand — ` +
|
|
1185
|
-
`\`harness probe concurrency --slug ${slug} --require-run-args\` — before assuming it is absent, ` +
|
|
1186
|
-
`then relaunch.`);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
// The ledger's `status` field is bookkeeping, not this file's resume oracle — the fast-forward reads
|
|
1190
|
-
// artifacts. It survives because `reduce snapshot` and the the ship report's census hook read it to tell
|
|
1191
|
-
// a run in flight from a finished one. A lost write is a degraded digest, not a corrupted build, so
|
|
1192
|
-
// it warns and continues — and the warning travels in the RunReturn, because a headless stdout
|
|
1193
|
-
// carries only the final message and a diagnostic on a channel nobody reads is not a diagnostic.
|
|
1194
|
-
const stateWarnings = [];
|
|
1195
|
-
// Orders a receipt says were dispatched and no WorkResult ever answered — a leg that did its craft
|
|
1196
|
-
// and never came back. Named at every close, whatever the close; never folded into "complete".
|
|
1197
|
-
const unansweredOrders = [];
|
|
1198
|
-
async function setRunStatus(status, phaseName) {
|
|
1199
|
-
const r = await cmd(`probe resume --slug ${slug} --set-status ${status}`, phaseName, `status:${status}`);
|
|
1200
|
-
if (!r.ok) {
|
|
1201
|
-
const why = (r.detail || `exit ${r.exit_code}`).trim();
|
|
1202
|
-
log(`RUN STATE — status="${status}" did not take: ${why}. The run continues (resume is derived from ` +
|
|
1203
|
-
`artifacts, not from this field), but the snapshot and the the ship report's census hook will read ` +
|
|
1204
|
-
`this run as unfinished.`);
|
|
1205
|
-
stateWarnings.push(`status="${status}" did not take: ${why}`);
|
|
1206
|
-
} else if (r.decision === "reopened") {
|
|
1207
|
-
// The ledger now carries the earlier close under `prior_closes`; say so where a reader of the
|
|
1208
|
-
// run's own log will see it, because resuming a closed run changes what its close will say.
|
|
1209
|
-
log(`RUN STATE — this run had been closed; moving it to "${status}" reopened it. The earlier close is ` +
|
|
1210
|
-
`kept in the ledger under prior_closes, and the run's next terminal close is recorded as its own.`);
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// A free-text reason, made safe to spell into a sub-agent's shell instruction: no quotes, no
|
|
1215
|
-
// newlines, no backticks or `$` — every one of those risks breaking the command the sub-agent
|
|
1216
|
-
// itself constructs from this file's own instruction text (see `cmd()`'s banner: every kernel call
|
|
1217
|
-
// in this script is spelled into a prompt, not spawned directly). Truncated, not elided: the exit
|
|
1218
|
-
// code and the ledger's own close_cause line are still the source of record — this is only what
|
|
1219
|
-
// crosses the prompt boundary.
|
|
1220
|
-
const causeArg = (s) => (String(s ?? "").replace(/[`"'$\\\n\r]/g, " ").replace(/\s+/g, " ").trim().slice(0, 300) || "no reason recorded");
|
|
1221
|
-
|
|
1222
|
-
// A terminal RunReturn closes the run's own ledger — a terminal status, its cause, and a close
|
|
1223
|
-
// timestamp, in one write. WHICH arms are terminal, and what status each closes as, is no longer
|
|
1224
|
-
// decided here: `probe resume --close-arm <ret.status>` hands the kernel the RunReturn arm itself
|
|
1225
|
-
// and it answers from `RUN_RETURN_CLOSE` (kernel/probe/resume.mjs), derived from the schema's own
|
|
1226
|
-
// enum rather than a pair of statuses this file used to compare `ret.status` against by hand — a
|
|
1227
|
-
// literal comparison that could not see a new arm go unhandled, because it never consulted the
|
|
1228
|
-
// kernel's own list of terminal statuses at all. `gate_h` now closes the run as `escalated` — the
|
|
1229
|
-
// breaker that tripped travels in the close's cause text, never as a status of its own; the
|
|
1230
|
-
// tech-lead skill's own GATE H → L4 orchestration still runs the census and the ship decision, it
|
|
1231
|
-
// just no longer finds the ledger's close fields unset when it gets there. `paused` and `ok` remain
|
|
1232
|
-
// explicitly non-terminal — the kernel says so, this call site no longer needs to know why.
|
|
1233
|
-
// Best-effort, the same discipline as `setRunStatus` above: a lost write degrades the trace's own
|
|
1234
|
-
// record of why the run ended, it does not change what this return reports.
|
|
1235
|
-
//
|
|
1236
|
-
// A close can also come back `ok:true` and still be a degraded outcome: `closeRun`
|
|
1237
|
-
// (kernel/probe/resume.mjs) refuses a DIFFERENT terminal status outright (that failure already hits
|
|
1238
|
-
// the `!r.ok` branch below, exit 3), but the SAME status with a DIFFERENT cause — a run_id closed
|
|
1239
|
-
// more than once across relaunches, e.g. `aborted` at Preflight then `aborted` again for a different
|
|
1240
|
-
// reason after a later relaunch — is *superseded* rather than refused: `ok:true`, the new cause
|
|
1241
|
-
// folded together with the old one on disk, and `decision:"superseded"` (the one bare token `cmd()`
|
|
1242
|
-
// relays verbatim across the courier boundary — see its own banner) naming that outcome. Reporting
|
|
1243
|
-
// that as a clean, silent success would be a state fact the trace needs with nothing surfacing it —
|
|
1244
|
-
// the same shape as leaving a run's ledger open after it ended. It costs nothing this return itself
|
|
1245
|
-
// reports — the ledger already folded the prior cause in — but the run's own RunReturn must say the
|
|
1246
|
-
// trace is degraded.
|
|
1247
|
-
async function closeIfTerminal(ret) {
|
|
1248
|
-
const cause = ret.status === "aborted"
|
|
1249
|
-
? `${ret.aborted_at || "?"}: ${ret.reason || "no reason recorded"}`
|
|
1250
|
-
: ret.status === "gate_h"
|
|
1251
|
-
? (ret.stalled ? `stalled=${ret.stalled} ` : "")
|
|
1252
|
-
+ `breaker=${ret.breaker ?? "?"} green_scopes=${Array.isArray(ret.green_scopes) ? ret.green_scopes.length : "?"} hammer_proposals=${Array.isArray(ret.hammer_proposals) ? ret.hammer_proposals.length : "?"}`
|
|
1253
|
-
+ (Array.isArray(ret.tripped_scopes) ? ` tripped_scopes=${ret.tripped_scopes.length}` : "")
|
|
1254
|
-
// A result the single writer never applied is named at the close, not folded into "not green".
|
|
1255
|
-
+ (Array.isArray(ret.unapplied_results) && ret.unapplied_results.length ? ` unapplied_results=${ret.unapplied_results.length}` : "")
|
|
1256
|
-
+ (ret.census ? ` census=${ret.census}` : "") + (ret.l4 ? ` l4=${ret.l4}` : "") + (ret.ship_report ? ` ship_report=${ret.ship_report}` : "")
|
|
1257
|
-
: `verdict=${ret.verdict ?? "?"} rounds=${ret.rounds_used ?? "?"} qa_findings=${ret.qa_findings ?? "?"}` + (ret.qa ? ` qa=${ret.qa}` : "")
|
|
1258
|
-
+ (ret.after ? ` after=${ret.after} breaker=${ret.breaker ?? "?"} census=${ret.census ?? "?"} cut_list=${Array.isArray(ret.cut_list) ? ret.cut_list.length : "?"}` : "");
|
|
1259
|
-
// `--close-arm` hands the kernel the arm itself (not a status this file decided was terminal) —
|
|
1260
|
-
// a non-terminal arm (`paused`, `ok`) still exits 0 with no "decision" key, so the branches below
|
|
1261
|
-
// stay silent for it exactly as they did when this file's own guard returned early.
|
|
1262
|
-
const unanswered = unansweredOrders.length ? ` unanswered_orders=${unansweredOrders.length}` : "";
|
|
1263
|
-
const r = await cmd(`probe resume --slug ${slug} --close-arm ${ret.status} --cause "${causeArg(cause + unanswered)}"`, "Ship", `close:${ret.status}`);
|
|
1264
|
-
if (!r.ok) {
|
|
1265
|
-
const why = (r.detail || `exit ${r.exit_code}`).trim();
|
|
1266
|
-
log(`RUN STATE — close(${ret.status}) did not take: ${why}. This return's own status and reason still ` +
|
|
1267
|
-
`stand; only the ledger's own closed_at/close_cause record is degraded.`);
|
|
1268
|
-
stateWarnings.push(`close(${ret.status}) did not take: ${why}`);
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
if (String(r.decision ?? "").trim() === "superseded") {
|
|
1272
|
-
log(`RUN STATE — close(${ret.status}) superseded an earlier close recorded under the same status ` +
|
|
1273
|
-
`but a different cause — this run_id was closed more than once. Both causes are on the ledger's ` +
|
|
1274
|
-
`own close_cause line; this return's trace is degraded, not corrupted.`);
|
|
1275
|
-
stateWarnings.push(`close(${ret.status}) superseded an earlier close of this run_id — see harness-run.md's close_cause for both reasons`);
|
|
1276
|
-
}
|
|
1277
|
-
// The close itself took (r.ok above) — an export_warning here is `exportOnClose`
|
|
1278
|
-
// (kernel/probe/resume.mjs) reporting it could not project this run's
|
|
1279
|
-
// fact tables. Advisory, same as every other line in this function: the close stands, the run's
|
|
1280
|
-
// own return says the trace is short one export rather than swallowing the fact.
|
|
1281
|
-
if (r.export_warning) {
|
|
1282
|
-
log(`RUN STATE — close(${ret.status}) took, but its export did not: ${r.export_warning}`);
|
|
1283
|
-
stateWarnings.push(`close(${ret.status}): ${r.export_warning}`);
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
const withWarnings = async (ret) => {
|
|
1287
|
-
await closeIfTerminal(ret);
|
|
1288
|
-
return stateWarnings.length ? { ...ret, state_warnings: stateWarnings } : ret;
|
|
1289
|
-
};
|
|
1290
|
-
|
|
1291
|
-
// =============================================================================================
|
|
1292
|
-
// THE RUN
|
|
1293
|
-
// =============================================================================================
|
|
1294
|
-
phase("Preflight");
|
|
1295
|
-
|
|
1296
|
-
// CANARY — can THIS SESSION resolve a worker skill at all?
|
|
1297
|
-
//
|
|
1298
|
-
// `init run` already refused if the SKILL.md files are not on disk. That is the cheap half and it
|
|
1299
|
-
// is not the failure: in the session that produced this defect the files were sitting right there
|
|
1300
|
-
// in the repo, and the dispatch still returned "Unknown skill" because the plugin was not loaded.
|
|
1301
|
-
// A file check passes green on both states that actually happen — installed-but-disabled, and a
|
|
1302
|
-
// different version loaded — because those copies have all ten SKILL.md files too.
|
|
1303
|
-
//
|
|
1304
|
-
// So one real dispatch, before any worker is paid for. Deliberately with NO `--order`: this is
|
|
1305
|
-
// testing name resolution, not doing work, and an order would leave a compiled order with no result
|
|
1306
|
-
// in `orders/` that every reader of that directory would then have to know about. A canary that
|
|
1307
|
-
// perturbs the run it is clearing is not a preflight.
|
|
1308
|
-
//
|
|
1309
|
-
// The evidence is not the sub-agent's report — it is the hook layer's. A Skill call whose name does
|
|
1310
|
-
// not resolve fires no hook at all, so a decision row naming the skill is proof the name resolved,
|
|
1311
|
-
// and `verify dispatch` reads that row. The sub-agent cannot write it, so it cannot fake it.
|
|
1312
|
-
const canarySkill = "orient";
|
|
1313
|
-
await agent(
|
|
1314
|
-
`Make exactly ONE tool call and nothing else: Skill(shapeup-sdlc-plugin:${canarySkill}).\n\n` +
|
|
1315
|
-
`Pass no arguments. Do NOT act on anything the skill returns — this is a preflight that checks ` +
|
|
1316
|
-
`the skill can be reached, not a request to do its work. Do not use any other tool.\n\n` +
|
|
1317
|
-
`Then report whether the call returned or errored, in one line.`,
|
|
1318
|
-
{ model: "sonnet", effort: "low", phase: "Preflight", label: `canary:${canarySkill}` },
|
|
1319
|
-
);
|
|
1320
|
-
const canary = await cmd(`verify dispatch --skill ${canarySkill} --within 900`, "Preflight", "canary-evidence");
|
|
1321
|
-
if (!canary.ok) {
|
|
1322
|
-
return await withWarnings(aborted("preflight",
|
|
1323
|
-
`the ${canarySkill} skill did not resolve in this session — no dispatch reached the hook layer. ` +
|
|
1324
|
-
`A run would report phases completing while the sub-agents improvised every worker's craft. ` +
|
|
1325
|
-
`Load the plugin (\`claude --plugin-dir <repo>\`, or install and enable it) and relaunch. ` +
|
|
1326
|
-
`(${canary.detail || `exit ${canary.exit_code}`})`));
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// ENVIRONMENT CANARY — do the project's own build and launch probes run in THIS session?
|
|
1330
|
-
//
|
|
1331
|
-
// The skill canary proves the workers resolve; it says nothing about whether the commands the round
|
|
1332
|
-
// gate will run can. A missing package install or local SDK pointer fails the build in a second, a
|
|
1333
|
-
// probe outside the grant is refused, a device that is not attached leaves every `[ui]` row
|
|
1334
|
-
// ungraded — and each used to surface only at the first round gate, after planning was paid for.
|
|
1335
|
-
// `verify build --preflight` runs the same declared steps from a sub-agent, through the same grant,
|
|
1336
|
-
// and writes nothing. It WARNS rather than aborts: a baseline can be red for a reason the feature is
|
|
1337
|
-
// meant to fix, and an unattended run should still say so up front rather than stop.
|
|
1338
|
-
//
|
|
1339
|
-
// The evidence is this command's exit code. Whether a grant "looks present" in a settings file is
|
|
1340
|
-
// not evidence of anything: a grant can arrive by other routes, and one in the file can still be
|
|
1341
|
-
// refused above it.
|
|
1342
|
-
const envCanary = await cmd(`verify build --slug ${slug} --preflight`, "Preflight", "canary-env");
|
|
1343
|
-
if (envCanary.exit_code === 1 || envCanary.exit_code === 4) {
|
|
1344
|
-
const kind = envCanary.exit_code === 4 ? "a probe could not run (exit 2 — typically no device attached, or a missing artifact)" : "a declared step is red";
|
|
1345
|
-
const msg = `ENVIRONMENT — preflight: ${kind}${envCanary.detail ? `: ${envCanary.detail}` : ""}. The same step runs in every ` +
|
|
1346
|
-
`round's build gate, so fix the environment now if it is not the feature's to fix.`;
|
|
1347
|
-
log(msg);
|
|
1348
|
-
stateWarnings.push(msg);
|
|
1349
|
-
} else if (envCanary.exit_code !== 0 && envCanary.exit_code !== 3) {
|
|
1350
|
-
const msg = `ENVIRONMENT — preflight did not run (exit ${envCanary.exit_code}${envCanary.detail ? `: ${envCanary.detail}` : ""}); ` +
|
|
1351
|
-
`nothing is known about the build or launch probes until the first round gate.`;
|
|
1352
|
-
log(msg);
|
|
1353
|
-
stateWarnings.push(msg);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// GATE L0.9b's launch record must exist before anything past Preflight dispatches; see
|
|
1357
|
-
// requireLaunchRecord()'s own banner for why this cannot be left to Step 2's prose alone.
|
|
1358
|
-
const launchRecordAbort = await requireLaunchRecord();
|
|
1359
|
-
if (launchRecordAbort) return await withWarnings(launchRecordAbort);
|
|
1360
|
-
|
|
1361
|
-
phase("Orient");
|
|
1362
|
-
|
|
1363
|
-
const rs = await query(`probe resume --slug ${slug} --plugin-root "${args.pluginRoot}"`, RESUME, "Orient", "resume-state");
|
|
1364
|
-
// WHICH ORCHESTRATOR IS RUNNING. A run keeps the workflow copy it opened with — correct for a run
|
|
1365
|
-
// in flight, and silent: a relaunch after an upgrade executes the old script, reports normally and
|
|
1366
|
-
// closes normally, so every observation is of the previous release. Said out loud, never enforced.
|
|
1367
|
-
if (rs.staged_workflow?.warning) {
|
|
1368
|
-
log(`RUN STATE — ${rs.staged_workflow.warning}`);
|
|
1369
|
-
stateWarnings.push(rs.staged_workflow.warning);
|
|
1370
|
-
}
|
|
1371
|
-
// A probe that produced nothing is not an EMPTY run — it is an unknown one. Treating it as empty
|
|
1372
|
-
// would re-dispatch every phase from the top, over a run that may be in progress.
|
|
1373
|
-
if (!rs) {
|
|
1374
|
-
return await withWarnings(aborted("probe", "the fast-forward derivation returned no state — refusing to re-dispatch a run that may already be in progress"));
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// A RELAUNCH OVER A CLOSED RUN REOPENS IT FIRST. The first status write is what takes a close back,
|
|
1378
|
-
// and a launch that fast-forwards every planning phase made that write only at BUILD, so the gates
|
|
1379
|
-
// it crossed on the way were recorded against a run whose ledger still read closed. Bookkeeping,
|
|
1380
|
-
// not a phase decision: the phases below still branch on artifacts alone.
|
|
1381
|
-
if (rs.closed_status) await setRunStatus("orienting", "Orient");
|
|
1382
|
-
|
|
1383
|
-
const specFolder = rs.spec_folder || `shapeup/${slug}/spec/`;
|
|
1384
|
-
// A set the PO named at L0.5, or null: `harness compile` then resolves it from the spec for each
|
|
1385
|
-
// evaluate order. A fixed fallback here would travel as an explicit list and switch off the judge's
|
|
1386
|
-
// own auto-enable, which is how every run once graded spec-conformance alone.
|
|
1387
|
-
const evalDims = rs.eval_dimensions?.length ? rs.eval_dimensions : null;
|
|
1388
|
-
// What the judge was actually asked to grade, read back off the newest evaluate order.
|
|
1389
|
-
let gradedDims = evalDims || [];
|
|
1390
|
-
|
|
1391
|
-
// ---- ORIENT + GATE L1a ----------------------------------------------------------------------
|
|
1392
|
-
let spikedArea = "~", spikeResult = "~", riskiest = [];
|
|
1393
|
-
if (!rs.has_orient_artifacts) {
|
|
1394
|
-
log(`ORIENT — dispatching (slug ${slug})`);
|
|
1395
|
-
await setRunStatus("orienting", "Orient");
|
|
1396
|
-
const dispatchOrient = (note = "") => worker({
|
|
1397
|
-
skill: "orient", operation: "orient", schema: ORIENT, phase: "Orient", label: note ? "orient:again" : "orient",
|
|
1398
|
-
payload: { pitch: rs.intake_path, breadboard: rs.breadboard_path, spec_folder: specFolder, feature: slug, stack: rs.stack },
|
|
1399
|
-
// NAME THE FILES. "write the orient/ artifacts" was the whole instruction, while completion is
|
|
1400
|
-
// decided by four exact filenames — so a leg that did the work and called its output
|
|
1401
|
-
// `code-surface-map.md` and `discovered-tasks.md` aborted the run at the post-condition, having
|
|
1402
|
-
// spiked real code and written four genuinely useful files. The skill doc states these names
|
|
1403
|
-
// four times over; the dispatch prose stated them zero. Cheap to say, and the phase's completion
|
|
1404
|
-
// contract belongs where the worker reads it, not only where it is enforced.
|
|
1405
|
-
extra:
|
|
1406
|
-
"Read and spike real code before any board exists. The phase is COMPLETE only when the run's " +
|
|
1407
|
-
"orient directory — the one this order's substrate permits — contains all four of these, " +
|
|
1408
|
-
"named exactly: `code-surface.md`, " +
|
|
1409
|
-
"`discovered-seed.md`, `hill-signal.md`, and one `spike-<area>.md` (or `spike-not-needed.md` " +
|
|
1410
|
-
"when the risk scan came back rank 0). Any other filename leaves the phase incomplete and " +
|
|
1411
|
-
"the run aborts, however good the contents are." + note,
|
|
1412
|
-
});
|
|
1413
|
-
const o = await dispatchOrient();
|
|
1414
|
-
if (o.__failed) return await withWarnings(diedAt("ORIENT", o));
|
|
1415
|
-
const post = await requirePhase("ORIENT", "orient", "Orient", "orient", dispatchOrient);
|
|
1416
|
-
if (post) return await withWarnings(post);
|
|
1417
|
-
await advisory(`reduce graph --slug ${slug}`, "Orient", "graph:orient");
|
|
1418
|
-
spikedArea = o.spiked_area; spikeResult = o.spike_result; riskiest = o.riskiest_unknowns || [];
|
|
1419
|
-
} else {
|
|
1420
|
-
const post = await fastForward("ORIENT", "orient", "Orient", "artifacts already on disk");
|
|
1421
|
-
if (post) return await withWarnings(post);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
{
|
|
1425
|
-
// `breadboard` travels in the block because a missing one is invisible anywhere later: every
|
|
1426
|
-
// downstream artifact reads the same whether or not the pitch's second half reached the run.
|
|
1427
|
-
const g = await crossGate("L1a", "Orient", ["proceed", "ask", "abort"],
|
|
1428
|
-
{ breadboard: rs.breadboard_source ?? "none", spiked_area: spikedArea, spike_result: spikeResult, riskiest_unknowns: riskiest });
|
|
1429
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// ---- COVERAGE (the requirements registry) — ahead of ANALYZE, whose ACs cite its ids ----------
|
|
1433
|
-
//
|
|
1434
|
-
// WHY IT RUNS AT ALL, and why here. The pitch's own requirement list is the one statement of what
|
|
1435
|
-
// the run was asked for, and until it is extracted into `shapeup/<slug>/requirements.md` there is
|
|
1436
|
-
// no stable key an acceptance criterion, a scope contract or a verdict can point back to. Measured
|
|
1437
|
-
// on a full run: the planner produced the requirement edge on the board and the judge never saw
|
|
1438
|
-
// it, because nothing on either side shared a key space. So the registry is written BEFORE the
|
|
1439
|
-
// board, not beside it — ANALYZE's acceptance criteria cite `REQ-<n>` ids, which have to exist
|
|
1440
|
-
// before they can be cited.
|
|
1441
|
-
//
|
|
1442
|
-
// IT IS NOT A PHASE, AND THAT IS THE WHOLE DESIGN OF THIS BLOCK.
|
|
1443
|
-
// · No `phase("Coverage")`: the dispatch belongs to the planning stretch the Analyze group
|
|
1444
|
-
// already covers (`setRunStatus("mapping")` spans it), so it never renders as an empty group
|
|
1445
|
-
// on a relaunch — the failure a per-phase progress box would otherwise have to pay a leg to
|
|
1446
|
-
// avoid, and there is no leg to spend here (see the next point).
|
|
1447
|
-
// · No `requirePhase()` / no `fastForward()`: both route to `probe resume --require`, whose
|
|
1448
|
-
// `--require` is an ENUM over `PHASE_ARTIFACT`'s keys. `coverage` is not one, so the call exits
|
|
1449
|
-
// 2, and this file reads any exit other than 6 as "the predicate was never asked" — a dispatch
|
|
1450
|
-
// that worked would abort the run. The skip is therefore narrated, and guarded on the bare
|
|
1451
|
-
// `has_requirements` boolean the resume state carries.
|
|
1452
|
-
// · Not in `PHASE_ARTIFACT` either: that map is also `nextPhase()`'s ordered list, so adding it
|
|
1453
|
-
// would fast-forward every pre-registry run to the registry instead of to `build`.
|
|
1454
|
-
phase("Analyze");
|
|
1455
|
-
if (!rs.has_requirements) {
|
|
1456
|
-
log(`COVERAGE — dispatching (slug ${slug})`);
|
|
1457
|
-
await setRunStatus("mapping", "Analyze");
|
|
1458
|
-
const c = await worker({
|
|
1459
|
-
skill: "ba-pitch-analyzer", operation: "coverage", schema: PHASE_OK, phase: "Analyze", label: "coverage",
|
|
1460
|
-
payload: { requirements: rs.intake_path, feature: slug },
|
|
1461
|
-
extra:
|
|
1462
|
-
"Extract the pitch's requirement clauses into the SHARED requirements registry, one atomic " +
|
|
1463
|
-
"clause per row. A clause carrying an R-id keeps its number as REQ-<n> and records the R-id " +
|
|
1464
|
-
"verbatim in its source cell; ids are assigned once and never renumbered.",
|
|
1465
|
-
});
|
|
1466
|
-
if (c.__failed) return await withWarnings(diedAt("COVERAGE", c));
|
|
1467
|
-
await advisory(`reduce graph --slug ${slug}`, "Analyze", "graph:coverage");
|
|
1468
|
-
} else {
|
|
1469
|
-
log(`COVERAGE — a requirements registry is already on disk; not re-dispatching it`);
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
// ---- ANALYZE (spec tree + board) — ahead of WIRE, which reads its use cases -------------------
|
|
1473
|
-
if (!rs.has_spec_tree) {
|
|
1474
|
-
log(`ANALYZE — dispatching (slug ${slug})`);
|
|
1475
|
-
// "mapping", not "analyzing": the kernel's RUN_STATUSES enum is deliberately COARSER than this
|
|
1476
|
-
// file's phases — one value covers ANALYZE through MAP SCOPES, the stretch where the run is
|
|
1477
|
-
// working out the shape. `analyzing` is not a member and never was, so this call failed on every
|
|
1478
|
-
// single run since the cutover. It is advisory, so nothing stopped; the ledger simply stayed on
|
|
1479
|
-
// the previous phase and the snapshot under-reported where the run had got to.
|
|
1480
|
-
await setRunStatus("mapping", "Analyze");
|
|
1481
|
-
const { r: a } = await boardSentBack((note) => worker({
|
|
1482
|
-
skill: "ba-pitch-analyzer", operation: "analyze", schema: PHASE_OK, phase: "Analyze", label: note ? "analyze:again" : "analyze",
|
|
1483
|
-
payload: { pitch: rs.intake_path, breadboard: rs.breadboard_path, spec_folder: specFolder, feature: slug, lens: rs.lens, orient_dir: rs.orient_dir },
|
|
1484
|
-
extra: "Write the spec tree and the board from the orient artifacts — do not re-scan the code." + note,
|
|
1485
|
-
}), "analyze");
|
|
1486
|
-
if (a.__failed) return await withWarnings(diedAt("ANALYZE", a));
|
|
1487
|
-
const post = await requirePhase("ANALYZE", "analyze", "Analyze");
|
|
1488
|
-
if (post) return await withWarnings(post);
|
|
1489
|
-
await advisory(`reduce graph --slug ${slug}`, "Analyze", "graph:analyze");
|
|
1490
|
-
} else if (!rs.has_board) {
|
|
1491
|
-
// THE HALF THAT DOES NOT SURVIVE. The spec tree is committed; the board is per-machine and
|
|
1492
|
-
// gitignored, and ANALYZE writes both. A run after the first on a machine — and every run in a
|
|
1493
|
-
// fresh checkout — used to fast-forward on the committed half alone and build over no board:
|
|
1494
|
-
// GATE L2 crossed 0/0 tasks, the evaluator could read no `covers:` clause, and the requirements
|
|
1495
|
-
// projection said "no evidence" for a round whose static criteria all passed. The board-only
|
|
1496
|
-
// operation regenerates it from the tree without re-deriving the tree.
|
|
1497
|
-
log(`ANALYZE — spec tree on disk, no board: dispatching the board-only operation (slug ${slug})`);
|
|
1498
|
-
await setRunStatus("mapping", "Analyze");
|
|
1499
|
-
const { r: b } = await boardSentBack((note) => worker({
|
|
1500
|
-
skill: "ba-pitch-analyzer", operation: "board", schema: PHASE_OK, phase: "Analyze", label: note ? "board:again" : "board",
|
|
1501
|
-
payload: { spec_folder: specFolder, feature: slug, lens: rs.lens },
|
|
1502
|
-
extra: "The spec tree is committed and FROZEN for this dispatch. Regenerate the per-machine board " +
|
|
1503
|
-
"under the run's tasks/ directory from the use cases on disk — every acceptance criterion " +
|
|
1504
|
-
"carrying its `(covers: REQ-…)` clause — and write nothing under the spec folder." + note,
|
|
1505
|
-
}), "board");
|
|
1506
|
-
if (b.__failed) return await withWarnings(diedAt("ANALYZE", b));
|
|
1507
|
-
// The board a worker writes carries `depends_on`; its inverse (`unlocks`) and a new task's
|
|
1508
|
-
// `status: todo` are mechanical, so the kernel writes them rather than trusting every regeneration
|
|
1509
|
-
// to remember — a board that came back without both failed spec-lint on every task at L1b.
|
|
1510
|
-
await advisory(`reduce board --slug ${slug} --write`, "Analyze", "board:derive");
|
|
1511
|
-
const post = await requirePhase("ANALYZE", "analyze", "Analyze", "board");
|
|
1512
|
-
if (post) return await withWarnings(post);
|
|
1513
|
-
await advisory(`reduce graph --slug ${slug}`, "Analyze", "graph:board");
|
|
1514
|
-
} else {
|
|
1515
|
-
const post = await fastForward("ANALYZE", "analyze", "Analyze", "spec tree and board already on disk");
|
|
1516
|
-
if (post) return await withWarnings(post);
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// ---- WIRE + GATE L1a.5 ------------------------------------------------------------------------
|
|
1520
|
-
phase("Wire");
|
|
1521
|
-
if (!rs.has_wiring_map) {
|
|
1522
|
-
// FAIL FAST, at the orchestrator: `rs` already carries has_project_profile from the resume
|
|
1523
|
-
// snapshot taken before ORIENT — the SAME fact solution-architect would have to discover for
|
|
1524
|
-
// itself. Dispatching without it spends a full worker turn only for the worker to hit its own
|
|
1525
|
-
// documented rule ("profile absent ⇒ ESCALATE, do not invent an entry point" — solution-
|
|
1526
|
-
// architect/SKILL.md) and escalate; an escalation writes no wiring-map.md, so `has_wiring_map`
|
|
1527
|
-
// stays false and every relaunch re-dispatches and re-escalates identically. The orchestrator
|
|
1528
|
-
// holds the state a gate needs; it should not hand the check to the LLM it is about to pay for.
|
|
1529
|
-
if (!rs.has_project_profile) {
|
|
1530
|
-
return await withWarnings(aborted("WIRE",
|
|
1531
|
-
`missing SHARED project-profile.md at ${rs.project_profile_path} — GATE L0 writes it ` +
|
|
1532
|
-
`({schema_version:1, archetype, entry_point}; references/gates.md GATE L0 §PROFILE) before ` +
|
|
1533
|
-
`this workflow launches. WIRE cannot resolve an entry_call_site without an entry_point to ` +
|
|
1534
|
-
`resolve against. Write the profile, then relaunch.`));
|
|
1535
|
-
}
|
|
1536
|
-
log(`WIRE — dispatching (slug ${slug})`);
|
|
1537
|
-
const dispatchWire = (note = "") => worker({
|
|
1538
|
-
skill: "solution-architect", operation: "wire", schema: PHASE_OK, phase: "Wire", label: note ? "wire:again" : "wire",
|
|
1539
|
-
payload: { feature: slug, spec_folder: specFolder, project_profile: rs.project_profile_path, breadboard: rs.breadboard_path },
|
|
1540
|
-
extra: "Write the wiring map: per use case, engine → seam → entry-point call site → affordance." + note,
|
|
1541
|
-
});
|
|
1542
|
-
const w = await dispatchWire();
|
|
1543
|
-
if (w.__failed) return await withWarnings(diedAt("WIRE", w));
|
|
1544
|
-
const post = await requirePhase("WIRE", "wire", "Wire", "wire", dispatchWire);
|
|
1545
|
-
if (post) return await withWarnings(post);
|
|
1546
|
-
await advisory(`reduce graph --slug ${slug}`, "Wire", "graph:wire");
|
|
1547
|
-
} else {
|
|
1548
|
-
const post = await fastForward("WIRE", "wire", "Wire", "wiring map already on disk");
|
|
1549
|
-
if (post) return await withWarnings(post);
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
{
|
|
1553
|
-
const g = await crossGate("L1a.5", "Wire", ["proceed", "ask", "abort"], { wiring_map: "written" });
|
|
1554
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// ---- MAP SCOPES + GATE L1b --------------------------------------------------------------------
|
|
1558
|
-
phase("MapScopes");
|
|
1559
|
-
// Both ways this list is produced now carry the same shape: `probe resume`'s `scope_files` and
|
|
1560
|
-
// `MAPSCOPES.scopes` are each `{scope_id, path}`, so nothing downstream has to know which branch
|
|
1561
|
-
// filled it. An entry missing either half is dropped rather than turned into a scope whose path is
|
|
1562
|
-
// `undefined` — `compile --scope undefined` exits 2, and the attempt loop reads a non-zero exit as
|
|
1563
|
-
// the stagnation breaker, which is a dead scope reported as a hard one.
|
|
1564
|
-
let scopes = (rs.scope_files || []).filter((s) => s?.scope_id && s?.path)
|
|
1565
|
-
.map((s) => ({ path: s.path, scope_id: s.scope_id }));
|
|
1566
|
-
const mappedThisRun = scopes.length === 0;
|
|
1567
|
-
if (scopes.length === 0) {
|
|
1568
|
-
log(`MAP SCOPES — dispatching (slug ${slug})`);
|
|
1569
|
-
const m = await worker({
|
|
1570
|
-
skill: "scope-architect", operation: "map-scopes", schema: MAPSCOPES, phase: "MapScopes", label: "map-scopes",
|
|
1571
|
-
payload: { feature: slug, breadboard: rs.breadboard_path },
|
|
1572
|
-
// SAY THE PASS RULE, for the same reason ORIENT's filenames are named above: the rule lives in
|
|
1573
|
-
// `verify t0` (a fixture passes iff it exits 0) and the architect never saw it. Given a contract
|
|
1574
|
-
// that said only "commands that drive this scope end-to-end", it wrote the scope's error paths
|
|
1575
|
-
// as bare invocations — `todo done abc # E_INVALID_INDEX, exit 1` — which cannot pass by
|
|
1576
|
-
// construction, so four of six scopes could never go T0-green however correct their code was.
|
|
1577
|
-
// The two that did go green were the two whose fixtures happened to be `node --test …`.
|
|
1578
|
-
extra:
|
|
1579
|
-
"Write the scope contracts (substrate whitelists, verification fixtures) and report the " +
|
|
1580
|
-
"riskiest-first sequence. EVERY e2e_verification_fixture MUST EXIT 0 when the scope is " +
|
|
1581
|
-
"correct — T0 scores any non-zero exit as a failure, so a fixture written as a bare " +
|
|
1582
|
-
"error-path invocation can never pass and its scope can never go green. Put expected " +
|
|
1583
|
-
"non-zero exits INSIDE a test file that itself exits 0, and name that test file as the " +
|
|
1584
|
-
"fixture. Write `e2e_verification_fixtures` as a FRONTMATTER key holding bare command " +
|
|
1585
|
-
"strings — a `## e2e_verification_fixtures` markdown section is not parsed, and a command " +
|
|
1586
|
-
"with prose appended to it is not runnable. A scope whose fixtures do not parse has nothing " +
|
|
1587
|
-
"to verify it and is refused at the board review.",
|
|
1588
|
-
});
|
|
1589
|
-
if (m.__failed) return await withWarnings(diedAt("MAP SCOPES", m));
|
|
1590
|
-
const post = await requirePhase("MAP SCOPES", "map-scopes", "MapScopes");
|
|
1591
|
-
if (post) return await withWarnings(post);
|
|
1592
|
-
await advisory(`reduce graph --slug ${slug}`, "MapScopes", "graph:map-scopes");
|
|
1593
|
-
scopes = m.scopes;
|
|
1594
|
-
} else {
|
|
1595
|
-
const post = await fastForward("MAP SCOPES", "map-scopes", "MapScopes",
|
|
1596
|
-
`${scopes.length} scope contract(s) already on disk`);
|
|
1597
|
-
if (post) return await withWarnings(post);
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
// DEPENDENCY ORDER — a scope is never built beside a scope it consumes.
|
|
1601
|
-
//
|
|
1602
|
-
// The fan-out used to chunk scopes by a fixed width over the directory's alphabetical order. On the
|
|
1603
|
-
// criterion-1 run that scheduled `cli-integration` — the scope that wires every other scope's module
|
|
1604
|
-
// to the entry point — SECOND, beside the very scopes it consumes. It failed both rounds, and its
|
|
1605
|
-
// failure was the whole of the run's failure: five of six scopes went green, `bin/todo.js` stayed a
|
|
1606
|
-
// placeholder, and four individually T0-green command modules were unreachable.
|
|
1607
|
-
//
|
|
1608
|
-
// All three facts below are derived by the kernel from the contracts, so nothing here declares any
|
|
1609
|
-
// of them. The waves fix the ORDER scopes are considered in; the dependency edges fix when each one
|
|
1610
|
-
// is RELEASED; the exclusions forbid two scopes that may write the same declared-shared path from
|
|
1611
|
-
// running at the same time. Everything else fans out.
|
|
1612
|
-
const wavesFrom = (raw, list) => {
|
|
1613
|
-
const byId = new Map(list.map((s) => [s.scope_id, s]));
|
|
1614
|
-
const idOf = (p) => String(p).split("/").pop().replace(/\.(md|json)$/, "");
|
|
1615
|
-
const w = (raw || []).map((g) => g.map((p) => byId.get(idOf(p))).filter(Boolean)).filter((g) => g.length);
|
|
1616
|
-
// Every scope must appear exactly once, or the grouping is not trustworthy and one wave — today's
|
|
1617
|
-
// behavior — is the safe answer. A scheduler that drops a scope is worse than an unscheduled one.
|
|
1618
|
-
return w.length && w.reduce((a, g) => a + g.length, 0) === list.length ? w : [list];
|
|
1619
|
-
};
|
|
1620
|
-
let waves = wavesFrom(rs.scope_waves, scopes);
|
|
1621
|
-
let rawDeps = rs.scope_deps;
|
|
1622
|
-
let rawExclusions = rs.scope_exclusions;
|
|
1623
|
-
if (mappedThisRun && scopes.length > 1) {
|
|
1624
|
-
// The contracts were written THIS run, so the state probed before MAP SCOPES could not have seen
|
|
1625
|
-
// them. One cheap re-derivation, on the fresh-run path only.
|
|
1626
|
-
const rs2 = await query(`probe resume --slug ${slug}`, RESUME, "MapScopes", "scope-waves");
|
|
1627
|
-
if (rs2?.scope_waves?.length) {
|
|
1628
|
-
waves = wavesFrom(rs2.scope_waves, scopes);
|
|
1629
|
-
rawDeps = rs2.scope_deps;
|
|
1630
|
-
rawExclusions = rs2.scope_exclusions;
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
// The waves flattened ARE the build order: dependencies first, input order within a level. At a dial
|
|
1634
|
-
// of 1 the window degrades to exactly this sequence, which is the sequential lane unchanged.
|
|
1635
|
-
const buildOrder = waves.flat();
|
|
1636
|
-
const excluded = withExclusions(scopeEdges(rawDeps, waves, scopes), rawExclusions, buildOrder);
|
|
1637
|
-
const scopeReleases = excluded.edges;
|
|
1638
|
-
if (excluded.added) {
|
|
1639
|
-
log(`BUILD order — ${excluded.added} pair(s) of scopes may write the same declared-shared path and are ` +
|
|
1640
|
-
`serialised against each other. Concurrent writers to one shared file lose each other's work, and ` +
|
|
1641
|
-
`nothing downstream reports it: every fixture, lint and hook stays green over the surviving copy.`);
|
|
1642
|
-
}
|
|
1643
|
-
// THE CEILING IS REPORTED WHETHER OR NOT IT IS THE DIAL, and it is reported BEFORE anything is
|
|
1644
|
-
// dispatched. A ceiling under the dial is a scope-cutting fact — the substrate the contracts declared
|
|
1645
|
-
// does not admit the concurrency the run is paying for — and it is fixed by re-cutting scopes at the
|
|
1646
|
-
// board review, not by anything BUILD can do. Said here, it costs a line; found later, it is a slow
|
|
1647
|
-
// round indistinguishable from slow workers.
|
|
1648
|
-
const ceiling = Math.min(releaseCeiling(scopeReleases, buildOrder), maxParallelScopes);
|
|
1649
|
-
if (waves.length > 1 || excluded.added || ceiling < maxParallelScopes) {
|
|
1650
|
-
const edgeCount = [...scopeReleases.values()].reduce((a, s) => a + s.size, 0);
|
|
1651
|
-
log(`BUILD order — ${waves.length} dependency wave(s): ${waves.map((w) => w.map((s) => s.scope_id).join("+")).join(" → ")}` +
|
|
1652
|
-
` · ${edgeCount} release edge(s) · at most ${ceiling} scope(s) can be open at once` +
|
|
1653
|
-
`${ceiling < maxParallelScopes ? ` (the window is ${maxParallelScopes}; the substrate the contracts declared is what caps it, not the dial)` : ""}`);
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
// Advisory lints at L1b. spec-lint is hard — a substrate overlap makes parallel builds unsafe, and
|
|
1657
|
-
// a breadboard Place with no screen builds the wrong thing; trace-lint stays advisory until
|
|
1658
|
-
// `covers:` is populated; hill-derive is a projection. The abort names no cause of its own: spec-lint
|
|
1659
|
-
// has more than one kind of red, and the detail says which.
|
|
1660
|
-
const specLint = await cmd(`verify spec --slug ${slug}`, "MapScopes", "spec-lint");
|
|
1661
|
-
if (!specLint.ok) {
|
|
1662
|
-
return await withWarnings(aborted("L1b", `spec-lint reported red findings before BUILD: ${specLint.detail || `exit ${specLint.exit_code}`}`));
|
|
1663
|
-
}
|
|
1664
|
-
await advisory(`verify trace --slug ${slug} --quiet`, "MapScopes", "trace-lint");
|
|
1665
|
-
await advisory(`reduce hill --slug ${slug}`, "MapScopes", "hill-derive");
|
|
1666
|
-
|
|
1667
|
-
{
|
|
1668
|
-
const g = await crossGate("L1b", "MapScopes", ["proceed", "ask", "abort"], { scopes: scopes.map((s) => s.scope_id) });
|
|
1669
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
// =============================================================================================
|
|
1673
|
-
// ROUNDS of BUILD → GATE L2 → EVAL → GATE L3, bounded by maxRounds.
|
|
1674
|
-
//
|
|
1675
|
-
// The round loop is inlined here rather than dispatched round-by-round because a round dispatched
|
|
1676
|
-
// once can attempt every scope from attempt 1, while an outer loop must survive a mid-BUILD kill
|
|
1677
|
-
// and resume without re-work. So before opening a scope's attempt loop this file asks whether THIS
|
|
1678
|
-
// ROUND already has a green T0 verdict for it on disk, and skips the scope entirely when it does.
|
|
1679
|
-
// =============================================================================================
|
|
1680
|
-
const lastEval = rs.eval_rounds_done?.length ? Math.max(...rs.eval_rounds_done) : 0;
|
|
1681
|
-
let round = lastEval + 1;
|
|
1682
|
-
let verdict = null;
|
|
1683
|
-
// DERIVED FROM DISK AT EVERY LAUNCH, never accumulated only in memory. These two lists ARE GATE H's
|
|
1684
|
-
// census: `scope-hammer` is dispatched with `hammer_proposals`, and AGENTS.md makes a scope that
|
|
1685
|
-
// exhausted its attempt budget a queued GATE H proposal. As bare `const []` they were emptied by the
|
|
1686
|
-
// one event that most needs them intact — a gate PAUSE is a `return`, so the PO's answer is followed
|
|
1687
|
-
// by a FRESH LAUNCH whose accumulators start empty and whose round loop is then fast-forwarded past
|
|
1688
|
-
// the rounds that filled them. The PO was handed an empty cut list for a run that had genuinely
|
|
1689
|
-
// exhausted scopes. Unattended (`ci`) runs never pause, which is why no archived trace shows it.
|
|
1690
|
-
//
|
|
1691
|
-
// This file has now paid for the same class three times — `findings` in the temporal dead zone and
|
|
1692
|
-
// `payload.bugs` "lived in a variable, which a relaunch between two rounds resets to empty" are the
|
|
1693
|
-
// other two. The cure is the same each time and it is not a bigger variable: re-derive the fact.
|
|
1694
|
-
const allGreen = [];
|
|
1695
|
-
const allHammer = [];
|
|
1696
|
-
const allUnapplied = []; // results on disk the single writer never applied, run-wide
|
|
1697
|
-
// OUTSIDE the loop, because its whole purpose is to cross a round boundary: round r's verdict is
|
|
1698
|
-
// what round r+1 has to act on. Declared inside, it was in the temporal dead zone at the BUILD that
|
|
1699
|
-
// needed it — a runtime error no static check can see, since nothing but a real second round ever
|
|
1700
|
-
// reaches that line.
|
|
1701
|
-
let findings = [];
|
|
1702
|
-
|
|
1703
|
-
// THE ROUND THE RELAUNCH IS RESUMING INTO MAY ALREADY HAVE PASSED, and until this leg existed the
|
|
1704
|
-
// run had no way to find out. `round` above opens at the round AFTER the last EVAL — correct when
|
|
1705
|
-
// that EVAL returned FAIL, and the only reading available. But a pause at GATE L3, at QA or at
|
|
1706
|
-
// GATE H happens strictly AFTER `results/evaluate-r<N>.json` is written, so the relaunch opened
|
|
1707
|
-
// round N+1, asked the graph which scopes were green IN ROUND N+1, was told none, and re-dispatched
|
|
1708
|
-
// every scope through the attempt ratchet before running a second EVAL — over a verdict of PASS
|
|
1709
|
-
// sitting on disk. It cost a round of `maxRounds`, a full build fan-out, and a second judgement free
|
|
1710
|
-
// to return FAIL where the first passed. The PO answered a gate and the run went backwards.
|
|
1711
|
-
//
|
|
1712
|
-
// The verdict is an ARTIFACT, and `probe eval` reads it out of the WorkResult `reduce ingest` wrote
|
|
1713
|
-
// — the same probe, and deliberately not the dispatching agent's account of it, that L3 branches on
|
|
1714
|
-
// below. This is the fast-forward the planning phases have always had, applied to the one phase that
|
|
1715
|
-
// did not have it: a completed EVAL is skipped exactly like a completed ANALYZE.
|
|
1716
|
-
if (lastEval) {
|
|
1717
|
-
phase("Eval");
|
|
1718
|
-
const prior = await query(`probe eval --slug ${slug} --round ${lastEval}`, EVAL_VERDICT, "Eval", `ff:eval-r${lastEval}`);
|
|
1719
|
-
if (prior?.ok && prior.overall === "PASS") {
|
|
1720
|
-
verdict = "pass";
|
|
1721
|
-
if (Array.isArray(prior.dimensions) && prior.dimensions.length) gradedDims = prior.dimensions;
|
|
1722
|
-
round = lastEval;
|
|
1723
|
-
log(`EVAL — round ${lastEval} already returned PASS on disk, fast-forwarding past the build/eval loop`);
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
// `verdict !== "pass"` is the same exit the `break` below takes when a round passes — stated here as
|
|
1728
|
-
// well so a RESUMED run reaches it, since the break belongs to a loop iteration a relaunch never ran.
|
|
1729
|
-
while (verdict !== "pass" && round <= maxRounds) {
|
|
1730
|
-
phase("Build");
|
|
1731
|
-
await setRunStatus("building", "Build");
|
|
1732
|
-
|
|
1733
|
-
// The wall-clock breaker, opt-in, checked at each round boundary. Exit 6 is "tripped": route to
|
|
1734
|
-
// GATE H and ship what is green, never kill the run from outside.
|
|
1735
|
-
const budget = await cmd(`verify budget --slug ${slug} --strict`, "Build", `budget:r${round}`);
|
|
1736
|
-
if (budget.exit_code === 6) {
|
|
1737
|
-
await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
|
|
1738
|
-
return await settleAtGateH({ status: "gate_h", breaker: "deadline", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
log(`BUILD round ${round} — ${scopes.length} scope(s), up to ${maxParallelScopes} at once, attempt budget ${attemptBudget}`);
|
|
1742
|
-
const roundGreen = [], roundHammer = [];
|
|
1743
|
-
|
|
1744
|
-
// ONE bounded query opens the round, instead of one probe per scope. The graph is a projection of
|
|
1745
|
-
// the same verdict artifacts `probe t0` reads, so this is the identical fact asked once — which is
|
|
1746
|
-
// the difference between a read model and a directory walk.
|
|
1747
|
-
const g = await query(`reduce graph --slug ${slug} --subgraph run`, SUBGRAPH, "Build", `graph:r${round}`);
|
|
1748
|
-
const alreadyGreen = new Set(g?.green_scopes_by_round?.[String(round)] || []);
|
|
1749
|
-
if (alreadyGreen.size) log(`BUILD r${round} — ${alreadyGreen.size} scope(s) already green in the graph, skipping them`);
|
|
1750
|
-
|
|
1751
|
-
// REBUILD THE CENSUS FROM THE GRAPH THIS QUERY JUST RETURNED. Everything a prior launch learned is
|
|
1752
|
-
// already on disk: a scope green in ANY round is green work, and a scope the run has touched but
|
|
1753
|
-
// never got green is what GATE H has to be shown. One query, already made, re-read — so a relaunch
|
|
1754
|
-
// after a paused gate carries the same census the launch that paused it would have.
|
|
1755
|
-
const greenEver = new Set(Object.values(g?.green_scopes_by_round || {}).flat());
|
|
1756
|
-
for (const sid of greenEver) if (!allGreen.includes(sid)) allGreen.push(sid);
|
|
1757
|
-
for (const sid of (g?.scopes || [])) {
|
|
1758
|
-
if (!greenEver.has(sid) && !allHammer.includes(sid) && scopes.some((x) => x.scope_id === sid)) allHammer.push(sid);
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
// SCOPES FAN OUT. A scope contract is the definition of an independent subtask — disjoint
|
|
1762
|
-
// substrate, own fixtures, own ratchet — so the loop that ran them one at a time was leaving the
|
|
1763
|
-
// whole point of the contract on the floor. `pipeline()` has NO barrier between its stages: a
|
|
1764
|
-
// fast scope is being confirmed while a slow one is still on attempt 3. One pipeline per scope,
|
|
1765
|
-
// so that property is now GLOBAL rather than per group — nothing waits on a group boundary, and
|
|
1766
|
-
// the window refills the instant any leg settles.
|
|
1767
|
-
//
|
|
1768
|
-
// Three stages, because each answers a different question about the same scope:
|
|
1769
|
-
// check — is it already green on disk from a killed round? (resume, no re-work)
|
|
1770
|
-
// build — the attempt ratchet, inside the worker's own shell
|
|
1771
|
-
// confirm — MEASURED, NOT CLAIMED: the worker says green; the T0 artifact has to agree.
|
|
1772
|
-
// A green with no artifact on disk is a claim, and the evaluator that must cite that
|
|
1773
|
-
// artifact would find nothing.
|
|
1774
|
-
// NEVER RETURN `null` FROM A STAGE TO MEAN "carry on". The runtime reads a null stage result as
|
|
1775
|
-
// DROP THIS ITEM and skips its remaining stages — measured directly, not inferred:
|
|
1776
|
-
//
|
|
1777
|
-
// stage1 → null ⇒ stage2 ran 0/3 times, settled = [NULL, NULL, NULL]
|
|
1778
|
-
// stage1 → {…} ⇒ stage2 ran 3/3 times
|
|
1779
|
-
//
|
|
1780
|
-
// This stage used to return `null` for "not green yet — stage 2, please build it", which meant
|
|
1781
|
-
// every scope that was not ALREADY green was dropped before `buildScope` could run. On a fresh
|
|
1782
|
-
// run that is every scope, so the round ended with 0 green and 6 queued, the inner breaker tripped
|
|
1783
|
-
// and the run returned `gate_h` having dispatched no builder at all. BUILD could never dispatch;
|
|
1784
|
-
// the failure looked exactly like six genuinely hard scopes.
|
|
1785
|
-
const settled = await scheduleScopes(buildOrder, scopeReleases, maxParallelScopes, async (scope) => {
|
|
1786
|
-
const done = await pipeline(
|
|
1787
|
-
[scope],
|
|
1788
|
-
async (s) => (alreadyGreen.has(s.scope_id)
|
|
1789
|
-
? { scope_id: s.scope_id, green: true, resumed: true }
|
|
1790
|
-
: { scope_id: s.scope_id, pending: true }), // not green yet → stage 2 builds it
|
|
1791
|
-
async (pre, s) => (pre?.pending ? buildScope(s, round) : pre),
|
|
1792
|
-
async (res, s) => {
|
|
1793
|
-
if (!res) return res;
|
|
1794
|
-
// THE SINGLE WRITER IS ASKED FIRST, WHATEVER THE LEG SAID. This question used to sit behind
|
|
1795
|
-
// the green checks below, and the state it exists to catch — a result on disk that nothing
|
|
1796
|
-
// applied — was reachable only for a scope that was already fully green. Measured live: a
|
|
1797
|
-
// leg reported green with no T0 verdict at all, the T0 re-read correctly returned "not
|
|
1798
|
-
// green", the round returned before this line, and the result — six tasks, two discoveries
|
|
1799
|
-
// — was never read by anyone. A dead leg (`__failed`) can have left a result too. So every
|
|
1800
|
-
// settled scope is asked, and what it answers travels on the result to the close, where an
|
|
1801
|
-
// unapplied result is named rather than folded into "not green". Application stays gated
|
|
1802
|
-
// on the green checks: ingest ticks acceptance boxes, and a result T0 never measured must
|
|
1803
|
-
// not mark work green — the repair below is for a scope that IS green.
|
|
1804
|
-
const applied = await query(`probe leg --slug ${slug} --scope ${s.scope_id} --round ${round}`,
|
|
1805
|
-
LEGCHECK, "Build", `legcheck:${s.scope_id}-r${round}`);
|
|
1806
|
-
const unapplied = applied?.closed ? [] : (applied?.unapplied || []);
|
|
1807
|
-
if (res.__failed) return unapplied.length ? { ...res, unapplied } : res;
|
|
1808
|
-
if (!res.green) return unapplied.length ? { ...res, unapplied } : res;
|
|
1809
|
-
// THE T0 RE-READ IS SKIPPED FOR A RESUMED SCOPE; THE LEG CHECK BELOW IS NOT.
|
|
1810
|
-
//
|
|
1811
|
-
// `resumed` means the graph already reported this scope green for this round, so re-reading
|
|
1812
|
-
// its T0 artifact would only confirm what the resume derivation just read. But these two
|
|
1813
|
-
// stages answer DIFFERENT questions, and the second one is precisely the question a resumed
|
|
1814
|
-
// scope is most likely to fail: a relaunch happens because the previous launch DIED, and a
|
|
1815
|
-
// leg that died between writing its result and running `reduce ingest` leaves exactly this
|
|
1816
|
-
// state — green T0 on disk, result never applied, board still `pending`. The short-circuit
|
|
1817
|
-
// used to cover both stages, so the one scope class known to be at risk was the one class
|
|
1818
|
-
// nobody asked, and the late-ingest repair nine lines below could never fire for it.
|
|
1819
|
-
if (!res.resumed) {
|
|
1820
|
-
const confirmed = await query(`probe t0 --slug ${slug} --scope ${s.scope_id} --round ${round}`,
|
|
1821
|
-
T0CHECK, "Build", `t0confirm:${s.scope_id}-r${round}`);
|
|
1822
|
-
if (!confirmed?.green) {
|
|
1823
|
-
log(`BUILD r${round} — ${s.scope_id} reported green but no T0 verdict is on disk for this ` +
|
|
1824
|
-
`round; treating it as not green (the evaluator cites that artifact, and it is not there).`);
|
|
1825
|
-
return { ...res, green: false, reason: "reported green with no T0 verdict artifact on disk", ...(unapplied.length ? { unapplied } : {}) };
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
// AND ITS RESULT HAS TO HAVE REACHED THE BOARD. A green T0 says the worker's fixtures ran and
|
|
1829
|
-
// passed; it says nothing about whether the WorkResult was applied, and this stage used to ask
|
|
1830
|
-
// only the first question. Measured on a live run: a leg wrote its code, a green verdict, a
|
|
1831
|
-
// kept trial row and its WorkResult, then skipped step 3 of its own script. It reported green,
|
|
1832
|
-
// this stage re-verified the T0 artifact, agreed, and the round walked on with the scope's task
|
|
1833
|
-
// still `pending` and zero acceptance criteria ticked — the board GATE L2 reads as 100%
|
|
1834
|
-
// disagreeing with a scope that was genuinely finished.
|
|
1835
|
-
//
|
|
1836
|
-
// The evidence is the leg-completion row, because `reduce ingest` writes it: its presence
|
|
1837
|
-
// proves the writer ran, and it is not something the leg can assert about itself.
|
|
1838
|
-
for (const orderPath of unapplied) {
|
|
1839
|
-
// INGESTED HERE RATHER THAN FAILED. The result is on disk and valid — re-running the leg
|
|
1840
|
-
// would pay a whole attempt again for work already done. Only the single writer writes
|
|
1841
|
-
// shared state, and that writer is this command; which step invokes it is not the invariant.
|
|
1842
|
-
log(`BUILD r${round} — ${s.scope_id} finished without applying its own result. Ingesting it ` +
|
|
1843
|
-
`here: ${orderPath}. The leg skipped its ingest step, so the board did not see work that ` +
|
|
1844
|
-
`is on disk and T0-green.`);
|
|
1845
|
-
// QUOTED, and that is not cosmetic. The PreToolUse envelope gate scans the `Agent` prompt as
|
|
1846
|
-
// well as `Skill` args, so a bare order path interpolated into a sub-agent's prompt is read
|
|
1847
|
-
// together with the trailing quote of the surrounding literal and the whole dispatch is
|
|
1848
|
-
// denied — measured once already, on a path threaded through a prompt exactly like this one.
|
|
1849
|
-
await advisory(`reduce ingest --order "${orderPath}"`, "Build", `late-ingest:${s.scope_id}-r${round}`);
|
|
1850
|
-
}
|
|
1851
|
-
if (applied && !applied.closed && !(applied.unapplied || []).length && applied.results_total === 0) {
|
|
1852
|
-
// Green T0, no result envelope at all: the two records disagree about whether a leg ran, and
|
|
1853
|
-
// the round must not resolve that by preferring the one that says yes.
|
|
1854
|
-
log(`BUILD r${round} — ${s.scope_id} has a green T0 verdict and no WorkResult on disk for ` +
|
|
1855
|
-
`this round; treating it as not green.`);
|
|
1856
|
-
return { ...res, green: false, reason: "green T0 verdict with no WorkResult envelope" };
|
|
1857
|
-
}
|
|
1858
|
-
return res;
|
|
1859
|
-
},
|
|
1860
|
-
);
|
|
1861
|
-
return done[0];
|
|
1862
|
-
});
|
|
1863
|
-
|
|
1864
|
-
for (const [i, res] of settled.entries()) {
|
|
1865
|
-
const scopeId = buildOrder[i].scope_id;
|
|
1866
|
-
for (const p of (res?.unapplied || [])) {
|
|
1867
|
-
if (!allUnapplied.includes(p)) allUnapplied.push(p);
|
|
1868
|
-
log(`BUILD r${round} — ${scopeId} has a result on disk nothing applied: ${p}`);
|
|
1869
|
-
}
|
|
1870
|
-
// A dead builder is a SPENT ATTEMPT, not a dead run: the scope goes to GATE H's census and
|
|
1871
|
-
// the round continues. Killing the run here would discard every other scope's green work.
|
|
1872
|
-
if (!res || res.__failed) {
|
|
1873
|
-
log(`BUILD r${round} — ${scopeId} lost its worker: ${res?.__failed || "no result"}`);
|
|
1874
|
-
roundHammer.push(scopeId);
|
|
1875
|
-
} else if (res.green) {
|
|
1876
|
-
roundGreen.push(res.scope_id || scopeId);
|
|
1877
|
-
} else {
|
|
1878
|
-
roundHammer.push(res.scope_id || scopeId);
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
for (const sid of roundGreen) if (!allGreen.includes(sid)) allGreen.push(sid);
|
|
1883
|
-
// A scope that went green this round is no longer a cut candidate, however it was queued earlier.
|
|
1884
|
-
for (const sid of roundGreen) { const i = allHammer.indexOf(sid); if (i !== -1) allHammer.splice(i, 1); }
|
|
1885
|
-
for (const sid of roundHammer) if (!allHammer.includes(sid) && !allGreen.includes(sid)) allHammer.push(sid);
|
|
1886
|
-
|
|
1887
|
-
// NOTHING GREEN AND SOMETHING QUEUED → GATE H. This used to return the literal `inner` as its breaker, and the
|
|
1888
|
-
// protocol's INNER breaker is the per-scope attempt budget, which "queues a proposal, never blocks
|
|
1889
|
-
// the round" — so the close named a breaker the attested census flatly denied (one attempt spent
|
|
1890
|
-
// of five, `tripped: false`), and the operator was told a scope had exhausted its attempts after
|
|
1891
|
-
// it used one. The word is now earned: `probe attempts` — the one derivation built so the census
|
|
1892
|
-
// and the breaker cannot disagree — is asked for every queued scope, and the return names
|
|
1893
|
-
// `attempt_budget` only for the scopes it says tripped, `none` when the round simply stalled.
|
|
1894
|
-
if (roundGreen.length === 0 && roundHammer.length > 0) {
|
|
1895
|
-
const tripped = [];
|
|
1896
|
-
for (const sid of roundHammer) {
|
|
1897
|
-
const census = await query(`probe attempts --slug ${slug} --scope "${sid}" --round ${round} --attempt-budget ${attemptBudget}`,
|
|
1898
|
-
ATTEMPTS, "Build", `census:${sid}-r${round}`);
|
|
1899
|
-
if (census?.tripped) tripped.push(sid);
|
|
1900
|
-
}
|
|
1901
|
-
await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
|
|
1902
|
-
return await settleAtGateH({
|
|
1903
|
-
status: "gate_h", breaker: tripped.length ? "attempt_budget" : "none", stalled: "no_green",
|
|
1904
|
-
tripped_scopes: tripped, unapplied_results: allUnapplied,
|
|
1905
|
-
hammer_proposals: allHammer, green_scopes: allGreen,
|
|
1906
|
-
});
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
// ---- ROUND BUILD GATE — the feature builds and launches, measured before anyone is asked --------
|
|
1910
|
-
//
|
|
1911
|
-
// T0 is per scope and inside the scope's substrate; nothing in it proves the FEATURE compiles or
|
|
1912
|
-
// starts. Measured on a live mobile run: 30/30 T0 trials green on the first try (TypeScript
|
|
1913
|
-
// stand-ins, structural greps, a suite wrapper that never compiled its sources) while the
|
|
1914
|
-
// ledger's own `run_cmd` failed, and three rounds of EVAL then graded a blank screen because
|
|
1915
|
-
// nothing in the loop had ever installed or launched the app. `verify build` runs the ledger's
|
|
1916
|
-
// `run_cmd`, then the profile's `build_probe` and `launch_probe`, and writes one artifact
|
|
1917
|
-
// `reduce hill` and `harness compile` both read — so a red gate moves no dot downhill and becomes
|
|
1918
|
-
// the next round's bug list without this script carrying anything across the round boundary.
|
|
1919
|
-
//
|
|
1920
|
-
// Exit 3 is "nothing declared" — the honest state of a run whose L0 pinned no run command and
|
|
1921
|
-
// whose profile names no probe — and it is logged, not treated as green. Any other non-0/1 exit
|
|
1922
|
-
// means the gate itself did not run, and the run says so rather than inferring a verdict.
|
|
1923
|
-
const gate = await cmd(`verify build --slug ${slug} --round ${round}`, "Build", `build-gate:r${round}`);
|
|
1924
|
-
let buildGate = "green";
|
|
1925
|
-
if (gate.exit_code === 1) {
|
|
1926
|
-
buildGate = "red";
|
|
1927
|
-
log(`BUILD GATE r${round} — RED${gate.detail ? `: ${gate.detail}` : ""}. EVAL will not run over a feature ` +
|
|
1928
|
-
`that does not build or launch; the failing step is compiled into round ${round + 1}'s orders as bugs.`);
|
|
1929
|
-
} else if (gate.exit_code === 3) {
|
|
1930
|
-
buildGate = "undeclared";
|
|
1931
|
-
log(`BUILD GATE r${round} — nothing declared: no run_cmd in the run ledger and no build_probe or ` +
|
|
1932
|
-
`launch_probe in project-profile.md. EVAL runs over an unproven build; pin them at GATE L0.`);
|
|
1933
|
-
} else if (gate.exit_code !== 0) {
|
|
1934
|
-
buildGate = "unknown";
|
|
1935
|
-
log(`BUILD GATE r${round} — the gate itself did not run (exit ${gate.exit_code}` +
|
|
1936
|
-
`${gate.detail ? `: ${gate.detail}` : ""}). Treated as undeclared, not as green.`);
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// Trace-lint runs a SECOND time here, and this is the pass that can see anything. At L1b the
|
|
1940
|
-
// scopes' files do not exist yet, so per-scope reachability measures an empty set and its warning
|
|
1941
|
-
// cannot fire. After the round's build gate the code is on disk, which is exactly when "this
|
|
1942
|
-
// scope's fixture is green and none of its code is in the compiled set" becomes a checkable fact
|
|
1943
|
-
// — the shape that once left three scopes green while their files never compiled. Advisory, like
|
|
1944
|
-
// the L1b pass: it overwrites the same projection with a later and better-informed one.
|
|
1945
|
-
await advisory(`verify trace --slug ${slug} --quiet`, "Build", `trace-lint:r${round}`);
|
|
1946
|
-
await advisory(`reduce hill --slug ${slug}`, "Build", "hill-derive");
|
|
1947
|
-
{
|
|
1948
|
-
const g = await crossGate("L2", "Build", ["proceed", "ask", "abort"],
|
|
1949
|
-
{ round, green_scopes: roundGreen, hammer_proposals: roundHammer, build_gate: buildGate });
|
|
1950
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
// ---- EVAL — exactly one feature-level pass per round (the single-judge invariant) ------------
|
|
1954
|
-
phase("Eval");
|
|
1955
|
-
await setRunStatus("evaluating", "Eval");
|
|
1956
|
-
if (buildGate === "red") {
|
|
1957
|
-
// A red gate outranks --no-eval: skipping the judge is the operator's call, but the build failing
|
|
1958
|
-
// is a measured fact, and a round that does not compile has nothing for anyone to pass.
|
|
1959
|
-
verdict = "fail";
|
|
1960
|
-
findings = [];
|
|
1961
|
-
log(`EVAL r${round} — not dispatched: the round build gate is red. The judge grades a running ` +
|
|
1962
|
-
`feature; this one does not build or launch. Round ${round + 1} fixes the gate's failing step.`);
|
|
1963
|
-
} else if (args.noEval) {
|
|
1964
|
-
log("EVAL — skipped (--no-eval)");
|
|
1965
|
-
// references/protocol.md's own words for this, twice: a run --no-eval ships records
|
|
1966
|
-
// `not-evaluated` — "recorded plainly — never silently upgraded [to `pass`]". Nothing verified
|
|
1967
|
-
// the feature beyond task-executor's own per-AC evidence checks, and the report this run
|
|
1968
|
-
// freezes at GATE L4 has to say that as plainly as the gate block already does.
|
|
1969
|
-
verdict = "not-evaluated";
|
|
1970
|
-
} else {
|
|
1971
|
-
const { r: e, v: ev } = await sendBackOnce(
|
|
1972
|
-
(note) => worker({
|
|
1973
|
-
skill: "spec-evaluator", operation: "evaluate", schema: EVAL, phase: "Eval",
|
|
1974
|
-
label: note ? `eval:r${round}:again` : `eval:r${round}`, model: evalModel, round,
|
|
1975
|
-
// No `t0_artifacts` here, deliberately: `harness compile` derives them from the round's green
|
|
1976
|
-
// T0 verdicts on disk, for every lane — this script could only name paths it was told about.
|
|
1977
|
-
// The same goes for `build_gate` and `launch_cmd`: compile reads them off the round's gate
|
|
1978
|
-
// artifact and the project profile, so the judge gets the launch evidence without this script
|
|
1979
|
-
// having to carry it.
|
|
1980
|
-
payload: { ...(evalDims ? { dimensions: evalDims } : {}), run_cmd: rs.run_cmd, round },
|
|
1981
|
-
extra: "Evaluate the running feature against every acceptance criterion and Done-when. One feature-level pass; cite every artifact the order lists under t0_artifacts, re-hashing each yourself." + note,
|
|
1982
|
-
}),
|
|
1983
|
-
// The pass/fail branch is decided from the WorkResult on disk, not from the dispatching
|
|
1984
|
-
// agent's own summary of it (`e.overall`) — see EVAL_VERDICT's comment for why.
|
|
1985
|
-
(suffix) => query(`probe eval --slug ${slug} --round ${round}`, EVAL_VERDICT, "Eval", `verdict:r${round}${suffix}`),
|
|
1986
|
-
// A verdict the kernel refused — grouped rows, a missing citation — is correctable, not a dead judge.
|
|
1987
|
-
(v) => !v.ok && !!v.overall && !!v.reason,
|
|
1988
|
-
`EVAL r${round}`,
|
|
1989
|
-
);
|
|
1990
|
-
if (e.__failed) return await withWarnings(diedAt("L3", e));
|
|
1991
|
-
if (!ev) return await withWarnings(diedAt("L3", nullFail(`verdict:r${round}`)));
|
|
1992
|
-
// A round with no verdict to act on is NOT a dead worker. An evaluator that refused the round
|
|
1993
|
-
// wrote a result saying why, and `probe eval` carries it as `reason`; reported as "died after
|
|
1994
|
-
// retries", the one sentence naming the cause stayed in a file nobody was pointed at.
|
|
1995
|
-
if (!ev.ok || !ev.overall) {
|
|
1996
|
-
return await withWarnings(diedAt("L3", { __failed: `verdict:r${round}: no verdict this round can act on — ${ev.reason || `status ${ev.status || "unknown"}`}` }));
|
|
1997
|
-
}
|
|
1998
|
-
verdict = ev.overall === "PASS" ? "pass" : "fail";
|
|
1999
|
-
if (Array.isArray(ev.dimensions) && ev.dimensions.length) gradedDims = ev.dimensions;
|
|
2000
|
-
findings = e.findings || [];
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
// ---- REFUTE WAVE (opt-in) — one independent skeptic per FAIL finding, BEFORE it costs a whole
|
|
2004
|
-
// fix round. Perspective-diverse by prompt: the refuter is told to try to refute and to default to
|
|
2005
|
-
// "real" when it cannot. A pure addition; off unless args.adversarialVerify.
|
|
2006
|
-
if (verdict === "fail" && args.adversarialVerify && findings.length) {
|
|
2007
|
-
phase("Refute");
|
|
2008
|
-
const checks = await parallel(findings.map((f) => () =>
|
|
2009
|
-
agent(
|
|
2010
|
-
`A single judge marked this acceptance criterion FAILED for slug "${slug}":\n` +
|
|
2011
|
-
` criterion: ${f.criterion}\n evidence: ${f.evidence}\n\n` +
|
|
2012
|
-
`Independently try to REFUTE the failure by exercising the running feature yourself. ` +
|
|
2013
|
-
`Return refuted=true ONLY if you can show it actually passes; otherwise refuted=false.`,
|
|
2014
|
-
{ model: evalModel, phase: "Refute", label: `refute:${f.id}`, schema: REFUTATION },
|
|
2015
|
-
)));
|
|
2016
|
-
const overturned = new Set(checks.filter(Boolean).filter((c) => c.refuted).map((c) => c.id));
|
|
2017
|
-
if (overturned.size) log(`REFUTE — ${overturned.size}/${findings.length} FAIL finding(s) overturned by an independent skeptic`);
|
|
2018
|
-
if (overturned.size === findings.length) {
|
|
2019
|
-
verdict = "pass";
|
|
2020
|
-
log("REFUTE — every finding was overturned; treating this round as PASS");
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
await advisory(`reduce graph --slug ${slug}`, "Eval", `graph:eval-r${round}`);
|
|
2025
|
-
await advisory(`reduce hill --slug ${slug}`, "Eval", "hill-derive");
|
|
2026
|
-
const g3 = await crossGate("L3", "Eval", ["loop", "stop", "ask"], { round, verdict, build_gate: buildGate });
|
|
2027
|
-
if (g3.stop) return await withWarnings(g3.stop);
|
|
2028
|
-
|
|
2029
|
-
// "not-evaluated" (--no-eval) ships exactly like "pass" — protocol.md: the run "goes straight to
|
|
2030
|
-
// SHIP" once EVAL is skipped, never spends another round waiting on a verdict nobody is producing.
|
|
2031
|
-
if (verdict === "pass" || verdict === "not-evaluated") break; // → QA → GATE H → ship
|
|
2032
|
-
if (g3.decision === "stop" || round >= maxRounds) {
|
|
2033
|
-
return await settleAtGateH({ status: "gate_h", breaker: "outer", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
|
|
2034
|
-
}
|
|
2035
|
-
round += 1;
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
if (verdict !== "pass" && verdict !== "not-evaluated") {
|
|
2039
|
-
return await settleAtGateH({ status: "gate_h", breaker: "outer", unapplied_results: allUnapplied, hammer_proposals: allHammer, green_scopes: allGreen });
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
// ---- QA (post-PASS, pre-ship) — a level-up, never a gate. `--no-qa` answers it "skip". --------
|
|
2043
|
-
phase("QA");
|
|
2044
|
-
let qaFindings = 0;
|
|
2045
|
-
const qaG = await crossGate("QA", "QA", ["run", "skip", "ask"], { round, verdict });
|
|
2046
|
-
if (qaG.stop) return await withWarnings(qaG.stop);
|
|
2047
|
-
const qaRan = !args.noQa && qaG.decision === "run";
|
|
2048
|
-
// What QA amounted to, from the hunt's own record: `run` only when a charter ran. The QA gate's `run`
|
|
2049
|
-
// is the decision to dispatch, and a dispatched hunt that drove nothing is `not-hunted`.
|
|
2050
|
-
let qaState = "skipped";
|
|
2051
|
-
if (qaRan) {
|
|
2052
|
-
const { r: q, v: hv } = await sendBackOnce(
|
|
2053
|
-
(note) => worker({
|
|
2054
|
-
skill: "qa-edge-hunter", operation: "hunt", schema: QA_REPORT, phase: "QA", label: note ? "hunt:again" : "hunt", model: qaModel,
|
|
2055
|
-
payload: { feature: slug, spec_folder: specFolder, app_url: rs.app_url, round },
|
|
2056
|
-
extra: "Exploratory hunt over the shipped feature. No verdict and no score — findings only, each with a repro." + note,
|
|
2057
|
-
}),
|
|
2058
|
-
(suffix) => query(`probe hunt --slug ${slug}`, HUNT_OUTCOME, "QA", `hunt-outcome${suffix}`),
|
|
2059
|
-
(v) => !v.ok && v.status === "done" && !!v.reason,
|
|
2060
|
-
"QA hunt",
|
|
2061
|
-
);
|
|
2062
|
-
// QA is a level-up: losing its worker costs the findings, not the run.
|
|
2063
|
-
if (q.__failed) log(`QA — the hunt lost its worker: ${q.__failed}. Shipping without QA findings.`);
|
|
2064
|
-
else {
|
|
2065
|
-
qaFindings = hv && Number.isInteger(hv.findings) ? hv.findings : q.findings_count;
|
|
2066
|
-
qaState = hv?.qa === "run" ? "run" : "not-hunted";
|
|
2067
|
-
}
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
|
-
// ---- GATE H — delegated to scope-hammer (census, baseline comparison, cut list) ----------------
|
|
2071
|
-
phase("Ship");
|
|
2072
|
-
const h = await worker({
|
|
2073
|
-
skill: "scope-hammer", operation: "hammer", schema: HAMMER, phase: "Ship", label: "hammer",
|
|
2074
|
-
payload: { feature: slug, qa_findings: qaFindings, hammer_proposals: allHammer },
|
|
2075
|
-
extra: HAMMER_EXTRA,
|
|
2076
|
-
});
|
|
2077
|
-
if (h.__failed) return await withWarnings(diedAt("H", h));
|
|
2078
|
-
if (h.verdict === "cannot-ship") {
|
|
2079
|
-
return await withWarnings(aborted("H", `scope-hammer: CANNOT SHIP — ${h.cut_list.join(", ") || "a must-have failed"}`));
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
{
|
|
2083
|
-
const g = await crossGate("H", "Ship", ["accept-cut-list", "ship-all", "ask"], { verdict: h.verdict, cut_list: h.cut_list });
|
|
2084
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
// The frozen report's own verdict line has to say what actually happened — a `--no-eval` run
|
|
2088
|
-
// verified nothing beyond task-executor's per-AC checks, and `reduce ship` already accepts
|
|
2089
|
-
// "not-evaluated" as a real verdict (its own usage string, and `generate()`'s no-artifact
|
|
2090
|
-
// default). Hardcoding PASS here is exactly the silent upgrade protocol.md's Rules forbid.
|
|
2091
|
-
const shipVerdict = verdict === "not-evaluated" ? "not-evaluated" : "PASS";
|
|
2092
|
-
const ship = await cmd(`reduce ship --slug ${slug} --verdict ${shipVerdict} --qa ${qaState}`, "Ship", "ship-report");
|
|
2093
|
-
// GATE L4 HAS A CALL SITE. It used to be a line of prose in the tech lead's references — "resolve
|
|
2094
|
-
// the gate itself before any of the above" — and a run reached its ship decision with no ledger row
|
|
2095
|
-
// for it. The resolver narrows `ship` on the census artifact the hammer just wrote.
|
|
2096
|
-
{
|
|
2097
|
-
const g = await crossGate("L4", "Ship", ["ship", "hold", "ask"], { verdict: shipVerdict, census: h.verdict, cut_list: h.cut_list });
|
|
2098
|
-
if (g.stop) return await withWarnings(g.stop);
|
|
2099
|
-
if (g.decision === "hold") return await withWarnings({ status: "aborted", aborted_at: "L4", reason: `GATE L4 answered "hold" (census ${h.verdict}, verdict ${shipVerdict})` });
|
|
2100
|
-
}
|
|
2101
|
-
await advisory(`report export --slug ${slug}`, "Ship", "export-run");
|
|
2102
|
-
// The run's own concurrency, printed once where the records are complete and before the next run
|
|
2103
|
-
// supersedes the trace. It is a projection over `receipts/dispatch.jsonl` and `legs.jsonl`, so it
|
|
2104
|
-
// asserts nothing and gates nothing — and when those records cannot support a figure it says so
|
|
2105
|
-
// rather than printing a plausible one.
|
|
2106
|
-
await advisory(`probe concurrency --slug ${slug} --format table`, "Ship", "concurrency");
|
|
2107
|
-
await setRunStatus("shipped", "Ship");
|
|
2108
|
-
|
|
2109
|
-
// The dimensions this run did NOT evaluate, so GATE L4 can say what "shipped" does not cover.
|
|
2110
|
-
const ALL_DIMS = ["spec-conformance", "tdd-surface", "integration", "completeness",
|
|
2111
|
-
"test-surface-conformance", "security", "performance"];
|
|
2112
|
-
|
|
2113
|
-
return await withWarnings({
|
|
2114
|
-
status: "shipped",
|
|
2115
|
-
// The real verdict this run reached — "pass" or, over a --no-eval run, "not-evaluated". GATE L4's
|
|
2116
|
-
// own sign-off block reads this field verbatim (SKILL.md Step 4); hardcoding "pass" here told a
|
|
2117
|
-
// human answering that gate the run was graded when it never was.
|
|
2118
|
-
verdict,
|
|
2119
|
-
rounds_used: round,
|
|
2120
|
-
dims_evaluated: gradedDims,
|
|
2121
|
-
dims_not_evaluated: ALL_DIMS.filter((d) => !gradedDims.includes(d)),
|
|
2122
|
-
qa_findings: qaFindings,
|
|
2123
|
-
qa: qaState,
|
|
2124
|
-
report: REPORT_PATH,
|
|
2125
|
-
});
|
|
2126
|
-
|
|
2127
|
-
// =============================================================================================
|
|
2128
|
-
// buildScope — one scope's full attempt ratchet, as a single worker leg.
|
|
2129
|
-
//
|
|
2130
|
-
// The ratchet lives inside the worker's own shell rather than in this control script because each
|
|
2131
|
-
// attempt is implement → `harness verify t0`, and both halves need a real filesystem and a real
|
|
2132
|
-
// git. The worker reports only the outcome the round loop branches on.
|
|
2133
|
-
// =============================================================================================
|
|
2134
|
-
async function buildScope(scope, roundNo) {
|
|
2135
|
-
// THE VERDICT'S BUGS REACH THIS LEG THROUGH ITS ORDER, NOT THROUGH THIS FUNCTION.
|
|
2136
|
-
//
|
|
2137
|
-
// `WorkOrderPayload.bugs` is "the EVAL report's bug entries for this task — touch nothing else",
|
|
2138
|
-
// and AGENTS.md states the regression rule as "bugs + full Test Surface of touched UC". Nothing
|
|
2139
|
-
// populated it, so a round r+1 leg was dispatched with no idea the judge had cited anything: it
|
|
2140
|
-
// re-ran T0, found the scope still green, and reported done. Measured — EVAL returned FAIL naming
|
|
2141
|
-
// five defects at file:line; round 2 kept all six trees and changed none of them, and every bug
|
|
2142
|
-
// re-probed identically.
|
|
2143
|
-
//
|
|
2144
|
-
// Threading them from here was tried first and cannot work, twice over: a build order overrides
|
|
2145
|
-
// the compile line (below), and only the GENERIC line serialises `--payload`, so the field was
|
|
2146
|
-
// built and dropped; and the value lived in a variable, which a relaunch between two rounds
|
|
2147
|
-
// resets to empty. `harness compile` reads them off the ledgered verdict instead — see
|
|
2148
|
-
// `verdictBugs` — so a fresh round and a resumed one take the identical path.
|
|
2149
|
-
//
|
|
2150
|
-
// `digested_errors` could not cover this: it carries AEGIS triples from a RED T0, and a scope
|
|
2151
|
-
// whose fixtures pass while its behaviour contradicts the spec has no red trial to digest. That
|
|
2152
|
-
// gap is why EVAL is a separate layer, so the channel out of it has to be separate too.
|
|
2153
|
-
return worker({
|
|
2154
|
-
// No `operation` either, and for the same reason as the payload: the compile override below
|
|
2155
|
-
// replaces the whole command, so a declared operation here would be discarded — and it would
|
|
2156
|
-
// now be WRONG as well as dead. `harness compile` derives it: a round carrying cited defects
|
|
2157
|
-
// compiles as `fix`, which is the operation task-executor's own contract binds `payload.bugs`
|
|
2158
|
-
// to, and a round with nothing cited stays `execute`.
|
|
2159
|
-
skill: "task-executor", schema: SCOPE_RESULT, phase: "Build",
|
|
2160
|
-
label: `build:${scope.scope_id}-r${roundNo}`,
|
|
2161
|
-
// A build order is addressed by scope + round + attempt, never by operation: the attempt number
|
|
2162
|
-
// is part of its identity, so there is one order per attempt and the generic slug form cannot
|
|
2163
|
-
// express it. NOTE this override is why no `payload` is passed — it would be discarded.
|
|
2164
|
-
compile: `compile --scope "${scope.path}" --round ${roundNo} --attempt 1`,
|
|
2165
|
-
extra:
|
|
2166
|
-
(roundNo > 1
|
|
2167
|
-
? `THIS MAY BE A FIX ROUND. If your compiled order carries \`payload.bugs\`, the evaluator ` +
|
|
2168
|
-
`returned FAIL last round and those are the defects it cited against files you own — each ` +
|
|
2169
|
-
`with the criterion it broke and a file:line. Fix exactly those and touch nothing else. ` +
|
|
2170
|
-
`They are spec-conformance defects, so T0 already passes and will keep passing whether or ` +
|
|
2171
|
-
`not you fix them: a green T0 is NOT evidence you are done this round, and re-running the ` +
|
|
2172
|
-
`fixtures cannot tell you. Read the cited lines against the committed spec, change them, ` +
|
|
2173
|
-
`and keep T0 green. An entry marked \`unowned\` cites no file any scope owns — fix it only ` +
|
|
2174
|
-
`if it falls inside your substrate. `
|
|
2175
|
-
: "") +
|
|
2176
|
-
`Re-compile the order for every attempt after the first, with --attempt <n>. An attempt will ` +
|
|
2177
|
-
`be REFUSED (exit 3) while the previous one is unanswered — dispatched with no leg row and no ` +
|
|
2178
|
-
`WorkResult — because grading a tree the previous attempt may still be writing counts an ` +
|
|
2179
|
-
`attempt nobody ran. Let it come back rather than opening the next one. ` +
|
|
2180
|
-
`Run the attempt ratchet for THIS scope only: up to ${attemptBudget} attempts of implement → ` +
|
|
2181
|
-
`\`node "${KERNEL}" verify t0 "${scope.path}" --round ${roundNo} --attempt <n>\`, each scored against ` +
|
|
2182
|
-
`the last kept trial. Stop on the first green T0, or when the attempt budget or the stagnation ` +
|
|
2183
|
-
`breaker trips. Write only inside this scope's substrate whitelist — the sandbox hook enforces it. ` +
|
|
2184
|
-
`Report green, attempts_used, which breaker (if any) tripped, and the T0 artifact path — ` +
|
|
2185
|
-
`attempts_used is what the attested channels carry, not a count of the orders you compiled.`,
|
|
2186
|
-
});
|
|
2187
|
-
}
|