faberun 0.30.0 → 0.32.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The patch revise's narrowed context (R4, campaign-efficiency phase 4):
3
+ * what the reviser reads instead of the whole plan, the byte ceiling that
4
+ * context is held to, and the revise's own instruction sentences for it.
5
+ * Separate from `template.mjs` because the planning templates were already
6
+ * at that file's ceiling and this is a second job: `template.mjs` builds the
7
+ * five planning contracts and validates plan output, and this module owns
8
+ * the one stage whose reading narrowed — the findings' nodes, the
9
+ * dependencies those nodes need, and the plan's own declarations, never the
10
+ * whole node list and never the repository facts.
11
+ */
12
+ import { stableJson } from "../util.mjs";
13
+
14
+ /** @typedef {import("./template.mjs").PlanOutputNode} PlanOutputNode */
15
+ /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
16
+
17
+ /**
18
+ * The two openings a revise starts from, swapped by `template.mjs`'s
19
+ * `instructionsFor`: the whole plan before the first validated plan (a draft
20
+ * that never validated — the rejected draft is what its findings were raised
21
+ * against), the narrowed context `reviseContextFor` builds once there is a
22
+ * plan to patch (R4).
23
+ */
24
+ export const REVISE_OPENING = "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, definitionOfDone item and contract-level suite that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.";
25
+ export const REVISE_PATCH_OPENING = "Start from the revise context in readFiles — the nodes your findings name (changedNodes), the dependencies those nodes need (dependencyNodes), and the plan's own declarations — and change only what a finding requires. Keep every node id, write file, definitionOfDone item and contract-level suite that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.";
26
+
27
+ /** The patch-mode counterpart of the phase declaration rule: the declarations travel in the context. */
28
+ export const REVISE_PATCH_PHASES_INSTRUCTION = "The context carries the plan's phase declarations: when your patch adds or removes a node, carry output.patch.phases with every declaration, updated so the node ids it assigns still cover the plan's nodes exactly once. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.";
29
+
30
+ /** The patch-mode field of the classification rule: the revise returns `output.patch`, not `output.plan`. */
31
+ export const REVISE_PATCH_VENDOR_RULE = "Never name a runtime, harness, model, or vendor anywhere in output.patch.";
32
+
33
+ /**
34
+ * The revise's own selection sentence (R4). The reviser reads no fact set —
35
+ * the plan's readFiles were drawn from it when the plan was drafted — so it
36
+ * keeps the selection instead of selecting again. `FACT_SELECTION_RULE` (in
37
+ * `template.mjs`) stays the drafter's rule; this is the one the revise
38
+ * carries instead, the same narrowing the path-cut rule took.
39
+ */
40
+ export const REVISE_FACT_SELECTION_RULE = "Each node's readFiles in the nodes you carry is the selection its drafter already drew from the phase fact set, and the revise context holds no fact set of its own: keep the plan's readFiles unless a finding names the change, and never add a read no node declares and no finding names. The revised plan is held to the same fact set the draft was, so a read the fact set does not hold is refused after the revise that wrote it.";
41
+
42
+ /**
43
+ * The byte ceiling the patch revise's context is held to: the same 65,536
44
+ * ceiling every planning prompt is measured against (`renderWorkerPrompt`'s
45
+ * guard, and the output ceiling two full-plan revises died at, RM-110),
46
+ * because the context is the revise packet's mandatory fact.
47
+ */
48
+ export const REVISE_CONTEXT_BUDGET_BYTES = 65_536;
49
+
50
+ /**
51
+ * The narrowed context a patch revise reads (R4): the plan's own
52
+ * declarations and the nodes the findings concern with the dependencies
53
+ * those nodes need — never the whole node list. The patch still applies to
54
+ * the full plan the pipeline holds; the reviser only stops reading it,
55
+ * because answering one finding cannot need the phase's other nodes, and
56
+ * reading them is what a revise's context grew to.
57
+ *
58
+ * A finding names its node by id; a finding whose nodeId names no node of
59
+ * the plan (the pipeline's own `nodeId: "plan"` shape) contributes none, so
60
+ * the retry passes the refused attempt's own changed node ids as
61
+ * `extraNodeIds` — a validator message names those by patch index, never by
62
+ * id. Dependencies are the transitive closure of `dependsOn`, in plan order.
63
+ *
64
+ * A context over `REVISE_CONTEXT_BUDGET_BYTES` is refused by name, never
65
+ * truncated to fit and never silently widened back to the whole plan: the
66
+ * same rule `renderWorkerPrompt` applies to a worker packet, applied to the
67
+ * reviser's packet.
68
+ *
69
+ * @param {{nodes: PlanOutputNode[]} & Record<string, unknown>} plan the plan the revise revises
70
+ * @param {PlanFindingOutput[]} findings the findings the revise must resolve
71
+ * @param {string[]} [extraNodeIds] nodes the reviser must see beyond the findings' own
72
+ * @returns {Record<string, unknown>}
73
+ */
74
+ export function reviseContextFor(plan, findings, extraNodeIds = []) {
75
+ const byId = new Map(plan.nodes.map((node) => [node.id, node]));
76
+ const named = new Set();
77
+ for (const finding of findings) {
78
+ if (byId.has(finding.nodeId)) named.add(finding.nodeId);
79
+ }
80
+ for (const id of extraNodeIds) {
81
+ if (byId.has(id)) named.add(id);
82
+ }
83
+ const dependencies = new Set();
84
+ const queue = [...named];
85
+ for (let index = 0; index < queue.length; index += 1) {
86
+ const node = byId.get(queue[index]);
87
+ for (const id of node?.dependsOn ?? []) {
88
+ if (!byId.has(id) || named.has(id) || dependencies.has(id)) continue;
89
+ dependencies.add(id);
90
+ queue.push(id);
91
+ }
92
+ }
93
+ const context = {
94
+ changedNodes: plan.nodes.filter((node) => named.has(node.id)),
95
+ dependencyNodes: plan.nodes.filter((node) => dependencies.has(node.id)),
96
+ ...(plan.phases === undefined ? {} : { phases: plan.phases }),
97
+ ...(plan.sharedVerification === undefined ? {} : { sharedVerification: plan.sharedVerification }),
98
+ ...(plan.finalVerification === undefined ? {} : { finalVerification: plan.finalVerification }),
99
+ ...(plan.justification === undefined ? {} : { justification: plan.justification }),
100
+ };
101
+ const bytes = Buffer.byteLength(stableJson(context), "utf8");
102
+ if (bytes > REVISE_CONTEXT_BUDGET_BYTES) {
103
+ throw new TypeError(
104
+ `revise_context_over_budget: the revise context is ${bytes - REVISE_CONTEXT_BUDGET_BYTES} bytes over the ${REVISE_CONTEXT_BUDGET_BYTES}-byte budget at ${bytes} total (${context.changedNodes.length} changed node(s), ${context.dependencyNodes.length} dependency node(s)). The context is the revise packet's mandatory fact, so it is refused by name, never truncated to fit and never widened back to the whole plan.`,
105
+ );
106
+ }
107
+ return context;
108
+ }
@@ -9,7 +9,10 @@
9
9
  * pipeline.mjs was 742 of 800 lines) because this is the one place a round's
