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.
- package/package.json +1 -1
- package/src/campaign/campaign-brief.mjs +17 -6
- package/src/campaign/chain.mjs +124 -8
- package/src/campaign/ledger.mjs +19 -7
- package/src/campaign/metrics-command.mjs +68 -9
- package/src/campaign/metrics.mjs +75 -45
- package/src/campaign/record.mjs +8 -1
- package/src/campaign/reserve.mjs +340 -0
- package/src/campaign/spend.mjs +229 -0
- package/src/campaign/unpark.mjs +54 -1
- package/src/campaign/watch.mjs +314 -37
- package/src/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- package/src/contract/judge-envelope.mjs +17 -2
- package/src/contract/scope-findings.mjs +3 -1
- package/src/contract/task-packet.mjs +40 -5
- package/src/contract/verification.mjs +67 -1
- package/src/engine/aux-admission.mjs +124 -0
- package/src/engine/capacity.mjs +78 -0
- package/src/engine/dispatch.mjs +116 -23
- package/src/engine/failure-cause.mjs +59 -0
- package/src/engine/lifecycle.mjs +3 -2
- package/src/engine/phase-session.mjs +56 -10
- package/src/engine/prompts.mjs +57 -2
- package/src/engine/reserve-admission.mjs +99 -0
- package/src/engine/resume.mjs +3 -1
- package/src/engine/review.mjs +21 -0
- package/src/engine/run-command.mjs +166 -11
- package/src/engine/scheduler.mjs +92 -88
- package/src/engine/settle.mjs +15 -8
- package/src/engine/settlement-overlap.mjs +143 -0
- package/src/engine/verify.mjs +131 -7
- package/src/harnesses/session-metrics.mjs +112 -20
- package/src/notify/index.mjs +86 -10
- package/src/plan/freeze.mjs +7 -2
- package/src/plan/human-step.mjs +36 -0
- package/src/plan/pipeline.mjs +53 -22
- package/src/plan/preflight.mjs +81 -5
- package/src/plan/progress.mjs +101 -1
- package/src/plan/repo-facts.mjs +403 -49
- package/src/plan/resolve.mjs +7 -0
- package/src/plan/revise-context.mjs +108 -0
- package/src/plan/rounds.mjs +180 -45
- package/src/plan/sizing.mjs +33 -1
- package/src/plan/template.mjs +134 -22
- package/src/repo/integrate.mjs +47 -5
- package/src/report/campaign-brief-estimate.mjs +47 -1
- package/src/report/campaign-brief.mjs +31 -2
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +13 -1
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/report/render.mjs +1 -1
- package/src/report/report-html.mjs +235 -0
- package/src/report/report-video.mjs +323 -0
- package/src/run/usage-windows.mjs +99 -4
- package/src/run/usage.mjs +69 -7
- package/src/web/api.mjs +179 -24
- package/src/web/app.mjs +27 -2
- 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
|
+
}
|
package/src/plan/rounds.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
29
|
-
import {
|
|
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
|
|
252
|
-
*
|
|
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
|
-
* @
|
|
255
|
-
* @returns {unknown}
|
|
269
|
+
* @type {import("./preflight.mjs").PlanCheckContext}
|
|
256
270
|
*/
|
|
257
|
-
const
|
|
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
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
331
|
-
if (
|
|
332
|
-
firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`,
|
|
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
|
-
|
|
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
|
|
340
|
-
// check's message is left to answer.
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
|
|
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
|
|
395
|
-
//
|
|
396
|
-
// because freeze runs after the last
|
|
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
|
-
|
|
405
|
-
|
|
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
|
+
}
|
package/src/plan/sizing.mjs
CHANGED
|
@@ -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
|