faberun 0.25.1 → 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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/skills/faberun/references/contract.md +30 -26
  3. package/src/campaign/campaign-brief-graph.mjs +218 -0
  4. package/src/campaign/campaign-brief.mjs +15 -167
  5. package/src/campaign/metrics-command.mjs +3 -1
  6. package/src/cli/brand.mjs +1 -0
  7. package/src/cli/plan.mjs +75 -3
  8. package/src/cli/setup.mjs +22 -5
  9. package/src/cli.mjs +35 -1
  10. package/src/contract/definition-of-done.mjs +48 -8
  11. package/src/contract/human-step.mjs +54 -0
  12. package/src/contract/index.mjs +25 -38
  13. package/src/contract/judge-independence.mjs +170 -0
  14. package/src/contract/judges.mjs +27 -0
  15. package/src/contract/provider.mjs +103 -0
  16. package/src/contract/runtime.mjs +19 -2
  17. package/src/contract/snapshot.mjs +31 -1
  18. package/src/engine/assignment.mjs +41 -5
  19. package/src/engine/backoff.mjs +30 -9
  20. package/src/engine/dispatch.mjs +19 -2
  21. package/src/engine/failover.mjs +10 -2
  22. package/src/engine/judge-gate.mjs +24 -4
  23. package/src/engine/judge-list.mjs +206 -0
  24. package/src/engine/lifecycle.mjs +19 -16
  25. package/src/engine/resume.mjs +46 -9
  26. package/src/engine/runtime-discovery.mjs +72 -11
  27. package/src/engine/scheduler.mjs +27 -6
  28. package/src/harnesses/catalogue.mjs +9 -2
  29. package/src/harnesses/index.mjs +2 -2
  30. package/src/harnesses/model-tiers.mjs +39 -0
  31. package/src/harnesses/protocol.mjs +4 -1
  32. package/src/host/config.mjs +7 -2
  33. package/src/plan/contest.mjs +78 -0
  34. package/src/plan/freeze.mjs +118 -7
  35. package/src/plan/human-step.mjs +38 -0
  36. package/src/plan/pipeline.mjs +224 -313
  37. package/src/plan/preflight.mjs +5 -3
  38. package/src/plan/proof-check.mjs +161 -0
  39. package/src/plan/proof-run.mjs +6 -2
  40. package/src/plan/proof-scope.mjs +175 -0
  41. package/src/plan/resolve.mjs +182 -0
  42. package/src/plan/reviewer.mjs +82 -0
  43. package/src/plan/rounds.mjs +446 -0
  44. package/src/plan/routing.mjs +11 -4
  45. package/src/plan/template.mjs +36 -14
  46. package/src/repo/signal-block.mjs +10 -3
  47. package/src/repo/source-identity.mjs +45 -5
  48. package/src/repo/worktree-prune.mjs +72 -0
  49. package/src/report/campaign-brief-estimate.mjs +1 -1
  50. package/src/report/campaign-brief.mjs +4 -0
  51. package/src/report/metrics-report.mjs +34 -1
  52. package/src/report/render.mjs +31 -10
@@ -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 ready = contract.nodes.filter((node) => {
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 ready) {
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" };
@@ -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
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * The user config at `$FABERUN_HOME/config.json`: which harnesses `setup`
3
- * enabled and which runtime is the default worker and judge.
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
+ }
@@ -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: stampPhaseRequirementIds(plan.nodes, phaseDeclarations) } : {}),
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),
@@ -0,0 +1,38 @@
1
+ /**
2
+ * R16: a requirement's `constraints` bullet can declare a step only the
3
+ * operator can perform (the planner and every worker run inside a sandbox
4
+ * that is never the operator's real environment). Detected here so
5
+ * freeze.mjs can carry it on the frozen plan as an explicit stop, and the
6
+ * Campaign Brief can list it among the human decisions. Pure text matching:
7
+ * no file I/O, no git, no model.
8
+ */
9
+
10
+ /** @typedef {{requirementId: string, step: string, command: string}} HumanStep */
11
+
12
+ /** The word a requirement's constraints must use to name the actor a worker cannot be. */
13
+ const OPERATOR_KEYWORD = /\boperator\b/iu;
14
+
15
+ /** The one command the declared step names, quoted the way the spec format already writes one (spec-format.md). */
16
+ const COMMAND_FENCE = /`([^`]+)`/u;
17
+
18
+ /**
19
+ * @param {import("./spec.mjs").SpecRequirement} requirement
20
+ * @returns {HumanStep|null}
21
+ */
22
+ export function detectHumanStep(requirement) {
23
+ const text = requirement.constraints;
24
+ if (!text || !OPERATOR_KEYWORD.test(text)) return null;
25
+ const command = COMMAND_FENCE.exec(text);
26
+ if (!command) return null;
27
+ return { requirementId: requirement.id ?? requirement.title, step: text, command: command[1] };
28
+ }
29
+
30
+ /**
31
+ * @param {import("./spec.mjs").SpecRequirement[]} requirements
32
+ * @returns {HumanStep[]}
33
+ */
34
+ export function collectHumanSteps(requirements) {
35
+ return requirements
36
+ .map((requirement) => detectHumanStep(requirement))
37
+ .filter((step) => step !== null);
38
+ }