10
10
  * finding bookkeeping — merge what is still open, drop what a revise
11
11
  * silently shrank, resolve what a revise touched — happens together with the
12
- * in-round freeze pre-flight. `droppedWriteFindings` and `unresolvedFindings`
12
+ * in-round freeze pre-flight — whose own rules are `preflight.mjs`'s
13
+ * (`freezePreflight`), imported rather than restated, so the rules a round
14
+ * grades against are the rules the freeze applies. `droppedWriteFindings` and
15
+ * `unresolvedFindings`
13
16
  * live here rather than in `pipeline.mjs` because nothing outside this loop
14
17
  * ever calls them. `runStage`, `assembleFrozenNodes`, `frozenContractRaw`,
15
18
  * `contest`, `invalidPlanFinding` and `logStage` are supplied by the caller
@@ -19,14 +22,23 @@
19
22
  * campaign) this module has no reason to hold; injecting them keeps this
20
23
  * module free of a runtime dependency on `pipeline.mjs`, so a test can drive
21
24
  * it with fakes.
25
+ *
26
+ * The file's other half is the round bounds that are not the review/revise
27
+ * loop: `runBoundedRounds`, the hard budget the reauthor flow spends widening
28
+ * a refused packet, and `reauthorTriggerDecision`, the durable gate that
29
+ * decides whether a trigger may spend that budget at all (R5,
30
+ * campaign-efficiency phase 4). The gate is keyed to the failure journal
31
+ * rather than to this process because its whole point is to hold across
32
+ * resumes; it lives beside the budget it guards because a round bound is
33
+ * what this module owns.
22
34
  */
