faberun 0.25.0 → 0.26.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/package.json +1 -1
- package/skills/faberun/references/contract.md +34 -28
- package/src/campaign/campaign-brief-graph.mjs +218 -0
- package/src/campaign/campaign-brief.mjs +15 -167
- package/src/campaign/metrics-command.mjs +3 -1
- package/src/cli/brand.mjs +1 -0
- package/src/cli/plan.mjs +75 -3
- package/src/cli/setup.mjs +22 -5
- package/src/cli.mjs +35 -1
- package/src/contract/definition-of-done.mjs +48 -8
- package/src/contract/human-step.mjs +54 -0
- package/src/contract/index.mjs +25 -38
- package/src/contract/judge-independence.mjs +170 -0
- package/src/contract/judges.mjs +27 -0
- package/src/contract/provider.mjs +103 -0
- package/src/contract/runtime.mjs +19 -2
- package/src/contract/snapshot.mjs +31 -1
- package/src/contract/task-packet.mjs +4 -1
- package/src/contract/worker-result.mjs +35 -4
- package/src/engine/assignment.mjs +41 -5
- package/src/engine/backoff.mjs +30 -9
- package/src/engine/dispatch.mjs +19 -2
- package/src/engine/failover.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -4
- package/src/engine/judge-list.mjs +206 -0
- package/src/engine/lifecycle.mjs +29 -24
- package/src/engine/resume.mjs +49 -12
- package/src/engine/runtime-discovery.mjs +72 -11
- package/src/engine/scheduler.mjs +27 -6
- package/src/harnesses/catalogue.mjs +9 -2
- package/src/harnesses/index.mjs +2 -2
- package/src/harnesses/model-tiers.mjs +39 -0
- package/src/harnesses/protocol.mjs +4 -1
- package/src/host/config.mjs +7 -2
- package/src/plan/contest.mjs +78 -0
- package/src/plan/freeze.mjs +118 -7
- package/src/plan/human-step.mjs +38 -0
- package/src/plan/pipeline.mjs +224 -313
- package/src/plan/preflight.mjs +5 -3
- package/src/plan/proof-check.mjs +161 -0
- package/src/plan/proof-run.mjs +6 -2
- package/src/plan/proof-scope.mjs +175 -0
- package/src/plan/resolve.mjs +182 -0
- package/src/plan/reviewer.mjs +82 -0
- package/src/plan/rounds.mjs +446 -0
- package/src/plan/routing.mjs +11 -4
- package/src/plan/template.mjs +36 -14
- package/src/repo/signal-block.mjs +10 -3
- package/src/repo/source-identity.mjs +45 -5
- package/src/repo/worktree-prune.mjs +72 -0
- package/src/report/campaign-brief-estimate.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/metrics-report.mjs +34 -1
- package/src/report/render.mjs +31 -10
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planning pipeline's review/revise rounds: draft a plan once, then
|
|
3
|
+
* alternate review and revise up to `reviewRounds` times until no critical
|
|
4
|
+
* finding remains, the round budget runs out, or a round's own revise did not
|
|
5
|
+
* lower the critical count review found the round before it while leaving one
|
|
6
|
+
* of that round's criticals standing (R14) — every one
|
|
7
|
+
* of those ends contested, through the round that reached it. Separate from
|
|
8
|
+
* `pipeline.mjs` (measured 2026-09-24:
|
|
9
|
+
* pipeline.mjs was 742 of 800 lines) because this is the one place a round's
|
|
10
|
+
* finding bookkeeping — merge what is still open, drop what a revise
|
|
11
|
+
* silently shrank, resolve what a revise touched — happens together with the
|
|
12
|
+
* in-round freeze pre-flight. `droppedWriteFindings` and `unresolvedFindings`
|
|
13
|
+
* live here rather than in `pipeline.mjs` because nothing outside this loop
|
|
14
|
+
* ever calls them. `runStage`, `assembleFrozenNodes`, `frozenContractRaw`,
|
|
15
|
+
* `contest`, `invalidPlanFinding` and `logStage` are supplied by the caller
|
|
16
|
+
* rather than imported, because `invalidPlanFinding` is also raised outside
|
|
17
|
+
* any round (the draft validation and the freeze catch in `pipeline.mjs`)
|
|
18
|
+
* and the rest close over pipeline state (`plansDir`, `runtimes`, the
|
|
19
|
+
* campaign) this module has no reason to hold; injecting them keeps this
|
|
20
|
+
* module free of a runtime dependency on `pipeline.mjs`, so a test can drive
|
|
21
|
+
* it with fakes.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { join, relative } from "node:path";
|
|
25
|
+
import { validateContract } from "../contract/index.mjs";
|
|
26
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
27
|
+
import { stableJson } from "../util.mjs";
|
|
28
|
+
import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
|
|
29
|
+
import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
|
|
30
|
+
import { validateFindings, validatePlanOutput } from "./template.mjs";
|
|
31
|
+
|
|
32
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
33
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
34
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
35
|
+
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
36
|
+
/** @typedef {import("./template.mjs").PlanningKind} PlanningKind */
|
|
37
|
+
/** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
|
|
38
|
+
/** @typedef {import("./pipeline.mjs").AssembledPlan} AssembledPlan */
|
|
39
|
+
/** @typedef {(kind: PlanningKind, inputs: Record<string, unknown>) => Promise<{contract: ValidatedContract, output: Record<string, unknown>}>} RunStageFn */
|
|
40
|
+
/** @typedef {(round: number, findings: PlanFindingOutput[], plan: PlanOutput|null) => Promise<ContestedPipelineResult>} ContestFn */
|
|
41
|
+
/** @typedef {(label: string, error: unknown) => PlanFindingOutput} InvalidPlanFindingFn */
|
|
42
|
+
/** @typedef {{resolved: true, plan: PlanOutput|null, findings: PlanFindingOutput[], roundsRun: number} | {resolved: false, result: ContestedPipelineResult}} RoundsResult */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `incoming` merged over `carried`, keyed by finding id. A finding both
|
|
46
|
+
* rounds raise is the newer reviewer's own re-judgement of the same
|
|
47
|
+
* objection, severity included, so it replaces the carried copy instead of
|
|
48
|
+
* duplicating it — this merge never rewrites a severity of its own. Carried
|
|
49
|
+
* findings keep their order and come first, so the oldest outstanding
|
|
50
|
+
* objection is at the top of the file the reviser reads.
|
|
51
|
+
*
|
|
52
|
+
* @param {PlanFindingOutput[]} carried
|
|
53
|
+
* @param {PlanFindingOutput[]} incoming
|
|
54
|
+
* @returns {PlanFindingOutput[]}
|
|
55
|
+
*/
|
|
56
|
+
function mergeFindings(carried, incoming) {
|
|
57
|
+
const merged = new Map(carried.map((finding) => [finding.id, finding]));
|
|
58
|
+
for (const finding of incoming) merged.set(finding.id, finding);
|
|
59
|
+
return [...merged.values()];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The findings still open against the plan a revise produced. A review round
|
|
64
|
+
* is under no obligation to repeat what the last one found, so replacing the
|
|
65
|
+
* finding set each round silently drops any objection the new reviewer is
|
|
66
|
+
* quiet about: a spike run froze a plan carrying a defect round 1 had named
|
|
67
|
+
* and round 2 did not repeat, and the worker refused the packet with
|
|
68
|
+
* context_missing.
|
|
69
|
+
*
|
|
70
|
+
* What counts as resolved is read off the two plans, never off the reviewer's
|
|
71
|
+
* silence. A finding whose node the revise removed is moot. A finding whose
|
|
72
|
+
* node the revise changed at all was acted on — the next review grades the
|
|
73
|
+
* changed node and can object again in its own words. A finding against a
|
|
74
|
+
* node the revise left identical was not addressed, and stays open. That rule
|
|
75
|
+
* is also what keeps a carried finding from making convergence impossible:
|
|
76
|
+
* every one of them clears the moment the reviser touches the node it names,
|
|
77
|
+
* so a plan that answers its objections still freezes inside the round
|
|
78
|
+
* budget, and a plan that does not still ends contested at it. A finding
|
|
79
|
+
* naming no node of the plan — the pipeline's own `nodeId: "plan"` shape and
|
|
80
|
+
* freeze failures — is re-derived from scratch by the next round's pre-flight,
|
|
81
|
+
* so carrying it would double it.
|
|
82
|
+
*
|
|
83
|
+
* @param {PlanFindingOutput[]} findings everything open at the end of the round
|
|
84
|
+
* @param {PlanOutput|null} previousPlan the plan the revise revised
|
|
85
|
+
* @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
|
|
86
|
+
* @returns {PlanFindingOutput[]}
|
|
87
|
+
*/
|
|
88
|
+
export function unresolvedFindings(findings, previousPlan, revisedPlan) {
|
|
89
|
+
// No revised plan to measure against: the refused-output finding drives the
|
|
90
|
+
// next round and everything raised so far is still outstanding.
|
|
91
|
+
if (!revisedPlan) return findings;
|
|
92
|
+
const before = new Map((previousPlan?.nodes ?? []).map((node) => [node.id, stableJson(node)]));
|
|
93
|
+
const after = new Map(revisedPlan.nodes.map((node) => [node.id, stableJson(node)]));
|
|
94
|
+
return findings.filter((finding) => after.has(finding.nodeId) && before.get(finding.nodeId) === after.get(finding.nodeId));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The write files the plan going into a revise declared that the revised plan
|
|
99
|
+
* no longer declares, one finding per (node id, path). This is the check the
|
|
100
|
+
* scope-closure validator cannot make: a shrink satisfies closure without a
|
|
101
|
+
* judgement about any importer, so the cheap move needs a comparator of its
|
|
102
|
+
* own. Severity is critical — a drop is not automatically wrong, but it is
|
|
103
|
+
* always worth a second look, and only a critical finding reaches the round
|
|
104
|
+
* loop's revise-or-contest decision; major would ride along in the findings
|
|
105
|
+
* file while the plan froze. Nodes are matched by id alone: a node the
|
|
106
|
+
* revision renamed or removed entirely is out of scope, because tracking
|
|
107
|
+
* identity across a rename is a judgement about the graph this check does
|
|
108
|
+
* not make — a removed node's writes were reviewed as a removal, not as a
|
|
109
|
+
* silent shrink.
|
|
110
|
+
*
|
|
111
|
+
* Membership is judged against the whole revised plan, not against the node
|
|
112
|
+
* that used to hold the path. A revise that splits one node in two and hands
|
|
113
|
+
* a file to the new sibling has not dropped that file: it is still declared,
|
|
114
|
+
* still reviewable, and the graph change is visible in the plan. Measured
|
|
115
|
+
* 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
|
|
116
|
+
* exactly that move a critical and contested a sound plan — the draft's only
|
|
117
|
+
* node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
|
|
118
|
+
* layered them into worktree-preserve-ref-verb and
|
|
119
|
+
* cancel-preserves-integrated-heads, which is the decomposition this
|
|
120
|
+
* repository's own layering asks for.
|
|
121
|
+
*
|
|
122
|
+
* Only a path that exists in the repository can be dropped. A file the plan
|
|
123
|
+
* itself invented and the revise then renamed is a rename, not a lost write:
|
|
124
|
+
* no importer, test or reader depends on a name nothing has created yet.
|
|
125
|
+
* Measured 2026-09-25 on the 3a gate rerun: two of six drops were exactly
|
|
126
|
+
* that (env-guard.mjs renamed env-declaration.mjs, a probe-results note moved
|
|
127
|
+
* to the ledger), and each counted as a critical against a revise that had
|
|
128
|
+
* resolved every critical its review raised.
|
|
129
|
+
*
|
|
130
|
+
* @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
|
|
131
|
+
* @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
|
|
132
|
+
* @param {string} cwd the repository the plan writes into
|
|
133
|
+
* @returns {PlanFindingOutput[]}
|
|
134
|
+
*/
|
|
135
|
+
export function droppedWriteFindings(previousPlan, revisedPlan, cwd) {
|
|
136
|
+
if (!previousPlan || !revisedPlan) return [];
|
|
137
|
+
const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
|
|
138
|
+
const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
|
|
139
|
+
/** @type {PlanFindingOutput[]} */
|
|
140
|
+
const findings = [];
|
|
141
|
+
for (const node of revisedPlan.nodes) {
|
|
142
|
+
const previousWrites = before.get(node.id);
|
|
143
|
+
if (!previousWrites) continue;
|
|
144
|
+
let dropped = 0;
|
|
145
|
+
for (const path of previousWrites) {
|
|
146
|
+
if (stillDeclared.has(path) || !existsSync(join(cwd, path))) continue;
|
|
147
|
+
dropped += 1;
|
|
148
|
+
findings.push({
|
|
149
|
+
id: `dropped-write-${node.id}-${dropped}`,
|
|
150
|
+
severity: "critical",
|
|
151
|
+
nodeId: node.id,
|
|
152
|
+
text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare, and no other node in the revised plan declares it either. Declare it again on whichever node owns the work: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work. Moving the file to another node is a resolution; removing it from the plan is not.`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return findings;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The finding a round's break condition raises when a revise did not lower
|
|
161
|
+
* the critical count the reviewer found: `round`'s own count sits at or above
|
|
162
|
+
* `history[history.length - 2]`'s, the round right before it. Carries the
|
|
163
|
+
* whole per-round history rather than just the two counts being compared, so
|
|
164
|
+
* the contested record and the campaign journal note it drives both show the
|
|
165
|
+
* shape of the whole attempt, not just the round that finally gave up on it.
|
|
166
|
+
*
|
|
167
|
+
* @param {number} round
|
|
168
|
+
* @param {number[]} history critical counts, one per round measured so far, this round's last
|
|
169
|
+
* @returns {PlanFindingOutput}
|
|
170
|
+
*/
|
|
171
|
+
function revisionNotConvergingFinding(round, history) {
|
|
172
|
+
const current = history[history.length - 1];
|
|
173
|
+
const previous = history[history.length - 2];
|
|
174
|
+
return {
|
|
175
|
+
id: `revision-not-converging-r${round}`,
|
|
176
|
+
severity: "critical",
|
|
177
|
+
nodeId: "plan",
|
|
178
|
+
text: `Round ${round} review still finds ${current} critical finding(s), no fewer than round ${round - 1}'s ${previous}. Critical count by round: ${history.join(", ")}. The revise is not resolving what review keeps objecting to, so the pipeline stops here instead of spending the round(s) still in budget.`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run the review/revise rounds against a draft plan, in place of the pipeline
|
|
184
|
+
* running them itself. Returns `resolved: true` with the plan and findings a
|
|
185
|
+
* round's break (no critical finding left) or an exhausted `reviewRounds`
|
|
186
|
+
* without a review ever running (the draft never validated) leaves behind, or
|
|
187
|
+
* `resolved: false` with the `contest` result the round that hit the budget,
|
|
188
|
+
* failed to converge (R14), or lost both of a round's revise attempts to
|
|
189
|
+
* `validatePlanOutput` produced — `pipeline.mjs` returns that result as its
|
|
190
|
+
* own.
|
|
191
|
+
*
|
|
192
|
+
* @param {{
|
|
193
|
+
* reviewRounds: number,
|
|
194
|
+
* plan: PlanOutput|null,
|
|
195
|
+
* rejectedDraft?: unknown,
|
|
196
|
+
* findings: PlanFindingOutput[],
|
|
197
|
+
* cwd: string,
|
|
198
|
+
* plansDir: string,
|
|
199
|
+
* scratchDir: string,
|
|
200
|
+
* workingPlanPath: string,
|
|
201
|
+
* relativeWorkingPlanPath: string,
|
|
202
|
+
* relativeSpecPath: string,
|
|
203
|
+
* relativeRepoFactsPath: string,
|
|
204
|
+
* relativeCataloguePath: string,
|
|
205
|
+
* packageMode: import("./sizing.mjs").PackageMode,
|
|
206
|
+
* repoFacts: import("./repo-facts.mjs").RepoFacts,
|
|
207
|
+
* runStage: RunStageFn,
|
|
208
|
+
* assembleFrozenNodes: (plan: PlanOutput) => AssembledPlan,
|
|
209
|
+
* frozenContractRaw: (assembly: AssembledPlan) => JsonObject,
|
|
210
|
+
* contest: ContestFn,
|
|
211
|
+
* invalidPlanFinding: InvalidPlanFindingFn,
|
|
212
|
+
* logStage: (stage: string, extra?: Record<string, unknown>) => void,
|
|
213
|
+
* }} options
|
|
214
|
+
* @returns {Promise<RoundsResult>}
|
|
215
|
+
*/
|
|
216
|
+
export async function runReviewRounds(options) {
|
|
217
|
+
const {
|
|
218
|
+
reviewRounds, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
|
|
219
|
+
relativeSpecPath, relativeRepoFactsPath, relativeCataloguePath, packageMode, repoFacts,
|
|
220
|
+
runStage, assembleFrozenNodes, frozenContractRaw, contest,
|
|
221
|
+
invalidPlanFinding, logStage,
|
|
222
|
+
} = options;
|
|
223
|
+
let plan = options.plan;
|
|
224
|
+
let findings = options.findings;
|
|
225
|
+
// A draft that never validated is still the plan its first revise repairs.
|
|
226
|
+
if (plan === null) writeJsonAtomic(workingPlanPath, options.rejectedDraft ?? null);
|
|
227
|
+
// The revise's write-drops, merged into `findings` below each round; see
|
|
228
|
+
// that merge for why.
|
|
229
|
+
/** @type {PlanFindingOutput[]} */
|
|
230
|
+
let droppedWrites = [];
|
|
231
|
+
let roundsRun = 0;
|
|
232
|
+
// The critical count review found each round it actually ran, oldest
|
|
233
|
+
// first: what R14's stop condition compares one round against the one
|
|
234
|
+
// before it. A revise retry (`reviseOnce` below) never appends here — only
|
|
235
|
+
// a round whose own review ran counts toward this history, because a retry
|
|
236
|
+
// spends no round.
|
|
237
|
+
/** @type {number[]} */
|
|
238
|
+
const criticalHistory = [];
|
|
239
|
+
// How many of the last round's criticals the revise left standing: each
|
|
240
|
+
// one names a node the revise did not change (`unresolvedFindings`). R14
|
|
241
|
+
// stops only when this is non-zero, because a count alone cannot tell a
|
|
242
|
+
// revise that ignored an objection from one that answered every objection
|
|
243
|
+
// and a fresh review found new ones. Measured 2026-09-25 on the 3a gate
|
|
244
|
+
// rerun: round 1 raised 2 criticals, the revise answered both (0 carried),
|
|
245
|
+
// round 2's review raised 2 different ones, and a count-only R14 stopped a
|
|
246
|
+
// plan with two rounds still in budget.
|
|
247
|
+
let carriedCritical = 0;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The freeze pre-flight: the contract this plan would freeze into, checked
|
|
251
|
+
* the way `freeze.mjs` checks it. Null when it would freeze.
|
|
252
|
+
*
|
|
253
|
+
* @param {PlanOutput} candidate
|
|
254
|
+
* @returns {unknown}
|
|
255
|
+
*/
|
|
256
|
+
const freezePreflightError = (candidate) => {
|
|
257
|
+
try {
|
|
258
|
+
const contract = validateContract(frozenContractRaw(assembleFrozenNodes(declareDirectoryGuards(raiseTimeoutsToMeasured(candidate, repoFacts).plan, repoFacts, cwd).plan)), join(plansDir, "contract.json"));
|
|
259
|
+
assertFilteredProofsNameTheirTest(contract);
|
|
260
|
+
assertTimeoutsCoverMeasured(contract, repoFacts);
|
|
261
|
+
return null;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return error;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Run the revise stage once, validate its output and run the freeze
|
|
269
|
+
* pre-flight on it, and on a rejection run it a second time with the
|
|
270
|
+
* check's message appended to the same round's findings — the one retry
|
|
271
|
+
* R14 grants before a round contests. Every shape rejection this reaches is
|
|
272
|
+
* one `validatePlanOutput` has no deterministic fix for: R21 already normalizes the one shape variant that used to need
|
|
273
|
+
* one (a text `proof.ref`) inside the validator itself, so nothing here has
|
|
274
|
+
* a mechanical repair to apply instead of asking the worker again. Neither
|
|
275
|
+
* attempt is charged against `reviewRounds`; the caller's own round counter
|
|
276
|
+
* is untouched either way.
|
|
277
|
+
*
|
|
278
|
+
* @param {number} round
|
|
279
|
+
* @param {PlanFindingOutput[]} findingsForRevise
|
|
280
|
+
* @returns {Promise<{plan: PlanOutput, runId: string, firstInvalid: PlanFindingOutput|null} | {plan: null, finding: PlanFindingOutput, firstInvalid: PlanFindingOutput}>}
|
|
281
|
+
*/
|
|
282
|
+
const reviseOnce = async (round, findingsForRevise) => {
|
|
283
|
+
const path = join(scratchDir, `findings-round-${round}.json`);
|
|
284
|
+
writeJsonAtomic(path, findingsForRevise);
|
|
285
|
+
// The revise edits the plan the findings were raised against, which the
|
|
286
|
+
// round wrote to the working plan before review (or the draft's rejected
|
|
287
|
+
// output, below). Measured 2026-09-25 on the 3a gate: a revise handed only
|
|
288
|
+
// the findings redrafted the plan from scratch, renamed files and dropped
|
|
289
|
+
// six writes, and its retry fixed one validator error while introducing
|
|
290
|
+
// another.
|
|
291
|
+
const revise = await runStage("revise", {
|
|
292
|
+
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath, packageMode,
|
|
293
|
+
});
|
|
294
|
+
// The whole deterministic check R14 names runs on the revise's output:
|
|
295
|
+
// the shape validator, and the freeze pre-flight a review round would
|
|
296
|
+
// otherwise only reach a round later. Measured 2026-09-26 on the 3a gate:
|
|
297
|
+
// four rounds with no critical from review each contested on a
|
|
298
|
+
// scope-closure gap the same revise had just opened, because the revise
|
|
299
|
+
// learned of it only after the next review had spent its round.
|
|
300
|
+
/** @type {PlanOutput|null} */
|
|
301
|
+
let structurallyValid = null;
|
|
302
|
+
/** @type {PlanFindingOutput} */
|
|
303
|
+
let firstInvalid;
|
|
304
|
+
try {
|
|
305
|
+
structurallyValid = validatePlanOutput(revise.output.plan);
|
|
306
|
+
const preflightError = freezePreflightError(structurallyValid);
|
|
307
|
+
if (preflightError === null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid: null };
|
|
308
|
+
firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, preflightError);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, error);
|
|
311
|
+
}
|
|
312
|
+
const retryPath = join(scratchDir, `findings-round-${round}-retry.json`);
|
|
313
|
+
writeJsonAtomic(retryPath, [...findingsForRevise, firstInvalid]);
|
|
314
|
+
// The retry repairs the output the check refused, not the plan before
|
|
315
|
+
// it: that output already carries this round's resolutions, and only the
|
|
316
|
+
// check's message is left to answer.
|
|
317
|
+
const retryPlanPath = join(scratchDir, `plan-round-${round}-rejected.json`);
|
|
318
|
+
writeJsonAtomic(retryPlanPath, revise.output.plan ?? null);
|
|
319
|
+
const retry = await runStage("revise", {
|
|
320
|
+
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, retryPath), planPath: relative(cwd, retryPlanPath), packageMode,
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
// A retry that validates goes to review even if the freeze pre-flight
|
|
324
|
+
// still refuses it: that round's own pre-flight raises it again.
|
|
325
|
+
return { plan: validatePlanOutput(retry.output.plan), runId: retry.contract.id, firstInvalid };
|
|
326
|
+
} catch (secondError) {
|
|
327
|
+
// The first attempt still validated: it goes to review rather than
|
|
328
|
+
// contesting a round over a retry that made things worse.
|
|
329
|
+
if (structurallyValid !== null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid };
|
|
330
|
+
return { plan: null, finding: invalidPlanFinding(`revise-r${round}-attempt2`, secondError), firstInvalid };
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
for (let round = 1; round <= reviewRounds; round += 1) {
|
|
335
|
+
roundsRun = round;
|
|
336
|
+
// Only a round whose plan a review actually graded has a critical count
|
|
337
|
+
// R14 can compare: an invalid plan skips review, and its one critical says
|
|
338
|
+
// the plan did not validate. Measured 2026-09-25 on the 3a gate: an invalid
|
|
339
|
+
// draft counted 1, the first real review counted 2 (one of them the
|
|
340
|
+
// scope-closure pre-flight), and R14 stopped the plan after 2 of 4 rounds.
|
|
341
|
+
const reviewed = plan !== null;
|
|
342
|
+
if (plan) {
|
|
343
|
+
// R14's mechanical repair with a single answer, applied before review so
|
|
344
|
+
// the reviewer grades, and freeze checks, the plan that would ship.
|
|
345
|
+
const timeouts = raiseTimeoutsToMeasured(plan, repoFacts);
|
|
346
|
+
if (timeouts.raised.length) logStage("timeouts-raised", { round, raised: timeouts.raised });
|
|
347
|
+
const guards = declareDirectoryGuards(timeouts.plan, repoFacts, cwd);
|
|
348
|
+
if (guards.declared.length) logStage("guards-declared", { round, declared: guards.declared });
|
|
349
|
+
plan = guards.plan;
|
|
350
|
+
// The reviewer grades a structurally valid plan; an invalid one skips
|
|
351
|
+
// review and reaches revise through the validator's finding instead.
|
|
352
|
+
writeJsonAtomic(workingPlanPath, plan);
|
|
353
|
+
const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
|
|
354
|
+
/** @type {PlanFindingOutput|null} */
|
|
355
|
+
let invalidFindings = null;
|
|
356
|
+
try {
|
|
357
|
+
// Merged over what is still open, never substituted for it: a round's
|
|
358
|
+
// reviewer grades the plan in front of it and may simply not mention
|
|
359
|
+
// an objection the last round raised, which a plain assignment here
|
|
360
|
+
// threw away.
|
|
361
|
+
findings = mergeFindings(findings, validateFindings(review.output.findings));
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// The review said nothing usable about the plan, so the plan cannot be
|
|
364
|
+
// treated as clean: the malformed-output finding is critical and
|
|
365
|
+
// drives the same revise-or-contest path a real critical finding does,
|
|
366
|
+
// with the still-outstanding findings riding along.
|
|
367
|
+
invalidFindings = invalidPlanFinding(`review-r${round}`, error);
|
|
368
|
+
findings = [...findings, invalidFindings];
|
|
369
|
+
}
|
|
370
|
+
// The contract this plan would freeze into is checked here, inside the
|
|
371
|
+
// round and after the review's findings merged into the open ones,
|
|
372
|
+
// because freeze runs after the last one: caught here, a plan that
|
|
373
|
+
// cannot freeze still has a revise left to fix it. The failure is a
|
|
374
|
+
// critical finding, never an auto-filled acknowledgement — scope
|
|
375
|
+
// closure exists to force the per-file decision (declare a write, or
|
|
376
|
+
// acknowledge a read-only importer), and the revise worker, which can
|
|
377
|
+
// read the repository, makes it from the validator's own message.
|
|
378
|
+
/** @type {PlanFindingOutput|null} */
|
|
379
|
+
let freezeFailure = null;
|
|
380
|
+
const preflightError = freezePreflightError(plan);
|
|
381
|
+
if (preflightError !== null) {
|
|
382
|
+
freezeFailure = invalidPlanFinding(`freeze-r${round}`, preflightError);
|
|
383
|
+
findings = [...findings, freezeFailure];
|
|
384
|
+
}
|
|
385
|
+
logStage("review", {
|
|
386
|
+
round,
|
|
387
|
+
runId: review.contract.id,
|
|
388
|
+
findingsCount: findings.length,
|
|
389
|
+
criticalCount: findings.filter((finding) => finding.severity === "critical").length,
|
|
390
|
+
...(freezeFailure === null ? {} : { freezeFailed: freezeFailure.text }),
|
|
391
|
+
...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// The revise's write-drops from the previous round land here — after the
|
|
395
|
+
// review above, before the critical check below — so a drop survives into
|
|
396
|
+
// the same revise-or-contest decision a review finding reaches. Merged by
|
|
397
|
+
// id: a drop the last round already carried and this revise made again is
|
|
398
|
+
// one finding, not two.
|
|
399
|
+
findings = mergeFindings(findings, droppedWrites);
|
|
400
|
+
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
401
|
+
if (criticalFindings.length === 0) break;
|
|
402
|
+
// R14: a round that ends with as many or more criticals than the round
|
|
403
|
+
// before it, while the revise left one of that round's criticals
|
|
404
|
+
// standing, is not converging — the revise already had it in front of
|
|
405
|
+
// it, and spending the rounds still in budget would not change that.
|
|
406
|
+
// Checked before the round-budget exit below, so the more informative
|
|
407
|
+
// finding wins when a round is both the last one and a non-improvement
|
|
408
|
+
// over the one before it.
|
|
409
|
+
const previousCritical = reviewed ? criticalHistory[criticalHistory.length - 1] : undefined;
|
|
410
|
+
if (reviewed) criticalHistory.push(criticalFindings.length);
|
|
411
|
+
if (previousCritical !== undefined && criticalFindings.length >= previousCritical && carriedCritical > 0) {
|
|
412
|
+
const notConverging = revisionNotConvergingFinding(round, criticalHistory);
|
|
413
|
+
findings = [...findings, notConverging];
|
|
414
|
+
logStage("revision-not-converging", { round, criticalHistory });
|
|
415
|
+
return { resolved: false, result: await contest(round, findings, plan) };
|
|
416
|
+
}
|
|
417
|
+
if (round === reviewRounds) return { resolved: false, result: await contest(round, findings, plan) };
|
|
418
|
+
// Kept for the write-drop and unresolved-finding comparisons below: the
|
|
419
|
+
// plan the revise revises, against whichever attempt's output validates.
|
|
420
|
+
const planBeforeRevise = plan;
|
|
421
|
+
const revised = await reviseOnce(round, findings);
|
|
422
|
+
if (revised.plan === null) {
|
|
423
|
+
// Both this round's revise attempts were rejected: the round contests
|
|
424
|
+
// rather than carrying the still-unrevised plan into another round.
|
|
425
|
+
findings = [...findings, revised.finding];
|
|
426
|
+
logStage("revise", { round, retried: true, invalid: revised.finding.text, firstInvalid: revised.firstInvalid.text });
|
|
427
|
+
return { resolved: false, result: await contest(round, findings, plan) };
|
|
428
|
+
}
|
|
429
|
+
plan = revised.plan;
|
|
430
|
+
droppedWrites = droppedWriteFindings(planBeforeRevise, plan, cwd);
|
|
431
|
+
// What the next round starts from: the findings this revise did not move
|
|
432
|
+
// the plan under. Everything else — a node it changed, a node it removed,
|
|
433
|
+
// and the pipeline's own shape findings, which the next round re-derives
|
|
434
|
+
// — is dropped here rather than carried forever.
|
|
435
|
+
findings = unresolvedFindings(findings, planBeforeRevise, plan);
|
|
436
|
+
carriedCritical = findings.filter((finding) => finding.severity === "critical").length;
|
|
437
|
+
logStage("revise", {
|
|
438
|
+
round,
|
|
439
|
+
runId: revised.runId,
|
|
440
|
+
droppedWrites: droppedWrites.length,
|
|
441
|
+
carriedFindings: findings.length,
|
|
442
|
+
...(revised.firstInvalid === null ? {} : { retried: true, firstInvalid: revised.firstInvalid.text }),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return { resolved: true, plan, findings, roundsRun };
|
|
446
|
+
}
|
package/src/plan/routing.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { ROUTING_STRATEGIES } from "../contract/runtime.mjs";
|
|
15
15
|
import { cheapest, isRuntimeAvailable, strongest } from "../engine/runtime-discovery.mjs";
|
|
16
|
+
import { effectiveProvider } from "../contract/provider.mjs";
|
|
16
17
|
|
|
17
18
|
/** @typedef {import("../contract/runtime.mjs").RoutingStrategy} RoutingStrategy */
|
|
18
19
|
/** @typedef {RoutingStrategy|"declared"} AppliedStrategy */
|
|
@@ -227,7 +228,8 @@ function chooseByStrategy(strategy, prefer, context) {
|
|
|
227
228
|
function blockedWhy(id, runtimes, availability, forbiddenVendors) {
|
|
228
229
|
const runtime = runtimes[id];
|
|
229
230
|
if (!runtime) return "is not declared in runtimes";
|
|
230
|
-
|
|
231
|
+
const provider = effectiveProvider(runtime);
|
|
232
|
+
if (provider !== undefined && forbiddenVendors.has(provider)) return `carries forbidden vendor ${provider}`;
|
|
231
233
|
return "is unavailable";
|
|
232
234
|
}
|
|
233
235
|
|
|
@@ -241,7 +243,8 @@ function blockedWhy(id, runtimes, availability, forbiddenVendors) {
|
|
|
241
243
|
function admits(id, runtimes, availability, forbiddenVendors) {
|
|
242
244
|
const runtime = runtimes[id];
|
|
243
245
|
if (!runtime) return false;
|
|
244
|
-
|
|
246
|
+
const provider = effectiveProvider(runtime);
|
|
247
|
+
if (provider !== undefined && forbiddenVendors.has(provider)) return false;
|
|
245
248
|
return isRuntimeAvailable(availability[id]);
|
|
246
249
|
}
|
|
247
250
|
|
|
@@ -262,7 +265,8 @@ function forbiddenJudgeVendors(workerId, runtimes) {
|
|
|
262
265
|
let id = workerId;
|
|
263
266
|
while (id !== undefined && runtimes[id] && !seen.has(id)) {
|
|
264
267
|
seen.add(id);
|
|
265
|
-
|
|
268
|
+
const provider = effectiveProvider(runtimes[id]);
|
|
269
|
+
if (provider !== undefined) vendors.add(provider);
|
|
266
270
|
id = runtimes[id].fallback;
|
|
267
271
|
}
|
|
268
272
|
return vendors;
|
|
@@ -283,7 +287,10 @@ function ruleLabel(row) {
|
|
|
283
287
|
function candidateEntries(runtimes, availability, forbiddenVendors) {
|
|
284
288
|
return Object.entries(runtimes)
|
|
285
289
|
.map(([id, runtime], order) => ({ id, runtime, order }))
|
|
286
|
-
.filter(({ id, runtime }) =>
|
|
290
|
+
.filter(({ id, runtime }) => {
|
|
291
|
+
const provider = effectiveProvider(runtime);
|
|
292
|
+
return (provider === undefined || !forbiddenVendors.has(provider)) && isRuntimeAvailable(availability[id]);
|
|
293
|
+
});
|
|
287
294
|
}
|
|
288
295
|
|
|
289
296
|
/**
|
package/src/plan/template.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
|
25
25
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
26
26
|
/** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
|
|
27
27
|
/** @typedef {"low"|"standard"|"high"} RiskTier */
|
|
28
|
-
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
28
|
+
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, reviewerId?: string, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
29
29
|
/** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
|
|
30
30
|
/** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
|
|
31
31
|
/** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
|
|
@@ -78,26 +78,30 @@ export function renderTaskKindCatalogue() {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* Which of the caller's
|
|
82
|
-
*
|
|
83
|
-
* spec-review is graded by the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
81
|
+
* Which of the caller's runtime inputs resolves this contract's single node.
|
|
82
|
+
* A draft or revise is authored by the worker role, read off
|
|
83
|
+
* `runtimeDefaults.worker`; a review or spec-review is graded by the
|
|
84
|
+
* reviewer role, read off `inputs.reviewerId` instead (R19) -- the planner's
|
|
85
|
+
* own ordered reviewer list (`reviewer.mjs`), never `runtimeDefaults.judge`,
|
|
86
|
+
* which is R18's judge-list default for the frozen contract's nodes and
|
|
87
|
+
* shares nothing with this one. There is no gate on this single-node
|
|
88
|
+
* contract, so the role only decides which runtime id the node itself
|
|
89
|
+
* carries.
|
|
86
90
|
*
|
|
87
|
-
* @type {Record<PlanningKind, "worker"|"
|
|
91
|
+
* @type {Record<PlanningKind, "worker"|"reviewer">}
|
|
88
92
|
*/
|
|
89
93
|
const KIND_ROLE = Object.freeze({
|
|
90
94
|
draft: "worker",
|
|
91
95
|
revise: "worker",
|
|
92
96
|
"spec-author": "worker",
|
|
93
|
-
review: "
|
|
94
|
-
"spec-review": "
|
|
97
|
+
review: "reviewer",
|
|
98
|
+
"spec-review": "reviewer",
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
/** @type {Record<PlanningKind, string[]>} */
|
|
98
102
|
const REQUIRED_INPUTS = Object.freeze({
|
|
99
103
|
draft: ["specPath", "repoFactsPath", "cataloguePath"],
|
|
100
|
-
revise: ["specPath", "repoFactsPath", "cataloguePath", "findingsPath"],
|
|
104
|
+
revise: ["specPath", "repoFactsPath", "cataloguePath", "findingsPath", "planPath"],
|
|
101
105
|
review: ["specPath", "repoFactsPath", "planPath"],
|
|
102
106
|
"spec-author": ["notesPath"],
|
|
103
107
|
"spec-review": ["specPath"],
|
|
@@ -144,6 +148,14 @@ function instructionsFor(kind, inputs) {
|
|
|
144
148
|
return INSTRUCTIONS[kind].map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
// AP1 of safe-to-hand-to-a-friend, measured 2026-09-26: a draft copied the
|
|
152
|
+
// spec's requirement proof `node --test --test-name-pattern="<title>"` into a
|
|
153
|
+
// Definition of Done item with no file, which runs every test file in the
|
|
154
|
+
// tree and read as having measured nothing; two nodes exhausted on it. The
|
|
155
|
+
// freeze now refuses that shape (proof-scope.mjs); this is the same rule told
|
|
156
|
+
// to the author, so the draft never writes it.
|
|
157
|
+
const NAMED_TEST_FILE_RULE = 'A command that filters node:test by name (--test-name-pattern) names the test file it selects from, as in node --test --test-name-pattern="<exact test title>" test/<area>/<file>.test.mjs: with no path node --test runs every test file in the tree and the freeze refuses it. A requirement proof the spec writes without a file gains the file of the test that carries that title.';
|
|
158
|
+
|
|
147
159
|
// The rule every planned packet is held to at freeze time, worded from
|
|
148
160
|
// AGENTS.md's Faberun protocol and src/repo/scope-closure.mjs ("reading it
|
|
149
161
|
// cannot fix it"): a first draft that ignores it produces a plan that fails
|
|
@@ -157,7 +169,7 @@ const SCOPE_CLOSURE_RULE = Object.freeze([
|
|
|
157
169
|
/** @type {Record<PlanningKind, string>} */
|
|
158
170
|
const OBJECTIVES = Object.freeze({
|
|
159
171
|
draft: "Draft an execution plan for this phase: classify every node's taskKind and riskTier from the spec and the repository facts, and propose the dependency graph.",
|
|
160
|
-
revise: "Revise the plan to resolve every one of the reviewer's findings,
|
|
172
|
+
revise: "Revise the plan in readFiles to resolve every one of the reviewer's findings, changing only what a finding requires.",
|
|
161
173
|
review: "Review this plan against the spec and the repository facts, and report only findings.",
|
|
162
174
|
"spec-author": "Turn free notes into a structured spec document following the spec format.",
|
|
163
175
|
"spec-review": "Review this spec for traceability and completeness, and report only findings.",
|
|
@@ -169,14 +181,17 @@ const INSTRUCTIONS = Object.freeze({
|
|
|
169
181
|
`Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
170
182
|
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
|
|
171
183
|
...SCOPE_CLOSURE_RULE,
|
|
184
|
+
NAMED_TEST_FILE_RULE,
|
|
172
185
|
`Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
173
186
|
"Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
|
|
174
187
|
SIZING_INSTRUCTION,
|
|
175
188
|
],
|
|
176
189
|
revise: [
|
|
190
|
+
"Start from the plan JSON in readFiles, the plan the findings were raised against, and return it with only the changes the findings require. Keep every node id, write file and definitionOfDone item that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.",
|
|
177
191
|
"Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
|
|
178
192
|
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
|
|
179
193
|
...SCOPE_CLOSURE_RULE,
|
|
194
|
+
NAMED_TEST_FILE_RULE,
|
|
180
195
|
`Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
181
196
|
"Never name a runtime, harness, model, or vendor anywhere in output.plan.",
|
|
182
197
|
SIZING_INSTRUCTION,
|
|
@@ -214,7 +229,7 @@ const NON_GOALS = Object.freeze({
|
|
|
214
229
|
function readFilesForKind(kind, inputs) {
|
|
215
230
|
if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath)];
|
|
216
231
|
if (kind === "revise") {
|
|
217
|
-
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath)];
|
|
232
|
+
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath), /** @type {string} */ (inputs.planPath)];
|
|
218
233
|
}
|
|
219
234
|
if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
|
|
220
235
|
if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
|
|
@@ -243,7 +258,7 @@ export function buildPlanningContract(kind, inputs) {
|
|
|
243
258
|
|
|
244
259
|
const readFiles = readFilesForKind(kind, inputs);
|
|
245
260
|
const role = KIND_ROLE[kind];
|
|
246
|
-
const runtimeId = (inputs.runtimeDefaults ?? {})[role];
|
|
261
|
+
const runtimeId = role === "reviewer" ? inputs.reviewerId : (inputs.runtimeDefaults ?? {})[role];
|
|
247
262
|
|
|
248
263
|
/** @type {JsonObject} */
|
|
249
264
|
const taskPacket = {
|
|
@@ -328,8 +343,15 @@ export function validatePlanOutput(plan) {
|
|
|
328
343
|
// round could not resolve the finding the preflight had just raised.
|
|
329
344
|
const scopeAcknowledged = nodeRecord.scopeAcknowledged ?? [];
|
|
330
345
|
requireStringArray(scopeAcknowledged, `${label}.scopeAcknowledged`);
|
|
331
|
-
|
|
346
|
+
// Verification is validated first so a DoD proof that names a verification
|
|
347
|
+
// command by its exact text (R21) can be checked and normalized to that
|
|
348
|
+
// command's index against this node's own commands, in hand here.
|
|
332
349
|
const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
|
|
350
|
+
const definitionOfDone = validateDefinitionOfDone(
|
|
351
|
+
nodeRecord.definitionOfDone ?? [],
|
|
352
|
+
`${label}.definitionOfDone`,
|
|
353
|
+
{ commands: verification, nodeId: /** @type {string} */ (nodeRecord.id) },
|
|
354
|
+
);
|
|
333
355
|
const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${label}.expectedTurns`);
|
|
334
356
|
return /** @type {PlanOutputNode} */ ({
|
|
335
357
|
id: /** @type {string} */ (nodeRecord.id),
|
|
@@ -2,8 +2,13 @@ export const SIGNAL_START = "<!-- faberun-active:start (managed by faberun — r
|
|
|
2
2
|
export const SIGNAL_END = "<!-- faberun-active:end -->";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Remove a complete runner-managed block, markers included, and join the text
|
|
6
|
+
* around it with the blank line the runner writes before it. A file whose
|
|
7
|
+
* commit has no block and whose working copy gained one therefore normalizes
|
|
8
|
+
* to the same text: measured 2026-09-25, keeping the markers made the block
|
|
9
|
+
* the runner appends to a block-less AGENTS.md read as a human edit, and the
|
|
10
|
+
* planner's review stage refused the tree its own draft stage had just
|
|
11
|
+
* written. Guidance outside the block remains part of source identity.
|
|
7
12
|
*
|
|
8
13
|
* @param {string} text
|
|
9
14
|
* @returns {string}
|
|
@@ -12,5 +17,7 @@ export function normalizeManagedSignalBlock(text) {
|
|
|
12
17
|
const start = text.indexOf(SIGNAL_START);
|
|
13
18
|
const end = text.indexOf(SIGNAL_END, start + SIGNAL_START.length);
|
|
14
19
|
if (start < 0 || end < start) return text;
|
|
15
|
-
|
|
20
|
+
const before = text.slice(0, start).trimEnd();
|
|
21
|
+
const after = text.slice(end + SIGNAL_END.length).replace(/^\s+/u, "");
|
|
22
|
+
return after ? `${before}\n\n${after}` : `${before}\n`;
|
|
16
23
|
}
|