faberun 0.25.1 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +30 -26
- 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/launch.mjs +14 -1
- 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/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 +19 -16
- package/src/engine/live-preflight.mjs +45 -7
- package/src/engine/resume.mjs +46 -9
- package/src/engine/run-identity.mjs +9 -6
- 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
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { normalizeProviderAvailability, probeRuntime } from "../harnesses/index.mjs";
|
|
2
|
+
import { effectiveProvider } from "../contract/provider.mjs";
|
|
3
|
+
import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
|
|
2
4
|
|
|
3
5
|
// Availability normalization belongs to the adapter registry, which is where
|
|
4
6
|
// each provider's own exhaustion, balance, and authentication wording is
|
|
@@ -8,7 +10,7 @@ export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/in
|
|
|
8
10
|
|
|
9
11
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
10
12
|
/** @typedef {{harness?: string, model?: string, vendor: string, tier?: number|string, costRank?: number, [key: string]: unknown}} RuntimeLike */
|
|
11
|
-
/** @typedef {{runtimes: Record<string, RuntimeLike>, runtimeDefaults?: {worker?: string, judge?: string}, nodes?: {id: string, runtime?: string, gate: {enabled: boolean, runtime?: string}}[]}} RuntimeContract */
|
|
13
|
+
/** @typedef {{runtimes: Record<string, RuntimeLike>, runtimeDefaults?: {worker?: string, judge?: string}, judgeIndependence?: string, nodes?: {id: string, runtime?: string, gate: {enabled: boolean, runtime?: string}}[]}} RuntimeContract */
|
|
12
14
|
/**
|
|
13
15
|
* One runtime's catalogue record: what the harness de facto reported, and
|
|
14
16
|
* when. An unobservable datum is null -- never zero and never full allowance,
|
|
@@ -20,7 +22,20 @@ export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/in
|
|
|
20
22
|
/** @typedef {{harness: string, model: string, vendor: string, tier: number, costRank: number, config?: Record<string, unknown>}} DiscoveryRuntime */
|
|
21
23
|
/** @typedef {{id: string, runtime: RuntimeLike, order: number}} RuntimeCandidate */
|
|
22
24
|
/** @typedef {import("../host/config.mjs").UserConfig} UserConfig */
|
|
23
|
-
/** @typedef {{
|
|
25
|
+
/** @typedef {{id: string, gate: {enabled: boolean, runtime?: string}}} ComposeOptionsNode */
|
|
26
|
+
/**
|
|
27
|
+
* `listJudge`'s return governs precedence, not just a value: `undefined`
|
|
28
|
+
* means no list applies to this node (its gate is disabled), so
|
|
29
|
+
* `composeAssignments` falls through to its own candidates unchanged; once a
|
|
30
|
+
* list applies, its pick is final and nothing else is tried, whether that
|
|
31
|
+
* pick is a runtime id (`chosen`) or `null` for an exhausted list. The full
|
|
32
|
+
* pick, not just `chosen`, is returned so an exhausted list's thrown error can
|
|
33
|
+
* name every skipped entry and why -- an operator reading an aborted run
|
|
34
|
+
* cannot otherwise tell the list was even consulted.
|
|
35
|
+
* @typedef {{id: string, reason: string}} JudgeListSkip
|
|
36
|
+
* @typedef {{chosen: string|null, skipped: JudgeListSkip[]}} JudgeListPick
|
|
37
|
+
* @typedef {{config?: UserConfig|null, onWarning?: (message: string) => void, listJudge?: (node: ComposeOptionsNode, workerId: string, workerProvider: string|undefined) => JudgeListPick|undefined}} ComposeOptions
|
|
38
|
+
*/
|
|
24
39
|
|
|
25
40
|
/**
|
|
26
41
|
* Candidates used when a contract omits its runtime catalogue. The catalogue
|
|
@@ -124,6 +139,16 @@ export function availableCandidates(runtimes, availability = {}) {
|
|
|
124
139
|
* `options.onWarning`; config never overrides an explicit node, gate or
|
|
125
140
|
* runtime-default declaration, and the cross-vendor judge rule still applies.
|
|
126
141
|
*
|
|
142
|
+
* `options.listJudge` (R18) is asked for an omitted judge before the single
|
|
143
|
+
* `config.judge` preference and the strongest-candidate default: it is the
|
|
144
|
+
* caller's own ordered-list selection (`engine/judge-list.mjs`), kept out of
|
|
145
|
+
* this module so a list pick's refusal- and usage-window reads never import a
|
|
146
|
+
* `run/` consumer of this very function back into a cycle. Returning
|
|
147
|
+
* `undefined` -- no list governs this node -- falls through to the candidates
|
|
148
|
+
* below exactly as if it had not been asked. A governed node's pick is final
|
|
149
|
+
* instead: a string is used as-is, and `null` -- every entry was skipped --
|
|
150
|
+
* throws rather than silently falling through to a judge outside the list.
|
|
151
|
+
*
|
|
127
152
|
* @param {RuntimeContract} contract
|
|
128
153
|
* @param {Record<string, RuntimeAvailability>} availability
|
|
129
154
|
* @param {ComposeOptions} [options]
|
|
@@ -149,21 +174,57 @@ export function composeAssignments(contract, availability = {}, options = {}) {
|
|
|
149
174
|
const workerRuntime = contract.runtimes[worker];
|
|
150
175
|
const judgeOmitted = node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined;
|
|
151
176
|
const preferredJudge = judgeOmitted ? candidateById(candidates, config?.judge) : undefined;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
177
|
+
const workerProvider = effectiveProvider(workerRuntime);
|
|
178
|
+
const declaredJudge = node.gate.runtime ?? contract.runtimeDefaults?.judge;
|
|
179
|
+
// R18: once a list governs this node (`judgeListPick` is not
|
|
180
|
+
// `undefined`), its pick is final -- a string is used as-is, and `null`
|
|
181
|
+
// (every entry skipped) must not fall through to
|
|
182
|
+
// `preferredJudge`/`strongest` below, or the node would land on a judge
|
|
183
|
+
// outside the declared list and the skip evidence `listJudge` already
|
|
184
|
+
// recorded would go unused. The thrown error itself names every skipped
|
|
185
|
+
// entry and why: this is the only trace of the list an operator reading
|
|
186
|
+
// an aborted run has, since the caller's own record of `judgeListStates`
|
|
187
|
+
// is never reached when this constructor throws before returning.
|
|
188
|
+
const judgeListPick = declaredJudge === undefined && judgeOmitted ? options.listJudge?.(node, worker, workerProvider) : undefined;
|
|
189
|
+
if (judgeListPick && judgeListPick.chosen === null) {
|
|
190
|
+
const reasons = judgeListPick.skipped.map((entry) => `${entry.id}: ${entry.reason}`).join("; ");
|
|
191
|
+
throw new Error(`runtime_assignment_judge_unavailable: every entry of the judge list is unavailable for node ${node.id} and worker ${worker} (${reasons})`);
|
|
192
|
+
}
|
|
193
|
+
// R20: in same-vendor mode a judge of the worker's own provider is
|
|
194
|
+
// admissible when it is another model at an equal or higher declared tier;
|
|
195
|
+
// the strongest such candidate is the default only when no other vendor has one.
|
|
196
|
+
const sameVendorMode = contract.judgeIndependence === SAME_VENDOR_REVIEW_MODE;
|
|
197
|
+
const judge = declaredJudge
|
|
198
|
+
?? judgeListPick?.chosen
|
|
199
|
+
?? (preferredJudge && effectiveProvider(preferredJudge.runtime) !== workerProvider ? preferredJudge.id : undefined)
|
|
200
|
+
?? strongest(candidates, workerProvider)?.id
|
|
201
|
+
?? (sameVendorMode ? strongest(candidates.filter(({ id, runtime }) => id !== worker && isTierAdmissible(workerRuntime, runtime)), undefined)?.id : undefined);
|
|
155
202
|
if (node.gate.enabled && (!judge || !contract.runtimes[judge])) {
|
|
156
203
|
throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
|
|
157
204
|
}
|
|
158
205
|
const judgeRuntime = judge ? contract.runtimes[judge] : undefined;
|
|
159
|
-
if (node.gate.enabled && workerRuntime && judgeRuntime && judgeRuntime
|
|
160
|
-
throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
|
|
206
|
+
if (node.gate.enabled && workerRuntime && judgeRuntime && effectiveProvider(judgeRuntime) === workerProvider) {
|
|
207
|
+
if (!sameVendorMode) throw new Error(`runtime_assignment_judge_unavailable: no available cross-vendor judge for node ${node.id} and worker ${worker}`);
|
|
208
|
+
if (judge === worker) throw new Error(`runtime_assignment_judge_unavailable: same-vendor mode needs a judge other than worker ${worker} for node ${node.id}`);
|
|
209
|
+
const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (workerRuntime), /** @type {{harness: string, model: string}} */ (judgeRuntime));
|
|
210
|
+
if (refusal) throw new Error(`runtime_assignment_judge_unavailable: ${refusal.message} for node ${node.id}`);
|
|
161
211
|
}
|
|
162
212
|
assignments[node.id] = { worker, judge: judge ?? worker };
|
|
163
213
|
}
|
|
164
214
|
return assignments;
|
|
165
215
|
}
|
|
166
216
|
|
|
217
|
+
/**
|
|
218
|
+
* @param {RuntimeLike} workerRuntime
|
|
219
|
+
* @param {RuntimeLike} judgeRuntime
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
function isTierAdmissible(workerRuntime, judgeRuntime) {
|
|
223
|
+
if (typeof workerRuntime.harness !== "string" || typeof workerRuntime.model !== "string") return false;
|
|
224
|
+
if (typeof judgeRuntime.harness !== "string" || typeof judgeRuntime.model !== "string") return false;
|
|
225
|
+
return sameVendorTierRefusal({ harness: workerRuntime.harness, model: workerRuntime.model }, { harness: judgeRuntime.harness, model: judgeRuntime.model }) === null;
|
|
226
|
+
}
|
|
227
|
+
|
|
167
228
|
/**
|
|
168
229
|
* @param {RuntimeCandidate[]} candidates
|
|
169
230
|
* @param {string|undefined} id
|
|
@@ -189,12 +250,12 @@ export function nextSameTierRuntime(contract, stateRouting, role, current, attem
|
|
|
189
250
|
const currentRuntime = contract.runtimes[current];
|
|
190
251
|
if (!currentRuntime) return null;
|
|
191
252
|
const workerId = stateRouting.assignments?.worker;
|
|
192
|
-
const
|
|
253
|
+
const workerProvider = workerId && contract.runtimes[workerId] ? effectiveProvider(contract.runtimes[workerId]) : null;
|
|
193
254
|
const used = new Set(attempted);
|
|
194
255
|
return Object.entries(contract.runtimes)
|
|
195
256
|
.filter(([id, runtime]) => id !== current && !used.has(id) && sameTier(runtime, currentRuntime))
|
|
196
257
|
.filter(([id]) => isRuntimeAvailable(stateRouting.availability?.[id]))
|
|
197
|
-
.filter(([, runtime]) => role !== "judge" || runtime
|
|
258
|
+
.filter(([, runtime]) => role !== "judge" || effectiveProvider(runtime) !== workerProvider)
|
|
198
259
|
.sort((left, right) => runtimeOrder(left[1]) - runtimeOrder(right[1]))
|
|
199
260
|
.map(([id]) => id)
|
|
200
261
|
.at(0) ?? null;
|
|
@@ -232,11 +293,11 @@ export function cheapest(candidates) {
|
|
|
232
293
|
* `cheapest` for `setup`'s cross-vendor judge default.
|
|
233
294
|
*
|
|
234
295
|
* @param {RuntimeCandidate[]} candidates
|
|
235
|
-
* @param {string} vendor
|
|
296
|
+
* @param {string|undefined} vendor
|
|
236
297
|
* @returns {RuntimeCandidate|null}
|
|
237
298
|
*/
|
|
238
299
|
export function strongest(candidates, vendor) {
|
|
239
|
-
return [...candidates].filter(({ runtime }) => runtime
|
|
300
|
+
return [...candidates].filter(({ runtime }) => effectiveProvider(runtime) !== vendor)
|
|
240
301
|
.sort((left, right) => tierOrder(right.runtime) - tierOrder(left.runtime)
|
|
241
302
|
|| runtimeOrder(right.runtime) - runtimeOrder(left.runtime)
|
|
242
303
|
|| left.order - right.order).at(0) ?? null;
|
package/src/engine/scheduler.mjs
CHANGED
|
@@ -44,6 +44,8 @@ import { captureNodeScopeBoundaries, checkWorkerScope, emptyScope } from "./scop
|
|
|
44
44
|
import { validateContractForLaunch } from "../campaign/chain.mjs";
|
|
45
45
|
import { finalVerificationCommands, gateProofTimeoutMs, sharedVerificationCommands } from "../contract/final-verification.mjs";
|
|
46
46
|
import { startJudge, startWorker } from "./dispatch.mjs";
|
|
47
|
+
import { raiseNodeAttention } from "./settle.mjs";
|
|
48
|
+
import { HUMAN_STEP_ERROR_CODE, humanStepAttentionMessage } from "../contract/human-step.mjs";
|
|
47
49
|
import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
|
|
48
50
|
import { blockDependents, runtimeAssignments } from "./assignment.mjs";
|
|
49
51
|
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from "./supervise.mjs";
|
|
@@ -290,6 +292,11 @@ export async function runContract(contractPath, options = {}) {
|
|
|
290
292
|
currentOverride: null,
|
|
291
293
|
assignments: runtimePlan.assignments[node.id],
|
|
292
294
|
availability: runtimePlan.availability,
|
|
295
|
+
// Only a genuinely list-driven pick (R18) carries this evidence. An
|
|
296
|
+
// exhausted list (`chosen` null) never reaches here: composeAssignments
|
|
297
|
+
// throws before a run is created rather than falling through to the
|
|
298
|
+
// discovery ranking (see `runtime-discovery.mjs`'s `listPick`).
|
|
299
|
+
...(runtimePlan.judgeListStates[node.id] ? { judgeList: runtimePlan.judgeListStates[node.id] } : {}),
|
|
293
300
|
},
|
|
294
301
|
progress: null,
|
|
295
302
|
invocations: [],
|
|
@@ -617,20 +624,34 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
|
|
|
617
624
|
autoRetryParkedNodes(contract, runDir, states, lock, parkedBefore);
|
|
618
625
|
blockDependents(contract, runDir, states, lock);
|
|
619
626
|
|
|
627
|
+
const ready = contract.nodes.filter((node) => {
|
|
628
|
+
const state = states.get(node.id);
|
|
629
|
+
return state?.status === "pending" && !pendingSettlements.has(node.id)
|
|
630
|
+
&& node.dependsOn.every((id) => states.get(id)?.status === "done");
|
|
631
|
+
});
|
|
632
|
+
// A human-step node (R16) never reaches a provider: once its
|
|
633
|
+
// dependencies are done it stops right here, `state.error.message`
|
|
634
|
+
// naming the step and its command, instead of entering the slot-gated
|
|
635
|
+
// dispatch below. It costs no slot and is not bounded by `maxParallel`,
|
|
636
|
+
// so this runs whether or not one is free.
|
|
637
|
+
for (const node of ready) {
|
|
638
|
+
if (!node.humanStep) continue;
|
|
639
|
+
transition(runDir, /** @type {NodeSnapshot} */ (states.get(node.id)), "blocked", {
|
|
640
|
+
phase: "complete",
|
|
641
|
+
error: { code: HUMAN_STEP_ERROR_CODE, message: humanStepAttentionMessage(node.humanStep) },
|
|
642
|
+
}, lock);
|
|
643
|
+
await raiseNodeAttention(campaign.path, runDir, /** @type {NodeSnapshot} */ (states.get(node.id)), HUMAN_STEP_ERROR_CODE);
|
|
644
|
+
}
|
|
620
645
|
const slots = contract.maxParallel - running.size;
|
|
621
646
|
if (slots > 0) {
|
|
622
|
-
const
|
|
623
|
-
const state = states.get(node.id);
|
|
624
|
-
return state?.status === "pending" && !pendingSettlements.has(node.id)
|
|
625
|
-
&& node.dependsOn.every((id) => states.get(id)?.status === "done");
|
|
626
|
-
});
|
|
647
|
+
const dispatchable = ready.filter((node) => !node.humanStep);
|
|
627
648
|
// Per-runtime capacity is judged per dispatch, not per tick: the
|
|
628
649
|
// counts include what this tick has already started, and a runtime a
|
|
629
650
|
// sibling is waiting out a quota reset on accepts nothing new.
|
|
630
651
|
const counts = runningPerRuntime(running.values());
|
|
631
652
|
const held = quotaHeldRuntimes(states.values(), Date.now());
|
|
632
653
|
let dispatched = 0;
|
|
633
|
-
for (const node of
|
|
654
|
+
for (const node of dispatchable) {
|
|
634
655
|
if (dispatched >= slots) break;
|
|
635
656
|
const state = states.get(node.id);
|
|
636
657
|
if (!state || routingBackoffActive(state, state.phase)) continue;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { getHarness, probeRuntime, registeredHarnesses, resolveVendor } from "./index.mjs";
|
|
3
3
|
import { DISCOVERY_RUNTIME_DEFINITIONS, composeAssignments } from "../engine/runtime-discovery.mjs";
|
|
4
|
+
import { canonicalProvider } from "../contract/provider.mjs";
|
|
4
5
|
import { errorMessage } from "../util.mjs";
|
|
6
|
+
import { ANTHROPIC_MODEL_TIERS } from "./model-tiers.mjs";
|
|
5
7
|
import { spawnInvocation } from "../host/platform.mjs";
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -51,7 +53,7 @@ const GLM_CONTEXT_WINDOW_TOKENS = 200_000;
|
|
|
51
53
|
const GLM_ONE_MILLION_CONTEXT_WINDOW_TOKENS = 1_048_576;
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
|
-
* @typedef {{id: string, contextWindowTokens: number|null, efforts: readonly string[], defaultEffort: string|null, effortInModelId: string|null}} DeclaredModel
|
|
56
|
+
* @typedef {{id: string, contextWindowTokens: number|null, efforts: readonly string[], defaultEffort: string|null, effortInModelId: string|null, tier: number|null}} DeclaredModel
|
|
55
57
|
* @typedef {{harness: string, vendor: string|null}} ModelPath
|
|
56
58
|
* @typedef {{id: string, contextWindowTokens: number|null, efforts: string[], defaultEffort: string|null, effortInModelId: string|null, declaredBy: ModelPath[]}} ModelView
|
|
57
59
|
* @typedef {{ok: boolean, available: boolean, reason: string, version: string|null}} ProbeView
|
|
@@ -72,6 +74,9 @@ export const DECLARED_MODEL_CATALOGUES = Object.freeze({
|
|
|
72
74
|
claude: Object.freeze([
|
|
73
75
|
declaredModel("claude-sonnet-5", { efforts: CLAUDE_EFFORTS }),
|
|
74
76
|
declaredModel("claude-opus-5", { efforts: CLAUDE_EFFORTS }),
|
|
77
|
+
declaredModel("claude-opus-5-5", { efforts: CLAUDE_EFFORTS }),
|
|
78
|
+
declaredModel("claude-fable-5", { efforts: CLAUDE_EFFORTS }),
|
|
79
|
+
declaredModel("claude-fable-5-1", { efforts: CLAUDE_EFFORTS }),
|
|
75
80
|
declaredModel("claude-sonnet-4-6", { efforts: CLAUDE_EFFORTS }),
|
|
76
81
|
]),
|
|
77
82
|
codex: Object.freeze([
|
|
@@ -162,6 +167,7 @@ function declaredModel(id, options) {
|
|
|
162
167
|
efforts: options.efforts,
|
|
163
168
|
defaultEffort: options.defaultEffort ?? null,
|
|
164
169
|
effortInModelId: options.effortInModelId ?? null,
|
|
170
|
+
tier: ANTHROPIC_MODEL_TIERS[id] ?? null,
|
|
165
171
|
});
|
|
166
172
|
}
|
|
167
173
|
|
|
@@ -182,6 +188,7 @@ function agyModel(id, contextWindowTokens = null) {
|
|
|
182
188
|
efforts: AGY_EFFORTS,
|
|
183
189
|
defaultEffort: encoded,
|
|
184
190
|
effortInModelId: encoded,
|
|
191
|
+
tier: ANTHROPIC_MODEL_TIERS[id] ?? null,
|
|
185
192
|
});
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -358,7 +365,7 @@ function suggestedAllocation() {
|
|
|
358
365
|
/** @type {Record<string, {available: boolean, exhaustedUntil: string|null, reason: string}>} */
|
|
359
366
|
const availability = {};
|
|
360
367
|
for (const [id, definition] of Object.entries(DISCOVERY_RUNTIME_DEFINITIONS)) {
|
|
361
|
-
const vendor = resolveVendor(definition);
|
|
368
|
+
const vendor = canonicalProvider(definition) ?? resolveVendor(definition);
|
|
362
369
|
if (!vendor) throw new Error(`discovery runtime ${id} resolves no vendor`);
|
|
363
370
|
runtimes[id] = { ...definition, vendor };
|
|
364
371
|
availability[id] = { available: true, exhaustedUntil: null, reason: "declared" };
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -177,7 +177,7 @@ export function writesWorkspace(runtime) {
|
|
|
177
177
|
* the exec'd binary actually is, so neither gets a default here — a contract
|
|
178
178
|
* using either must declare `vendor` outright.
|
|
179
179
|
*/
|
|
180
|
-
const DEFAULT_HARNESS_VENDORS = Object.freeze({
|
|
180
|
+
export const DEFAULT_HARNESS_VENDORS = Object.freeze({
|
|
181
181
|
claude: "anthropic",
|
|
182
182
|
codex: "openai",
|
|
183
183
|
agy: "google",
|
|
@@ -322,7 +322,7 @@ export function exhaustedUntilOf(envelope, now = Date.now()) {
|
|
|
322
322
|
* @returns {{available: false, exhaustedUntil: string|null, reason: string}|null} null when text names none of the known patterns
|
|
323
323
|
*/
|
|
324
324
|
function classifyAvailabilityText(text) {
|
|
325
|
-
if (/insufficient balance/iu.test(text) || /\b402\b/u.test(text)) {
|
|
325
|
+
if (/insufficient balance|spend limit/iu.test(text) || /\b402\b/u.test(text)) {
|
|
326
326
|
return { available: false, exhaustedUntil: null, reason: "insufficient_balance" };
|
|
327
327
|
}
|
|
328
328
|
if (/quota|rate.?limit|usage limit|session limit|limit exhausted|1310/iu.test(text)) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-model tier table same-vendor review reads (R20). Separate from
|
|
3
|
+
* `catalogue.mjs` because that module imports the engine's runtime discovery,
|
|
4
|
+
* and the tier rule is read by both contract validation and runtime
|
|
5
|
+
* assignment; kept here it has no imports, so neither reader cycles back
|
|
6
|
+
* through the catalogue.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every Anthropic model's tier, in the order of the owner's price list (R20):
|
|
11
|
+
* Sonnet 2 (US$ 2 / 10 per MTok), Opus 3 (5 / 25 and 4 / 20), Fable 4 (10 /
|
|
12
|
+
* 50). Sonnet is cheap enough to work; Opus or Fable is what judges it. A
|
|
13
|
+
* model absent from this table declares no tier and cannot judge in
|
|
14
|
+
* same-vendor mode (`contract/judge-independence.mjs`).
|
|
15
|
+
*
|
|
16
|
+
* @type {Readonly<Record<string, number>>}
|
|
17
|
+
*/
|
|
18
|
+
export const ANTHROPIC_MODEL_TIERS = Object.freeze({
|
|
19
|
+
"claude-sonnet-5": 2,
|
|
20
|
+
"claude-opus-5": 3,
|
|
21
|
+
"claude-opus-5-5": 3,
|
|
22
|
+
"claude-fable-5": 4,
|
|
23
|
+
"claude-fable-5-1": 4,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The declared tier of a model reached through a given harness: only an
|
|
28
|
+
* Anthropic model run through the `claude` harness carries one today. `null`
|
|
29
|
+
* covers every other harness and every model this table does not list, which
|
|
30
|
+
* is exactly what `sameVendorTierRefusal` reads as "cannot judge in
|
|
31
|
+
* same-vendor mode".
|
|
32
|
+
*
|
|
33
|
+
* @param {string} harness
|
|
34
|
+
* @param {string} model
|
|
35
|
+
* @returns {number|null}
|
|
36
|
+
*/
|
|
37
|
+
export function declaredModelTier(harness, model) {
|
|
38
|
+
return harness === "claude" ? ANTHROPIC_MODEL_TIERS[model] ?? null : null;
|
|
39
|
+
}
|
|
@@ -307,8 +307,11 @@ export function eventItem(event) {
|
|
|
307
307
|
* window: measured 2026-09-20, "You've hit your session limit · resets 6:40pm
|
|
308
308
|
* (America/Sao_Paulo)" settled as provider_error and two faberun nodes burnt
|
|
309
309
|
* both attempts inside a minute instead of holding until the reset.
|
|
310
|
+
* "spend limit" is the account's monthly credit ceiling: measured 2026-09-25,
|
|
311
|
+
* "You've hit your monthly spend limit" on claude-fable-5-1 settled as
|
|
312
|
+
* provider_error, so the planner's reviewer list never moved to its next entry.
|
|
310
313
|
*/
|
|
311
|
-
const QUOTA_TEXT_PATTERN = /429|1310|rate.?limit|usage limit|session limit|limit exhausted|quota|too many requests/iu;
|
|
314
|
+
const QUOTA_TEXT_PATTERN = /429|1310|rate.?limit|usage limit|session limit|spend limit|limit exhausted|quota|too many requests/iu;
|
|
312
315
|
|
|
313
316
|
/**
|
|
314
317
|
* @param {string|null|undefined} text
|
package/src/host/config.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The user config at `$FABERUN_HOME/config.json`: which harnesses `setup`
|
|
3
|
-
* enabled
|
|
3
|
+
* enabled, which runtime is the default worker and judge, the machine's own
|
|
4
|
+
* ordered `judges` list (R18) -- read only when a contract declares none --
|
|
5
|
+
* and its ordered `reviewers` list (R19), `faberun plan`'s own machine
|
|
6
|
+
* default, read only when `--reviewers` names none.
|
|
4
7
|
*
|
|
5
8
|
* It is separate from `host/home.mjs`, which owns the path, because reading a
|
|
6
9
|
* config is a validation problem and writing it is an atomic-write problem;
|
|
@@ -11,7 +14,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
|
11
14
|
import { dirname } from "node:path";
|
|
12
15
|
import { configPath, faberunHome } from "./home.mjs";
|
|
13
16
|
|
|
14
|
-
/** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, updatedAt: string}} UserConfig */
|
|
17
|
+
/** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, judges?: string[], reviewers?: string[], updatedAt: string}} UserConfig */
|
|
15
18
|
|
|
16
19
|
/** Paths already reported malformed, so a process that reads twice warns once. */
|
|
17
20
|
const warned = new Set();
|
|
@@ -83,5 +86,7 @@ function isUserConfig(value) {
|
|
|
83
86
|
if (!Array.isArray(record.harnesses) || !record.harnesses.every((harness) => typeof harness === "string")) return false;
|
|
84
87
|
if (record.worker !== undefined && typeof record.worker !== "string") return false;
|
|
85
88
|
if (record.judge !== undefined && typeof record.judge !== "string") return false;
|
|
89
|
+
if (record.judges !== undefined && (!Array.isArray(record.judges) || !record.judges.every((id) => typeof id === "string"))) return false;
|
|
90
|
+
if (record.reviewers !== undefined && (!Array.isArray(record.reviewers) || !record.reviewers.every((id) => typeof id === "string"))) return false;
|
|
86
91
|
return typeof record.updatedAt === "string";
|
|
87
92
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contested-plan writer: end a planning pipeline the way every
|
|
3
|
+
* no-valid-plan exit ends it — the contested `plan.json`, its stage line and
|
|
4
|
+
* an open question on the campaign journal. Separate from `pipeline.mjs` and
|
|
5
|
+
* `rounds.mjs` (measured 2026-09-24: pipeline.mjs was 742 of 800 lines)
|
|
6
|
+
* because both the round loop and the pipeline's own outer catches reach this
|
|
7
|
+
* same exit; a plain function they can each call, rather than a closure
|
|
8
|
+
* either would have to own, is what makes that sharing possible without
|
|
9
|
+
* `rounds.mjs` importing back from `pipeline.mjs`.
|
|
10
|
+
*
|
|
11
|
+
* `plan` and `resume` ride along on the written record so `faberun plan
|
|
12
|
+
* --resolve` (R9, `src/plan/resolve.mjs`) can continue from exactly this
|
|
13
|
+
* point without redrafting or re-collecting repo facts: `plan` is the last
|
|
14
|
+
* plan a draft or revise actually validated (null when none ever did), and
|
|
15
|
+
* `resume` is everything else `resolve.mjs` needs to reassemble and freeze
|
|
16
|
+
* it — the same inputs this run itself was given.
|
|
17
|
+
*
|
|
18
|
+
* The open-question note carries a count and the finding ids only, never
|
|
19
|
+
* their text (RM-105, measured 2026-09-24: two contests in the same campaign
|
|
20
|
+
* joined every critical finding's text into one note and blew the journal's
|
|
21
|
+
* per-entry cap, `entry.text is 4706 bytes, over the 2048-byte cap`). Every
|
|
22
|
+
* finding's full text already lives in `plan.json`, which the note points at.
|
|
23
|
+
*/
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { campaignCli } from "../cli/campaign.mjs";
|
|
26
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
27
|
+
|
|
28
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
29
|
+
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
30
|
+
/** @typedef {import("./pipeline.mjs").VerificationSuites} VerificationSuites */
|
|
31
|
+
/** @typedef {{status: "contested", plansDir: string, planPath: string, findings: PlanFindingOutput[], round: number}} ContestedPipelineResult */
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* campaignId: string,
|
|
35
|
+
* phase: string,
|
|
36
|
+
* specPath: string,
|
|
37
|
+
* specDigest: string,
|
|
38
|
+
* reviewRounds: number,
|
|
39
|
+
* runtimeDefaults: {worker?: string, judge?: string},
|
|
40
|
+
* reviewers: string[],
|
|
41
|
+
* runtimes: Record<string, Record<string, unknown>>,
|
|
42
|
+
* verification: VerificationSuites,
|
|
43
|
+
* packageMode: import("./sizing.mjs").PackageMode,
|
|
44
|
+
* targetedFix: boolean,
|
|
45
|
+
* approveBelow: "standard"|"high"|"none",
|
|
46
|
+
* repoFacts: import("./repo-facts.mjs").RepoFacts,
|
|
47
|
+
* }} ContestResumeContext
|
|
48
|
+
*/
|
|
49
|
+
/** @typedef {{formatVersion: 1, status: "contested", rounds: number, findings: PlanFindingOutput[], plan: PlanOutput|null, resume: ContestResumeContext}} ContestedPlanRecord */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{
|
|
53
|
+
* campaignId: string,
|
|
54
|
+
* phase: string,
|
|
55
|
+
* cwd: string,
|
|
56
|
+
* plansDir: string,
|
|
57
|
+
* sessionId: string,
|
|
58
|
+
* findings: PlanFindingOutput[],
|
|
59
|
+
* round: number,
|
|
60
|
+
* plan: PlanOutput|null,
|
|
61
|
+
* resume: ContestResumeContext,
|
|
62
|
+
* logStage: (stage: string, extra?: Record<string, unknown>) => void,
|
|
63
|
+
* }} options
|
|
64
|
+
* @returns {Promise<ContestedPipelineResult>}
|
|
65
|
+
*/
|
|
66
|
+
export async function contestPlan({ campaignId, phase, cwd, plansDir, sessionId, findings, round, plan, resume, logStage }) {
|
|
67
|
+
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
68
|
+
const planPath = join(plansDir, "plan.json");
|
|
69
|
+
writeJsonAtomic(planPath, /** @type {ContestedPlanRecord} */ ({ formatVersion: 1, status: "contested", rounds: round, findings, plan, resume }));
|
|
70
|
+
logStage("contested", { round, criticalCount: criticalFindings.length });
|
|
71
|
+
const criticalIds = criticalFindings.map((finding) => finding.id);
|
|
72
|
+
await campaignCli([
|
|
73
|
+
"note", campaignId, "--cwd", cwd, "--session-id", sessionId,
|
|
74
|
+
"--kind", "open-question", "--question-id", `plan-${phase}-contested`,
|
|
75
|
+
"--text", `Plan for phase ${phase} is contested after ${round} review round(s) with ${criticalFindings.length} critical finding(s): ${criticalIds.join(", ")}. Each finding's node/requirement and resolution is in ${planPath}. Resolve with: faberun plan --resolve ${plansDir} --answer <finding-id>=accept and/or --answer <finding-id>=reject:<reason>.`,
|
|
76
|
+
]);
|
|
77
|
+
return { status: "contested", plansDir, planPath, findings, round };
|
|
78
|
+
}
|
package/src/plan/freeze.mjs
CHANGED
|
@@ -21,8 +21,10 @@ import { assertObject, rejectUnknown, requirePacketHash, requireString } from ".
|
|
|
21
21
|
import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
22
22
|
import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
|
|
23
23
|
import { validatePlanPhases } from "./template.mjs";
|
|
24
|
+
import { NODE_VALUE_OPTIONS, assertFilteredProofsNameTheirTest } from "./proof-scope.mjs";
|
|
24
25
|
|
|
25
26
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
27
|
+
/** @typedef {import("./human-step.mjs").HumanStep} PlanHumanStep */
|
|
26
28
|
|
|
27
29
|
/** @typedef {{runtimeId: string, model: string}} PlanParticipant */
|
|
28
30
|
/** @typedef {{id: string, severity: "minor"|"major"|"critical", nodeId?: string, text: string}} PlanFinding */
|
|
@@ -30,7 +32,7 @@ import { validatePlanPhases } from "./template.mjs";
|
|
|
30
32
|
/** @typedef {PlanProvenanceInput & {packageVersion: string, schemaVersion: number, contractVersion: string}} PlanProvenance */
|
|
31
33
|
/** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
|
|
32
34
|
/** @typedef {{path: string, digest: string}} PlanSpecIdentity */
|
|
33
|
-
/** @typedef {{formatVersion: number, contractDigest: string, spec?: PlanSpecIdentity, phases?: PlanPhase[], provenance: PlanProvenance}} FrozenPlan */
|
|
35
|
+
/** @typedef {{formatVersion: number, contractDigest: string, spec?: PlanSpecIdentity, phases?: PlanPhase[], humanSteps?: PlanHumanStep[], provenance: PlanProvenance}} FrozenPlan */
|
|
34
36
|
/** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
|
|
35
37
|
/** @typedef {{scripts?: Record<string, string>, verificationCandidates: {argv: string[], measuredMs: number}[]}} MeasuredFacts */
|
|
36
38
|
|
|
@@ -46,8 +48,6 @@ const MEASURED_TIMEOUT_MARGIN = 1.5;
|
|
|
46
48
|
/** `node --test` options that run a subset of the files they name. */
|
|
47
49
|
const FILTER_OPTIONS = ["--test-name-pattern", "--test-skip-pattern", "--test-only", "--test-shard"];
|
|
48
50
|
|
|
49
|
-
/** `node` options whose value is the next argument, so it is not a path. */
|
|
50
|
-
const NODE_VALUE_OPTIONS = new Set(["--import", "--require", "-r", "--loader", "--experimental-loader", "--env-file", "--test-reporter", "--test-reporter-destination", "--test-name-pattern", "--test-skip-pattern", "--test-concurrency", "--test-timeout"]);
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* The `node --test <dir>` candidates one path argument includes: a directory
|
|
@@ -134,6 +134,38 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
|
|
|
134
134
|
if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Raise every plan node's verification timeout that sits under
|
|
139
|
+
* `MEASURED_TIMEOUT_MARGIN` times its measured duration to exactly that
|
|
140
|
+
* bound — the one repair `assertTimeoutsCoverMeasured` names, so it has a
|
|
141
|
+
* single answer and needs no worker. A command whose bound passes
|
|
142
|
+
* `maxTimeoutSec` is left alone: splitting it is a judgement, and the check
|
|
143
|
+
* still contests it. Measured 2026-09-26 on the 3a gate: four rounds left a
|
|
144
|
+
* plan with no critical from review, contested only because three nodes gave
|
|
145
|
+
* `node --test test/harnesses` 120s against a measured 84.4s (bound 127s).
|
|
146
|
+
*
|
|
147
|
+
* @param {import("./template.mjs").PlanOutput} plan
|
|
148
|
+
* @param {MeasuredFacts} facts
|
|
149
|
+
* @returns {{plan: import("./template.mjs").PlanOutput, raised: string[]}}
|
|
150
|
+
*/
|
|
151
|
+
export function raiseTimeoutsToMeasured(plan, facts) {
|
|
152
|
+
/** @type {string[]} */
|
|
153
|
+
const raised = [];
|
|
154
|
+
const nodes = plan.nodes.map((node) => ({
|
|
155
|
+
...node,
|
|
156
|
+
verification: (node.verification ?? []).map((command) => {
|
|
157
|
+
const measuredMs = measuredMsFor(command.argv, facts);
|
|
158
|
+
if (measuredMs === null) return command;
|
|
159
|
+
const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
|
|
160
|
+
const timeoutSec = command.timeoutSec ?? 120;
|
|
161
|
+
if (timeoutSec >= requiredSec || requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) return command;
|
|
162
|
+
raised.push(`${node.id}: ${command.argv.join(" ")} ${timeoutSec}s -> ${requiredSec}s`);
|
|
163
|
+
return { ...command, timeoutSec: requiredSec };
|
|
164
|
+
}),
|
|
165
|
+
}));
|
|
166
|
+
return { plan: raised.length ? { ...plan, nodes } : plan, raised };
|
|
167
|
+
}
|
|
168
|
+
|
|
137
169
|
/** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
|
|
138
170
|
function packageVersion() {
|
|
139
171
|
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
@@ -215,6 +247,78 @@ function specIdentityOf(spec) {
|
|
|
215
247
|
return { path: /** @type {string} */ (record.path), digest: /** @type {string} */ (record.digest) };
|
|
216
248
|
}
|
|
217
249
|
|
|
250
|
+
/** The fields a human step declaration carries: requirementId|step|command. */
|
|
251
|
+
const HUMAN_STEP_FIELDS = new Set(["requirementId", "step", "command"]);
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The human steps a frozen plan records, shape-checked before anything is
|
|
255
|
+
* written. Absent or empty is legal: most plans declare none.
|
|
256
|
+
*
|
|
257
|
+
* @param {unknown} humanSteps
|
|
258
|
+
* @returns {PlanHumanStep[]|undefined}
|
|
259
|
+
*/
|
|
260
|
+
function humanStepsOf(humanSteps) {
|
|
261
|
+
if (humanSteps === undefined) return undefined;
|
|
262
|
+
if (!Array.isArray(humanSteps) || humanSteps.length === 0) return undefined;
|
|
263
|
+
return humanSteps.map((step, index) => {
|
|
264
|
+
const label = `humanSteps[${index}]`;
|
|
265
|
+
assertObject(step, label);
|
|
266
|
+
const record = /** @type {Record<string, unknown>} */ (step);
|
|
267
|
+
rejectUnknown(record, HUMAN_STEP_FIELDS, label);
|
|
268
|
+
requireString(record.requirementId, `${label}.requirementId`);
|
|
269
|
+
requireString(record.step, `${label}.step`);
|
|
270
|
+
requireString(record.command, `${label}.command`);
|
|
271
|
+
return /** @type {PlanHumanStep} */ ({
|
|
272
|
+
requirementId: /** @type {string} */ (record.requirementId),
|
|
273
|
+
step: /** @type {string} */ (record.step),
|
|
274
|
+
command: /** @type {string} */ (record.command),
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Add one human node per declared step (R16): the frozen contract node the
|
|
281
|
+
* scheduler stops on instead of dispatching (`contract/human-step.mjs`
|
|
282
|
+
* validates the shape; the scheduler reads it at dispatch time). The node the
|
|
283
|
+
* planner drafted for the requirement keeps its own provider work: the human
|
|
284
|
+
* node depends on every node that carries the requirement (matched by the
|
|
285
|
+
* already-inherited `requirementIds`), and every node that depended on one of
|
|
286
|
+
* those carriers now waits for the human node too. A requirement no node
|
|
287
|
+
* carries still gets its stop, with no dependencies. The packet is a
|
|
288
|
+
* discovery packet with no files because it is never dispatched; only its
|
|
289
|
+
* `humanStep` is read.
|
|
290
|
+
*
|
|
291
|
+
* @param {unknown} nodes
|
|
292
|
+
* @param {PlanHumanStep[]} humanSteps
|
|
293
|
+
* @returns {unknown}
|
|
294
|
+
*/
|
|
295
|
+
function addHumanStepNodes(nodes, humanSteps) {
|
|
296
|
+
if (!Array.isArray(nodes)) return nodes;
|
|
297
|
+
let records = nodes.map((node) => /** @type {Record<string, unknown>} */ (node && typeof node === "object" ? node : {}));
|
|
298
|
+
for (const step of humanSteps) {
|
|
299
|
+
const id = `human-step-${step.requirementId.toLowerCase()}`;
|
|
300
|
+
if (records.some((record) => record.id === id)) throw new TypeError(`human step node id ${id} is already a planned node id`);
|
|
301
|
+
const carriers = records.filter((record) => Array.isArray(record.requirementIds) && record.requirementIds.includes(step.requirementId));
|
|
302
|
+
const carrierIds = new Set(carriers.map((record) => /** @type {string} */ (record.id)));
|
|
303
|
+
records = records.map((record) => {
|
|
304
|
+
const dependsOn = Array.isArray(record.dependsOn) ? /** @type {string[]} */ (record.dependsOn) : [];
|
|
305
|
+
if (carrierIds.has(/** @type {string} */ (record.id)) || !dependsOn.some((dependency) => carrierIds.has(dependency))) return record;
|
|
306
|
+
return { ...record, dependsOn: [...dependsOn, id] };
|
|
307
|
+
});
|
|
308
|
+
records.push({
|
|
309
|
+
id,
|
|
310
|
+
type: "human",
|
|
311
|
+
...(carriers[0]?.phase === undefined ? {} : { phase: carriers[0].phase }),
|
|
312
|
+
requirementIds: [step.requirementId],
|
|
313
|
+
dependsOn: [...carrierIds],
|
|
314
|
+
gate: false,
|
|
315
|
+
humanStep: { step: step.step, command: step.command },
|
|
316
|
+
taskPacket: { mode: "discovery", objective: step.step, instructions: [`The operator runs \`${step.command}\` and records it done with faberun resume --answer.`], readFiles: [], writeFiles: [], symbols: [], decisions: [], nonGoals: [], verification: [] },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return records;
|
|
320
|
+
}
|
|
321
|
+
|
|
218
322
|
/**
|
|
219
323
|
* The SHA-256 of a file's exact bytes: the independent digest a frozen plan's
|
|
220
324
|
* `plan.json.sha256` sidecar carries, recomputable by a reader without parsing
|
|
@@ -272,16 +376,18 @@ export function writeFrozenPlanRecord(outDir, record) {
|
|
|
272
376
|
* validatePlanOutput applies, and recorded on plan.json verbatim.
|
|
273
377
|
* `options.spec` carries the structured spec's path and content digest; when
|
|
274
378
|
* given, both are recorded so a reader can pin the plan to the exact spec it
|
|
275
|
-
* was planned from.
|
|
379
|
+
* was planned from. `options.humanSteps` carries the operator steps R16
|
|
380
|
+
* detected in the spec's requirements (`human-step.mjs`); recorded verbatim
|
|
381
|
+
* as the explicit stop the frozen plan carries for each of them.
|
|
276
382
|
*
|
|
277
383
|
* @param {JsonObject} plan
|
|
278
384
|
* `options.facts` carries repo facts' measured durations; given, a
|
|
279
385
|
* verification timeout that does not cover one is refused.
|
|
280
386
|
*
|
|
281
|
-
* @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity, facts?: MeasuredFacts}} options
|
|
387
|
+
* @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity, humanSteps?: PlanHumanStep[], facts?: MeasuredFacts}} options
|
|
282
388
|
* @returns {FrozenPlan}
|
|
283
389
|
*/
|
|
284
|
-
export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
|
|
390
|
+
export function freezePlan(plan, { outDir, provenance, phases, spec, humanSteps, facts }) {
|
|
285
391
|
// Shape-checked before anything is written, so a malformed declaration
|
|
286
392
|
// leaves the outDir exactly as it was — the same failure discipline as the
|
|
287
393
|
// validateContract rollback below. A declaration in the nodeIds shape must
|
|
@@ -290,19 +396,23 @@ export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
|
|
|
290
396
|
// report rather than a reason to refuse the freeze.
|
|
291
397
|
const phaseDeclarations = validatePlanPhases(phases, plannedNodeIdsOf(plan.nodes));
|
|
292
398
|
const specIdentity = specIdentityOf(spec);
|
|
399
|
+
const humanStepList = humanStepsOf(humanSteps);
|
|
293
400
|
mkdirSync(outDir, { recursive: true });
|
|
294
401
|
const contractPath = join(outDir, "contract.json");
|
|
402
|
+
const requirementStampedNodes = phaseDeclarations ? stampPhaseRequirementIds(plan.nodes, phaseDeclarations) : plan.nodes;
|
|
403
|
+
const stampedNodes = humanStepList ? addHumanStepNodes(requirementStampedNodes, humanStepList) : requirementStampedNodes;
|
|
295
404
|
const raw = /** @type {JsonObject} */ ({
|
|
296
405
|
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
297
406
|
contractVersion: CONTRACT_VERSION,
|
|
298
407
|
...plan,
|
|
299
408
|
// Listed again, not mutated in place, so the caller's plan object keeps
|
|
300
409
|
// the shape it was reviewed with.
|
|
301
|
-
...(phaseDeclarations ? { nodes:
|
|
410
|
+
...(phaseDeclarations || humanStepList ? { nodes: stampedNodes } : {}),
|
|
302
411
|
});
|
|
303
412
|
writeJsonAtomic(contractPath, raw);
|
|
304
413
|
try {
|
|
305
414
|
const validated = validateContract(raw, contractPath);
|
|
415
|
+
assertFilteredProofsNameTheirTest(validated);
|
|
306
416
|
if (facts) assertTimeoutsCoverMeasured(validated, facts);
|
|
307
417
|
} catch (error) {
|
|
308
418
|
rmSync(contractPath, { force: true });
|
|
@@ -318,6 +428,7 @@ export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
|
|
|
318
428
|
// contract schema takes no extra field), so the traceability a reviewer
|
|
319
429
|
// saw is readable straight off plan.json, nodeIds included.
|
|
320
430
|
...(phaseDeclarations === undefined ? {} : { phases: phaseDeclarations }),
|
|
431
|
+
...(humanStepList === undefined ? {} : { humanSteps: humanStepList }),
|
|
321
432
|
provenance: {
|
|
322
433
|
packageVersion: packageVersion(),
|
|
323
434
|
schemaVersion: /** @type {number} */ (raw.schemaVersion),
|