23
35
  import { existsSync } from "node:fs";
24
36
  import { join, relative } from "node:path";
25
- import { validateContract } from "../contract/index.mjs";
26
37
  import { writeJsonAtomic } from "../run/store.mjs";
27
38
  import { stableJson } from "../util.mjs";
28
- import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
29
- import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
39
+ import { freezePreflight } from "./preflight.mjs";
40
+ import { durableQuestion } from "./human-step.mjs";
41
+ import { reviseContextFor } from "./revise-context.mjs";
30
42
  import { applyPlanPatch, validateFindings, validatePlanOutput } from "./template.mjs";
31
43
 
32
44
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
@@ -248,22 +260,15 @@ export async function runReviewRounds(options) {
248
260
  let carriedCritical = 0;
249
261
 
250
262
  /**
251
- * The freeze pre-flight: the contract this plan would freeze into, checked
252
- * the way `freeze.mjs` checks it. Null when it would freeze.
263
+ * The deterministic checks and the two mechanical repairs, both read from
264
+ * `preflight.mjs` rather than restated here: the same set the pipeline ran on
265
+ * the draft runs again on every plan this loop grades, so a revise is held to
266
+ * the rules the freeze applies and this loop can never disagree with the
267
+ * freeze it stands in front of about what those rules are.
253
268
  *
254
- * @param {PlanOutput} candidate
255
- * @returns {unknown}
269
+ * @type {import("./preflight.mjs").PlanCheckContext}
256
270
  */
257
- const freezePreflightError = (candidate) => {
258
- try {
259
- const contract = validateContract(frozenContractRaw(assembleFrozenNodes(declareDirectoryGuards(raiseTimeoutsToMeasured(candidate, repoFacts).plan, repoFacts, cwd).plan)), join(plansDir, "contract.json"));
260
- assertFilteredProofsNameTheirTest(contract);
261
- assertTimeoutsCoverMeasured(contract, repoFacts);
262
- return null;
263
- } catch (error) {
264
- return error;
265
- }
266
- };
271
+ const preflightContext = { repoFacts, cwd, plansDir, assembleFrozenNodes, frozenContractRaw };
267
272
 
268
273
  /**
269
274
  * Run the revise stage once, validate its output and run the freeze
@@ -294,13 +299,39 @@ export async function runReviewRounds(options) {
294
299
  // the whole plan, so answering one finding costs the size of that
295
300
  // finding's nodes instead of the phase's. Two calls died on the full-plan
296
301
  // shape at 65,932 and 66,821 output tokens against the 65,536 ceiling.
297
- // The patch is only asked for when there is a validated plan to apply it
298
- // to: a first round whose draft never validated has no node list to patch,
299
- // and that round's revise returns a whole plan.
302
+ //
303
+ // R4, campaign-efficiency phase 4: once there is a validated plan to
304
+ // patch, the reviser also stops reading that plan, and reads no
305
+ // repository facts. Its context is the findings' nodes, the dependencies
306
+ // those nodes need, and the plan's own declarations (`reviseContextFor`),
307
+ // and a context over the packet ceiling is refused by name — the explicit
308
+ // overflow error R4 asks for on the reviser side — never truncated and
309
+ // never silently widened back to the whole plan; the refusal is the
310
+ // finding the round contests on. The patch still applies to the full
311
+ // plan this loop holds; only the reading narrowed. A first round whose
312
+ // draft never validated has no node list to narrow, so that round's
313
+ // revise reads the rejected draft and returns a whole plan.
300
314
  const base = plan;
301
315
  const patchMode = base !== null;
302
- const stageInputs = { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, packageMode, ...(patchMode ? { revisePatch: true } : {}) };
303
- const revise = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath });
316
+ /** @type {PlanFindingOutput|null} */
317
+ let contextFailure = null;
318
+ /** @type {string|null} */
319
+ let contextPath = null;
320
+ if (patchMode) {
321
+ const contextFile = join(scratchDir, `revise-context-round-${round}.json`);
322
+ try {
323
+ writeJsonAtomic(contextFile, reviseContextFor(/** @type {import("./template.mjs").PlanOutput} */ (base), findingsForRevise));
324
+ contextPath = relative(cwd, contextFile);
325
+ } catch (error) {
326
+ contextFailure = invalidPlanFinding(`revise-r${round}-context`, error);
327
+ }
328
+ }
329
+ if (contextFailure !== null) {
330
+ logStage("revise", { round, contextOverBudget: contextFailure.text });
331
+ return { plan: null, finding: contextFailure, firstInvalid: contextFailure };
332
+ }
333
+ const stageInputs = { specPath: relativeSpecPath, cataloguePath: relativeCataloguePath, packageMode, ...(patchMode ? { revisePatch: true } : {}) };
334
+ const revise = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, path), planPath: /** @type {string} */ (patchMode ? contextPath : relativeWorkingPlanPath) });
304
335
  /**
305
336
  * The plan a revise's output produced, before any validation: the merged
306
337
  * patch when the reviser patched a plan, its own `output.plan` when there
@@ -327,20 +358,51 @@ export async function runReviewRounds(options) {
327
358
  try {
328
359
  revision = /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */ (revisionFrom(base, revise.output));
329
360
  structurallyValid = validatePlanOutput(revision);
330
- const preflightError = freezePreflightError(structurallyValid);
331
- if (preflightError === null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid: null };
332
- firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, preflightError);
361
+ const failure = freezePreflight(structurallyValid, preflightContext).failure;
362
+ if (failure === null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid: null };
363
+ firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, failure);
333
364
  } catch (error) {
334
365
  firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, error);
335
366
  }
336
367
  const retryPath = join(scratchDir, `findings-round-${round}-retry.json`);
337
- writeJsonAtomic(retryPath, [...findingsForRevise, firstInvalid]);
368
+ const retryFindings = [...findingsForRevise, firstInvalid];
369
+ writeJsonAtomic(retryPath, retryFindings);
338
370
  // The retry repairs the revision the check refused, not the plan before
339
- // it: that revision already carries this round's resolutions, and only the
340
- // check's message is left to answer.
341
- const retryPlanPath = join(scratchDir, `plan-round-${round}-rejected.json`);
342
- writeJsonAtomic(retryPlanPath, revision ?? base);
343
- const retry = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, retryPath), planPath: relative(cwd, retryPlanPath) });
371
+ // it: that revision already carries this round's resolutions, and only
372
+ // the check's message is left to answer.
373
+ const retryFrom = revision ?? base;
374
+ /** @type {string} */
375
+ let retryPlanPath;
376
+ if (patchMode) {
377
+ // A validator message names the refused attempt's nodes by their index
378
+ // in its patch, and no finding names them by id: the nodes the first
379
+ // attempt changed ride into the retry's context beside the findings'
380
+ // own.
381
+ /** @type {string[]} */
382
+ let firstAttemptNodes = [];
383
+ if (revision !== null) {
384
+ const before = new Map(base.nodes.map((node) => [node.id, stableJson(node)]));
385
+ firstAttemptNodes = revision.nodes.filter((node) => before.get(node.id) !== stableJson(node)).map((node) => node.id);
386
+ }
387
+ const retryContextFile = join(scratchDir, `revise-context-round-${round}-retry.json`);
388
+ try {
389
+ writeJsonAtomic(retryContextFile, reviseContextFor(/** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */ (retryFrom), retryFindings, firstAttemptNodes));
390
+ retryPlanPath = relative(cwd, retryContextFile);
391
+ } catch (error) {
392
+ // The retry's context does not fit either: the first attempt's own
393
+ // output, when it validated, still goes to review rather than
394
+ // contesting a round over a retry it cannot hand a context to.
395
+ if (structurallyValid !== null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid };
396
+ const finding = invalidPlanFinding(`revise-r${round}-context-retry`, error);
397
+ logStage("revise", { round, contextOverBudget: finding.text });
398
+ return { plan: null, finding, firstInvalid };
399
+ }
400
+ } else {
401
+ const rejectedFile = join(scratchDir, `plan-round-${round}-rejected.json`);
402
+ writeJsonAtomic(rejectedFile, retryFrom);
403
+ retryPlanPath = relative(cwd, rejectedFile);
404
+ }
405
+ const retry = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, retryPath), planPath: retryPlanPath });
344
406
  try {
345
407
  // A retry that validates goes to review even if the freeze pre-flight
346
408
  // still refuses it: that round's own pre-flight raises it again. Its
@@ -364,13 +426,14 @@ export async function runReviewRounds(options) {
364
426
  // scope-closure pre-flight), and R14 stopped the plan after 2 of 4 rounds.
365
427
  const reviewed = plan !== null;
366
428
  if (plan) {
367
- // R14's mechanical repair with a single answer, applied before review so
368
- // the reviewer grades, and freeze checks, the plan that would ship.
369
- const timeouts = raiseTimeoutsToMeasured(plan, repoFacts);
370
- if (timeouts.raised.length) logStage("timeouts-raised", { round, raised: timeouts.raised });
371
- const guards = declareDirectoryGuards(timeouts.plan, repoFacts, cwd);
372
- if (guards.declared.length) logStage("guards-declared", { round, declared: guards.declared });
373
- plan = guards.plan;
429
+ // R14's mechanical repairs with a single answer, and the freeze rules
430
+ // they answer to, all measured before the review is dispatched: the
431
+ // reviewer grades — and freeze checks — the plan that would ship, and
432
+ // the failure below is measured against the bytes the reviewer read.
433
+ const checks = freezePreflight(plan, preflightContext);
434
+ if (checks.raised.length) logStage("timeouts-raised", { round, raised: checks.raised });
435
+ if (checks.declared.length) logStage("guards-declared", { round, declared: checks.declared });
436
+ plan = checks.plan;
374
437
  // The reviewer grades a structurally valid plan; an invalid one skips
375
438
  // review and reaches revise through the validator's finding instead.
376
439
  writeJsonAtomic(workingPlanPath, plan);
@@ -391,9 +454,9 @@ export async function runReviewRounds(options) {
391
454
  invalidFindings = invalidPlanFinding(`review-r${round}`, error);
392
455
  findings = [...findings, invalidFindings];
393
456
  }
394
- // The contract this plan would freeze into is checked here, inside the
395
- // round and after the review's findings merged into the open ones,
396
- // because freeze runs after the last one: caught here, a plan that
457
+ // The contract this plan would freeze into was checked above, before the
458
+ // review ran, and its failure rides on this round's line and findings
459
+ // because freeze runs after the last round: caught here, a plan that
397
460
  // cannot freeze still has a revise left to fix it. The failure is a
398
461
  // critical finding, never an auto-filled acknowledgement — scope
399
462
  // closure exists to force the per-file decision (declare a write, or
@@ -401,9 +464,8 @@ export async function runReviewRounds(options) {
401
464
  // read the repository, makes it from the validator's own message.
402
465
  /** @type {PlanFindingOutput|null} */
403
466
  let freezeFailure = null;
404
- const preflightError = freezePreflightError(plan);
405
- if (preflightError !== null) {
406
- freezeFailure = invalidPlanFinding(`freeze-r${round}`, preflightError);
467
+ if (checks.failure !== null) {
468
+ freezeFailure = invalidPlanFinding(`freeze-r${round}`, checks.failure);
407
469
  findings = [...findings, freezeFailure];
408
470
  }
409
471
  logStage("review", {
@@ -495,3 +557,76 @@ export async function runBoundedRounds(rounds, step) {
495
557
  }
496
558
  return { accepted: false, roundsUsed: rounds, last: history.at(-1) ?? null, history };
497
559
  }
560
+
561
+ /**
562
+ * A reauthor trigger as the failure journal records it: the classified cause,
563
+ * the refused node, and the refused packet's hash. Whatever else the journal
564
+ * line carries (outcome, timestamp) rides in the index signature — the gate
565
+ * counts every record on the key, whatever its outcome.
566
+ *
567
+ * @typedef {{cause: string, node: string, artifactVersion: string, [key: string]: unknown}} ReauthorRecord
568
+ */
569
+
570
+ /**
571
+ * @typedef {{action: "widen"} | {action: "refuse", reason: string} | {action: "ask", question: import("./human-step.mjs").DurableQuestion}} ReauthorTriggerDecision
572
+ */
573
+
574
+ /**
575
+ * The hard cap, per node and packet version, on reauthor triggers: once this
576
+ * many triggers are journaled against the same `(node, artifactVersion)` with
577
+ * the packet unchanged, a further trigger is refused whatever its cause. No
578
+ * measurement stands behind the number itself — it mirrors the one retry
579
+ * `reviseOnce` grants a revise (two chances to answer what was objected), and
580
+ * the journal makes the bound durable across resumes, which is the property
581
+ * R5 asks for: without it, a fresh process would start a fresh budget and the
582
+ * same refusal would spend a widening call on every resume.
583
+ */
584
+ export const REAUTHOR_ROUND_CAP = 2;
585
+
586
+ /**
587
+ * The one decision a packet-refusal trigger passes before the reauthor flow
588
+ * makes a single widening call (R5, campaign-efficiency phase 4). `records`
589
+ * is what reauthor.jsonl already journals for every outcome (n5,
590
+ * failure-cause classes); the widened packet itself is validated where it is
591
+ * applied, as it already is — this gate decides only whether a trigger may
592
+ * spend a widening round, never whether a proposal is sound.
593
+ *
594
+ * In order:
595
+ *
596
+ * - a trigger carrying no classified cause cannot be keyed into the journal,
597
+ * so it is refused before any call;
598
+ * - `ambiguous_requirement` becomes the durable question and never a widening
599
+ * round — widening cannot disambiguate prose (`durableQuestion`). An ask
600
+ * makes no provider call, so it is not capped and not deduplicated: asking
601
+ * again is the same durable question, not another attempt;
602
+ * - the exact same cause on the same node and packet version is refused —
603
+ * the journal already carrying it is the evidence that widening did not
604
+ * progress, and a repeat is how the call count goes indefinite;
605
+ * - the round cap counts every recorded trigger on that node and version,
606
+ * whatever its outcome: an applied widening is gone from this key by
607
+ * construction (the widened packet changes the hash and starts a fresh
608
+ * budget — progress resets the bound), while approval_required and
609
+ * rounds_exhausted spent their calls without one;
610
+ * - everything else widens.
611
+ *
612
+ * @param {ReauthorRecord[]} records
613
+ * @param {{cause: string, node: string, artifactVersion: string}} trigger
614
+ * @returns {ReauthorTriggerDecision}
615
+ */
616
+ export function reauthorTriggerDecision(records, trigger) {
617
+ if (!trigger.cause) {
618
+ return { action: "refuse", reason: `reauthor trigger on node ${trigger.node} carries no classified cause; widening is refused` };
619
+ }
620
+ if (trigger.cause === "ambiguous_requirement") {
621
+ const question = durableQuestion(trigger);
622
+ return { action: "ask", question: /** @type {import("./human-step.mjs").DurableQuestion} */ (question) };
623
+ }
624
+ const onVersion = records.filter((record) => record.node === trigger.node && record.artifactVersion === trigger.artifactVersion);
625
+ if (onVersion.some((record) => record.cause === trigger.cause)) {
626
+ return { action: "refuse", reason: `cause ${trigger.cause} already triggered a reauthor on node ${trigger.node} at ${trigger.artifactVersion} and the packet has not changed; the same refusal does not widen again` };
627
+ }
628
+ if (onVersion.length >= REAUTHOR_ROUND_CAP) {
629
+ return { action: "refuse", reason: `reauthor round cap ${REAUTHOR_ROUND_CAP} reached on node ${trigger.node} at ${trigger.artifactVersion}: ${onVersion.length} trigger(s) journaled without the packet changing` };
630
+ }
631
+ return { action: "widen" };
632
+ }
@@ -20,7 +20,7 @@ import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
20
20
  /** @typedef {{id: string, dependsOn?: string[], taskKind?: string, riskTier?: string, taskPacket: SizingTaskPacket, definitionOfDone?: DefinitionOfDoneItem[], parallel?: boolean, [key: string]: unknown}} PlanNode */
21
21
  /** @typedef {{nodes: PlanNode[], justification?: string, [key: string]: unknown}} Plan */
22
22
  /** @typedef {{path: string, covers: string[]}} SizingTestFileEntry */
23
- /** @typedef {{testFiles?: SizingTestFileEntry[]}} SizingFacts */
23
+ /** @typedef {{paths?: string[], testFiles?: SizingTestFileEntry[]}} SizingFacts */
24
24
  /** @typedef {"implementation"|"exploratory"} PackageMode */
25
25
  /** @typedef {{nodeBudgetMs: number, targetedFix?: boolean, facts?: SizingFacts, minWriteFiles?: number, maxMergedWriteFiles?: number, turnCeiling?: number, packageMode?: PackageMode, readVolume?: (path: string) => number|null}} SizingOptions */
26
26
  /** @typedef {{rule: string, nodes: string[], detail: string}} SizingTransformation */
@@ -74,6 +74,7 @@ export function applySizingRules(plan, options) {
74
74
  nodes = markParallelisable(nodes, transformations);
75
75
  nodes = flagOverTurnCeiling(nodes, options.turnCeiling ?? null, transformations);
76
76
  if (exploratory) nodes = flagReadVolumeImbalance(nodes, options.readVolume ?? null, transformations);
77
+ refuseReadSelectionOutsideFactSet(nodes, facts);
77
78
 
78
79
  if (nodes.length === 1 && !targetedFix) {
79
80
  throw new Error(`sizing_single_node_plan: node ${nodes[0].id} is the plan's only node; pass options.targetedFix to allow a single-node plan`);
@@ -93,6 +94,37 @@ export function applySizingRules(plan, options) {
93
94
  };
94
95
  }
95
96
 
97
+ /**
98
+ * A worker packet's readFiles is its declared selection from the phase fact
99
+ * set (R4, campaign-efficiency phase 4): the facts repo-facts.json measured
100
+ * for this phase. A read the fact set does not hold is context nothing in the
101
+ * phase vouches for, so the plan is refused in-round — named, so the revise
102
+ * that repairs it can act — rather than frozen into a packet whose context
103
+ * outgrew what was measured. This is PATH_CUT_RULE's absence rule, not its
104
+ * opposite: absence from `paths` is not proof a file does not exist, only
105
+ * proof it is not this phase's measured context.
106
+ *
107
+ * Skipped when the caller hands no fact set at all (`paths` absent —
108
+ * hand-built test fixtures; `collectRepoFacts` always produces it), because
109
+ * there is then nothing to have selected from.
110
+ *
111
+ * @param {PlanNode[]} nodes
112
+ * @param {SizingFacts} facts
113
+ * @returns {void}
114
+ */
115
+ function refuseReadSelectionOutsideFactSet(nodes, facts) {
116
+ const factSet = Array.isArray(facts.paths) ? new Set(facts.paths) : null;
117
+ if (!factSet) return;
118
+ for (const node of nodes) {
119
+ const readFiles = Array.isArray(node.taskPacket.readFiles) ? /** @type {string[]} */ (node.taskPacket.readFiles) : [];
120
+ for (const path of readFiles) {
121
+ if (!factSet.has(path)) {
122
+ throw new Error(`sizing_read_outside_fact_set: node ${node.id} declares readFiles entry ${path}, which the phase fact set does not hold; select each node's read files from repo-facts.json's paths and testFiles — a read outside the fact set is context nothing in this phase measured`);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
96
128
  /**
97
129
  * Whether a node's Definition of Done carries at least one mechanical proof
98
130
  * (`command`, `path`, or `verification`). A `judgment: true` item, or no