faberun 0.25.1 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +30 -26
- package/src/campaign/campaign-brief-graph.mjs +218 -0
- package/src/campaign/campaign-brief.mjs +15 -167
- package/src/campaign/metrics-command.mjs +3 -1
- package/src/cli/brand.mjs +1 -0
- package/src/cli/launch.mjs +14 -1
- package/src/cli/plan.mjs +75 -3
- package/src/cli/setup.mjs +22 -5
- package/src/cli.mjs +35 -1
- package/src/contract/definition-of-done.mjs +48 -8
- package/src/contract/human-step.mjs +54 -0
- package/src/contract/index.mjs +25 -38
- package/src/contract/judge-independence.mjs +170 -0
- package/src/contract/judges.mjs +27 -0
- package/src/contract/provider.mjs +103 -0
- package/src/contract/runtime.mjs +19 -2
- package/src/contract/snapshot.mjs +31 -1
- package/src/engine/assignment.mjs +41 -5
- package/src/engine/backoff.mjs +30 -9
- package/src/engine/dispatch.mjs +19 -2
- package/src/engine/failover.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -4
- package/src/engine/judge-list.mjs +206 -0
- package/src/engine/lifecycle.mjs +19 -16
- package/src/engine/live-preflight.mjs +45 -7
- package/src/engine/resume.mjs +46 -9
- package/src/engine/run-identity.mjs +9 -6
- package/src/engine/runtime-discovery.mjs +72 -11
- package/src/engine/scheduler.mjs +27 -6
- package/src/harnesses/catalogue.mjs +9 -2
- package/src/harnesses/index.mjs +2 -2
- package/src/harnesses/model-tiers.mjs +39 -0
- package/src/harnesses/protocol.mjs +4 -1
- package/src/host/config.mjs +7 -2
- package/src/plan/contest.mjs +78 -0
- package/src/plan/freeze.mjs +118 -7
- package/src/plan/human-step.mjs +38 -0
- package/src/plan/pipeline.mjs +224 -313
- package/src/plan/preflight.mjs +5 -3
- package/src/plan/proof-check.mjs +161 -0
- package/src/plan/proof-run.mjs +6 -2
- package/src/plan/proof-scope.mjs +175 -0
- package/src/plan/resolve.mjs +182 -0
- package/src/plan/reviewer.mjs +82 -0
- package/src/plan/rounds.mjs +446 -0
- package/src/plan/routing.mjs +11 -4
- package/src/plan/template.mjs +36 -14
- package/src/repo/signal-block.mjs +10 -3
- package/src/repo/source-identity.mjs +45 -5
- package/src/repo/worktree-prune.mjs +72 -0
- package/src/report/campaign-brief-estimate.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/metrics-report.mjs +34 -1
- package/src/report/render.mjs +31 -10
package/src/plan/preflight.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { preflightRuntimes } from "../engine/live-preflight.mjs";
|
|
|
10
10
|
import { liveSilenceCause } from "../engine/live-silence.mjs";
|
|
11
11
|
import { harnessCapabilities } from "../harnesses/index.mjs";
|
|
12
12
|
import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
|
|
13
|
+
import { effectiveProvider } from "../contract/provider.mjs";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The runtimes a planning run will spend, asked once before its first stage.
|
|
@@ -109,10 +110,11 @@ export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode
|
|
|
109
110
|
const seen = new Set();
|
|
110
111
|
for (let id = /** @type {string|undefined} */ (worker); id && runtimes[id] && !seen.has(id); id = /** @type {string|undefined} */ (runtimes[id].fallback)) {
|
|
111
112
|
seen.add(id);
|
|
112
|
-
|
|
113
|
+
const provider = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[id]));
|
|
114
|
+
if (provider !== undefined) vendors.add(provider);
|
|
113
115
|
}
|
|
114
|
-
const judgeVendor = runtimes[judge]
|
|
115
|
-
if (
|
|
116
|
+
const judgeVendor = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[judge]));
|
|
117
|
+
if (judgeVendor !== undefined && vendors.has(judgeVendor)) {
|
|
116
118
|
throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with worker ${worker} or its fallback, so no frozen node could route its judge; name a judge of another vendor`);
|
|
117
119
|
}
|
|
118
120
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deterministic stage between the draft and the first review round (R15):
|
|
3
|
+
* finds a Definition of Done proof no node can satisfy, without invoking a
|
|
4
|
+
* model. Two shapes of unsatisfiable proof are checked: a
|
|
5
|
+
* `--test-name-pattern` naming no test the tree has and no test file any
|
|
6
|
+
* node's `writeFiles` promises to add, and a bare `grep`/`grep -c` proof whose
|
|
7
|
+
* own DoD text claims the absence of what it greps for — grep exits 1 when
|
|
8
|
+
* nothing matches, so a proof shaped that way cannot exit 0 in the very state
|
|
9
|
+
* the node promises. Lives apart from `rounds.mjs` because both the pipeline
|
|
10
|
+
* (before any round) and every later round's own findings feed off the same
|
|
11
|
+
* plan-wide scan; this module owns none of the round bookkeeping.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { shellWords } from "../util.mjs";
|
|
16
|
+
import { NAME_PATTERN_FLAG } from "./proof-run.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
19
|
+
/** @typedef {import("./template.mjs").PlanOutputNode} PlanOutputNode */
|
|
20
|
+
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
21
|
+
/** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
|
|
22
|
+
/** @typedef {import("./repo-facts.mjs").RepoFacts} RepoFacts */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A test title inside a `test(...)`/`test.only(...)`/`test.skip(...)` call,
|
|
26
|
+
* quoted with any of the three ordinary JS quote characters.
|
|
27
|
+
*/
|
|
28
|
+
const TEST_TITLE = /\btest(?:\.\w+)?\(\s*(["'`])((?:\\.|(?!\1).)*)\1/gu;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Words an English Definition of Done uses to promise that something is
|
|
32
|
+
* gone, not present — the direction in which a bare `grep` cannot exit 0.
|
|
33
|
+
* Text-based and therefore inexact, but the same restraint the rest of this
|
|
34
|
+
* pipeline already accepts for a deterministic check: a plain `grep` proving
|
|
35
|
+
* presence (the ordinary case) is left alone, because flagging every `grep`
|
|
36
|
+
* regardless of direction would drown the one finding this stage exists for.
|
|
37
|
+
*/
|
|
38
|
+
const ABSENCE_CLAIM = /\b(?:no longer|not |never |removed|absent|without |must not|should not|does ?n[o']t|isn['’]t|no more|zero (?:occurrences|matches|instances)|nowhere)\b/iu;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The command text a DoD proof actually runs: `kind: "command"` carries it
|
|
42
|
+
* verbatim, `kind: "verification"` carries the (already index-normalized,
|
|
43
|
+
* see `contract/definition-of-done.mjs`) position of one of the node's own
|
|
44
|
+
* `verification` entries. `kind: "path"` has no command at all.
|
|
45
|
+
*
|
|
46
|
+
* @param {PlanOutputNode} node
|
|
47
|
+
* @param {DefinitionOfDoneItem["proof"]} proof
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
function proofCommandText(node, proof) {
|
|
51
|
+
if (!proof) return null;
|
|
52
|
+
if (proof.kind === "command") return proof.ref;
|
|
53
|
+
if (proof.kind === "verification") {
|
|
54
|
+
const command = node.verification[Number(proof.ref)];
|
|
55
|
+
return command ? command.argv.join(" ") : null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether `pattern` (a `--test-name-pattern` value, a regular expression) is
|
|
62
|
+
* satisfied by a test title already declared in one of `repoFacts.testFiles`
|
|
63
|
+
* — read straight off disk, since repo facts records only the path, never
|
|
64
|
+
* the titles inside it.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} cwd
|
|
67
|
+
* @param {RepoFacts} repoFacts
|
|
68
|
+
* @param {string} pattern
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function matchesAnExistingTest(cwd, repoFacts, pattern) {
|
|
72
|
+
/** @type {RegExp} */
|
|
73
|
+
let regex;
|
|
74
|
+
try {
|
|
75
|
+
regex = new RegExp(pattern, "u");
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of repoFacts.testFiles) {
|
|
80
|
+
/** @type {string} */
|
|
81
|
+
let source;
|
|
82
|
+
try {
|
|
83
|
+
source = readFileSync(join(cwd, entry.path), "utf8");
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const match of source.matchAll(TEST_TITLE)) {
|
|
88
|
+
if (regex.test(match[2])) return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {PlanOutput} plan @returns {boolean} */
|
|
95
|
+
function someNodeWritesATestFile(plan) {
|
|
96
|
+
return plan.nodes.some((node) => node.writeFiles.some((path) => /^test\/.*\.test\.mjs$/u.test(path)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether `commandText` is a `grep` proof no revise can protect against
|
|
101
|
+
* failing in the state it promises: the base command is `grep` (optionally
|
|
102
|
+
* `-c`), and it carries neither of the two shapes that make its exit code
|
|
103
|
+
* mean what a proof needs (`! grep -q …`, or a trailing `|| true`, which
|
|
104
|
+
* always exits 0 whatever grep found).
|
|
105
|
+
*
|
|
106
|
+
* @param {string} commandText
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
function isUnguardedGrep(commandText) {
|
|
110
|
+
const tokens = shellWords(commandText);
|
|
111
|
+
if (tokens.length === 0) return false;
|
|
112
|
+
const negated = tokens[0] === "!";
|
|
113
|
+
const base = negated ? tokens[1] : tokens[0];
|
|
114
|
+
if (base !== "grep") return false;
|
|
115
|
+
if (negated && tokens.includes("-q")) return false;
|
|
116
|
+
if (tokens.length >= 2 && tokens[tokens.length - 2] === "||" && tokens[tokens.length - 1] === "true") return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every DoD proof in `plan` no node can satisfy, checked without a model:
|
|
122
|
+
* see the module header for the two shapes this looks for. Returns one
|
|
123
|
+
* finding per unsatisfiable proof, in plan order, ready to merge into the
|
|
124
|
+
* findings the first review round already carries.
|
|
125
|
+
*
|
|
126
|
+
* @param {PlanOutput} plan
|
|
127
|
+
* @param {RepoFacts} repoFacts
|
|
128
|
+
* @param {string} cwd
|
|
129
|
+
* @returns {PlanFindingOutput[]}
|
|
130
|
+
*/
|
|
131
|
+
export function checkPlanProofs(plan, repoFacts, cwd) {
|
|
132
|
+
/** @type {PlanFindingOutput[]} */
|
|
133
|
+
const findings = [];
|
|
134
|
+
for (const node of plan.nodes) {
|
|
135
|
+
for (const item of node.definitionOfDone) {
|
|
136
|
+
const commandText = proofCommandText(node, item.proof);
|
|
137
|
+
if (commandText === null) continue;
|
|
138
|
+
const nameFlag = NAME_PATTERN_FLAG.exec(commandText);
|
|
139
|
+
if (nameFlag) {
|
|
140
|
+
const pattern = nameFlag[1] ?? nameFlag[2] ?? nameFlag[3];
|
|
141
|
+
if (matchesAnExistingTest(cwd, repoFacts, pattern) || someNodeWritesATestFile(plan)) continue;
|
|
142
|
+
findings.push({
|
|
143
|
+
id: `proof-unmet-${node.id}-${item.id}`,
|
|
144
|
+
severity: "critical",
|
|
145
|
+
nodeId: node.id,
|
|
146
|
+
text: `Node ${node.id}'s Definition of Done item "${item.id}" proves itself with --test-name-pattern="${pattern}", which matches no test already in the tree and no test file any node's writeFiles declares. Either name a test that already exists, or have some node's writeFiles add the test file this pattern will match.`,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (isUnguardedGrep(commandText) && ABSENCE_CLAIM.test(item.text)) {
|
|
151
|
+
findings.push({
|
|
152
|
+
id: `proof-unmet-${node.id}-${item.id}`,
|
|
153
|
+
severity: "critical",
|
|
154
|
+
nodeId: node.id,
|
|
155
|
+
text: `Node ${node.id}'s Definition of Done item "${item.id}" claims an absence ("${item.text}") but proves it with a bare grep ("${commandText}"): grep exits 1 when nothing matches, so this proof cannot exit 0 in the state the node promises. Write it as "! grep -q …" instead.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
package/src/plan/proof-run.mjs
CHANGED
|
@@ -106,8 +106,12 @@ function runShellFull(cwd, command, probes = {}, timeoutMs = MEASURE_TIMEOUT_MS)
|
|
|
106
106
|
return { output: combined, exitCode: result.status, truncated: Buffer.byteLength(combined, "utf8") > MEASURE_OUTPUT_CAP_BYTES, timedOut };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
109
|
+
/**
|
|
110
|
+
* `--test-name-pattern` in a shell command, quoted or bare, `=` or space.
|
|
111
|
+
* Exported for `proof-check.mjs`, whose pre-review stage extracts the same
|
|
112
|
+
* flag from a DoD proof's own command text to judge it before any node runs.
|
|
113
|
+
*/
|
|
114
|
+
export const NAME_PATTERN_FLAG = /--test-name-pattern(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/u;
|
|
111
115
|
/** A passing test as the spec reporter (`✔ name (1ms)`) or TAP (`ok 1 - name`) prints it. */
|
|
112
116
|
const PASSING_TEST_LINE = /^\s*(?:\u2714\s+|ok \d+ - )(.+?)(?:\s+\(\d[\d.]*m?s\))?\s*$/gmu;
|
|
113
117
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof and scope coherence decided when a plan freezes, before any worker
|
|
3
|
+
* attempt is spent on a node that cannot pass: a name-filtered node:test proof
|
|
4
|
+
* must name the file its test lives in (AP1 of safe-to-hand-to-a-friend), and
|
|
5
|
+
* a node that creates a file in a directory a test enumerates by name must be
|
|
6
|
+
* allowed to edit that test (AP2). Separate from `freeze.mjs` because both
|
|
7
|
+
* read the target repository's test sources, which the freeze's contract and
|
|
8
|
+
* timeout checks never do, and from `proof-check.mjs` because that stage only
|
|
9
|
+
* raises findings for a review round, while these run on the frozen shape.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { shellWords } from "../util.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
16
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
17
|
+
|
|
18
|
+
/** `node` options whose value is the next argument, so it is not a path. */
|
|
19
|
+
export 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"]);
|
|
20
|
+
|
|
21
|
+
const NAME_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
|
|
22
|
+
|
|
23
|
+
/** A test title in a `test(…)`/`it(…)`/`describe(…)` call, any of the three JS quotes. */
|
|
24
|
+
const TEST_TITLE = /\b(?:test|it|describe)(?:\.\w+)?\(\s*(["'`])((?:\\.|(?!\1).)*)\1/gu;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The name filters and path arguments of a `node --test` command, or null
|
|
28
|
+
* when the command is not one.
|
|
29
|
+
*
|
|
30
|
+
* @param {string[]} argv
|
|
31
|
+
* @returns {{nameFilters: Array<{flag: string, value: string}>, paths: string[]}|null}
|
|
32
|
+
*/
|
|
33
|
+
function nodeTestShape(argv) {
|
|
34
|
+
if (argv[0] !== "node" || !argv.includes("--test")) return null;
|
|
35
|
+
/** @type {Array<{flag: string, value: string}>} */
|
|
36
|
+
const nameFilters = [];
|
|
37
|
+
/** @type {string[]} */
|
|
38
|
+
const paths = [];
|
|
39
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
40
|
+
const token = argv[index];
|
|
41
|
+
const inline = NAME_FILTER_FLAGS.find((flag) => token.startsWith(`${flag}=`));
|
|
42
|
+
if (inline) nameFilters.push({ flag: inline, value: token.slice(inline.length + 1) });
|
|
43
|
+
else if (NAME_FILTER_FLAGS.includes(token)) nameFilters.push({ flag: token, value: argv[index + 1] ?? "" });
|
|
44
|
+
if (NODE_VALUE_OPTIONS.has(token)) index += 1;
|
|
45
|
+
else if (!token.startsWith("-")) paths.push(token);
|
|
46
|
+
}
|
|
47
|
+
return { nameFilters, paths };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refuse a contract with a name-filtered `node --test` proof that cannot
|
|
52
|
+
* select the test it names. Checked on every Definition of Done command proof
|
|
53
|
+
* and every packet verification command. Measured 2026-09-26 on node v26.8.1:
|
|
54
|
+
* with no path, `node --test` runs every test file in the tree, and every
|
|
55
|
+
* file the filter selects nothing in prints its own zero plan, so a proof
|
|
56
|
+
* with no file both costs the whole suite and reads as having measured
|
|
57
|
+
* nothing. A named file that some node writes is accepted as the test to be
|
|
58
|
+
* written; a named file nobody writes must already hold a test the pattern
|
|
59
|
+
* selects.
|
|
60
|
+
*
|
|
61
|
+
* @param {ValidatedContract} contract
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
export function assertFilteredProofsNameTheirTest(contract) {
|
|
65
|
+
const written = new Set(contract.nodes.flatMap((node) => node.taskPacket.writeFiles));
|
|
66
|
+
/** @type {string[]} */
|
|
67
|
+
const problems = [];
|
|
68
|
+
for (const node of contract.nodes) {
|
|
69
|
+
const commands = [
|
|
70
|
+
...(node.definitionOfDone ?? []).flatMap((item) => (item.proof?.kind === "command" ? [{ argv: shellWords(item.proof.ref), cwd: contract.cwd }] : [])),
|
|
71
|
+
...(node.taskPacket.verification ?? []).map((command) => ({ argv: command.argv, cwd: resolve(contract.cwd, command.cwd ?? ".") })),
|
|
72
|
+
];
|
|
73
|
+
for (const { argv, cwd } of commands) {
|
|
74
|
+
const shape = nodeTestShape(argv);
|
|
75
|
+
if (!shape || shape.nameFilters.length === 0) continue;
|
|
76
|
+
const shown = argv.join(" ");
|
|
77
|
+
if (shape.paths.length === 0) {
|
|
78
|
+
problems.push(`${node.id}: "${shown}" filters node:test by name but names no test file; with no path node --test runs every test file in the tree and each file the filter selects nothing in prints a zero plan. Name the file that holds the test, as in node --test --test-name-pattern="<title>" test/<file>.test.mjs`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const namePattern = shape.nameFilters.find(({ flag }) => flag === "--test-name-pattern")?.value;
|
|
82
|
+
if (namePattern === undefined) continue;
|
|
83
|
+
for (const path of shape.paths) {
|
|
84
|
+
const absolute = resolve(cwd, path);
|
|
85
|
+
if (written.has(path) || !existsSync(absolute) || !statSync(absolute).isFile()) continue;
|
|
86
|
+
if (!fileHasMatchingTest(absolute, namePattern)) {
|
|
87
|
+
problems.push(`${node.id}: "${shown}" selects no test in ${path}: no test title there matches --test-name-pattern "${namePattern}", and no node writes ${path}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (problems.length) throw new TypeError(`name-filtered proofs cannot select their test: ${problems.join("; ")}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} path
|
|
97
|
+
* @param {string} pattern
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function fileHasMatchingTest(path, pattern) {
|
|
101
|
+
/** @type {RegExp} */
|
|
102
|
+
let regex;
|
|
103
|
+
try {
|
|
104
|
+
regex = new RegExp(pattern, "u");
|
|
105
|
+
} catch {
|
|
106
|
+
// node --test would reject the pattern too; nothing it names can match.
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return [...readFileSync(path, "utf8").matchAll(TEST_TITLE)].some((match) => regex.test(match[2]));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Declare, in the writeFiles of every node that creates a file in a
|
|
114
|
+
* directory, each test that enumerates that directory by name — a test whose
|
|
115
|
+
* source names the directory and every entry it holds today. Such a test
|
|
116
|
+
* cannot pass once the directory gains an entry unless it is edited too, and
|
|
117
|
+
* the gate refuses an edit to a file a proof cites that the node did not
|
|
118
|
+
* declare. Measured 2026-09-26 (AP2 of safe-to-hand-to-a-friend): the node
|
|
119
|
+
* adding skills/faberun/references/local-env.md failed on
|
|
120
|
+
* test/docs/docs-diet.test.mjs, which lists references/ exactly. The repair
|
|
121
|
+
* has one answer, so it is applied rather than raised; each one is returned
|
|
122
|
+
* with its reason so the caller can print it. A directory with fewer than two
|
|
123
|
+
* entries is skipped: naming one file is not evidence of an exact listing.
|
|
124
|
+
*
|
|
125
|
+
* @param {PlanOutput} plan
|
|
126
|
+
* @param {{testFiles: Array<{path: string}>}} repoFacts
|
|
127
|
+
* @param {string} cwd
|
|
128
|
+
* @returns {{plan: PlanOutput, declared: string[]}}
|
|
129
|
+
*/
|
|
130
|
+
export function declareDirectoryGuards(plan, repoFacts, cwd) {
|
|
131
|
+
/** @type {Map<string, string>} */
|
|
132
|
+
const sources = new Map();
|
|
133
|
+
/** @param {string} path @returns {string} */
|
|
134
|
+
const sourceOf = (path) => {
|
|
135
|
+
if (!sources.has(path)) {
|
|
136
|
+
/** @type {string} */
|
|
137
|
+
let text = "";
|
|
138
|
+
try {
|
|
139
|
+
text = readFileSync(join(cwd, path), "utf8");
|
|
140
|
+
} catch {
|
|
141
|
+
// A listed test file that is gone enumerates nothing.
|
|
142
|
+
}
|
|
143
|
+
sources.set(path, text);
|
|
144
|
+
}
|
|
145
|
+
return /** @type {string} */ (sources.get(path));
|
|
146
|
+
};
|
|
147
|
+
/** @type {string[]} */
|
|
148
|
+
const declared = [];
|
|
149
|
+
const nodes = plan.nodes.map((node) => {
|
|
150
|
+
const added = new Set();
|
|
151
|
+
for (const path of node.writeFiles) {
|
|
152
|
+
if (existsSync(join(cwd, path))) continue;
|
|
153
|
+
const directory = dirname(path);
|
|
154
|
+
if (directory === "." || !existsSync(join(cwd, directory))) continue;
|
|
155
|
+
const entries = readdirSync(join(cwd, directory));
|
|
156
|
+
if (entries.length < 2) continue;
|
|
157
|
+
for (const { path: testPath } of repoFacts.testFiles) {
|
|
158
|
+
if (node.writeFiles.includes(testPath) || added.has(testPath)) continue;
|
|
159
|
+
const source = sourceOf(testPath);
|
|
160
|
+
if (!source.includes(directory) && !source.includes(directory.slice(directory.lastIndexOf("/") + 1) + "/")) continue;
|
|
161
|
+
if (!entries.every((entry) => source.includes(`'${entry}'`) || source.includes(`"${entry}"`) || source.includes(`\`${entry}\``))) continue;
|
|
162
|
+
added.add(testPath);
|
|
163
|
+
declared.push(`${node.id}: declared ${testPath} in writeFiles: it lists every entry of ${directory}/ by name, and this node creates ${path} there`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (added.size === 0) return node;
|
|
167
|
+
return {
|
|
168
|
+
...node,
|
|
169
|
+
readFiles: node.readFiles.filter((path) => !added.has(path)),
|
|
170
|
+
scopeAcknowledged: node.scopeAcknowledged.filter((path) => !added.has(path)),
|
|
171
|
+
writeFiles: [...node.writeFiles, ...added],
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
return { plan: declared.length ? { ...plan, nodes } : plan, declared };
|
|
175
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `faberun plan --resolve` (R9): the operator's answer to a contested plan.
|
|
3
|
+
* A contest already lists every critical finding with its id, the node or
|
|
4
|
+
* requirement it concerns, and what would resolve it (`plan.json`, written by
|
|
5
|
+
* `contest.mjs`); this module reads that record back, requires an
|
|
6
|
+
* `accept`/`reject` decision for each critical finding, records every
|
|
7
|
+
* decision on the campaign journal, and resumes at the review stage's own
|
|
8
|
+
* exit — straight into the same sizing/routing/freeze assembly a fresh plan
|
|
9
|
+
* runs through (`assembleFrozenPlan`/`frozenContractRawOf` in
|
|
10
|
+
* `pipeline.mjs`) — without redrafting or asking a provider anything. The
|
|
11
|
+
* operator's decision is what a review round would otherwise have to
|
|
12
|
+
* produce: `accept` proceeds despite the finding, `reject:<reason>` dismisses
|
|
13
|
+
* it with the operator's own reasoning; either way the finding stops
|
|
14
|
+
* blocking freeze once it is answered. Separate from `pipeline.mjs` because
|
|
15
|
+
* it starts from a `plan.json` a previous run already wrote instead of an
|
|
16
|
+
* empty scratch directory, and needs none of that module's draft or provider
|
|
17
|
+
* seams.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { readCampaign } from "../campaign/record.mjs";
|
|
22
|
+
import { campaignCli } from "../cli/campaign.mjs";
|
|
23
|
+
import { campaignTree } from "../run/paths.mjs";
|
|
24
|
+
import { appendJsonl, readJson } from "../run/store.mjs";
|
|
25
|
+
import { freezePlan } from "./freeze.mjs";
|
|
26
|
+
import { collectHumanSteps } from "./human-step.mjs";
|
|
27
|
+
import { highestOf, modelOf } from "./pipeline-shape.mjs";
|
|
28
|
+
import { reviewerProvenanceOf } from "./reviewer.mjs";
|
|
29
|
+
import { PLANNER_SESSION_ID, assembleFrozenPlan, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
|
|
30
|
+
import { contestPlan } from "./contest.mjs";
|
|
31
|
+
import { parseSpec } from "./spec.mjs";
|
|
32
|
+
import { RISK_TIERS } from "./template.mjs";
|
|
33
|
+
|
|
34
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
35
|
+
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
36
|
+
/** @typedef {import("./contest.mjs").ContestedPlanRecord} ContestedPlanRecord */
|
|
37
|
+
/** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
|
|
38
|
+
/** @typedef {import("./pipeline.mjs").FrozenPipelineResult} FrozenPipelineResult */
|
|
39
|
+
/** @typedef {{decision: "accept"|"reject", reason?: string}} FindingAnswer */
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `--answer <finding-id>=accept` or `--answer <finding-id>=reject:<reason>`,
|
|
43
|
+
* one flag per critical finding, split on the first `=`. `reject` further
|
|
44
|
+
* splits on the first `:` for its reason, which may itself contain `:`, and
|
|
45
|
+
* the reason must not be empty — a bare `reject` names no rationale to record.
|
|
46
|
+
*
|
|
47
|
+
* @param {string[]} rawAnswers
|
|
48
|
+
* @returns {Map<string, FindingAnswer>}
|
|
49
|
+
*/
|
|
50
|
+
export function parseAnswerFlags(rawAnswers) {
|
|
51
|
+
/** @type {Map<string, FindingAnswer>} */
|
|
52
|
+
const answers = new Map();
|
|
53
|
+
for (const raw of rawAnswers) {
|
|
54
|
+
const eq = raw.indexOf("=");
|
|
55
|
+
if (eq < 0) throw new Error(`--answer must be <finding-id>=accept or <finding-id>=reject:<reason>: ${raw}`);
|
|
56
|
+
const findingId = raw.slice(0, eq);
|
|
57
|
+
const rest = raw.slice(eq + 1);
|
|
58
|
+
if (!findingId) throw new Error(`--answer finding id must not be empty: ${raw}`);
|
|
59
|
+
if (rest === "accept") {
|
|
60
|
+
answers.set(findingId, { decision: "accept" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (rest === "reject" || rest.startsWith("reject:")) {
|
|
64
|
+
const reason = rest === "reject" ? "" : rest.slice("reject:".length);
|
|
65
|
+
if (!reason) throw new Error(`--answer reject requires a reason: ${raw}`);
|
|
66
|
+
answers.set(findingId, { decision: "reject", reason });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`--answer must be <finding-id>=accept or <finding-id>=reject:<reason>: ${raw}`);
|
|
70
|
+
}
|
|
71
|
+
return answers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The decision text one operator answer becomes on the campaign journal:
|
|
76
|
+
* bounded and self-contained, never the finding's own text — that already
|
|
77
|
+
* lives in `plan.json`, and repeating it here is exactly the shape of
|
|
78
|
+
* over-long note RM-105 fixed in `contest.mjs`.
|
|
79
|
+
*
|
|
80
|
+
* @param {PlanFindingOutput} finding
|
|
81
|
+
* @param {FindingAnswer} answer
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function decisionText(finding, answer) {
|
|
85
|
+
const verb = answer.decision === "accept" ? "Accepted" : "Rejected";
|
|
86
|
+
return answer.reason ? `${verb} finding ${finding.id} (${finding.nodeId}): ${answer.reason}` : `${verb} finding ${finding.id} (${finding.nodeId}).`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resume a contested plan from the operator's answers.
|
|
91
|
+
*
|
|
92
|
+
* @param {{plansDir: string, cwd: string, answers: Map<string, FindingAnswer>}} options
|
|
93
|
+
* @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
|
|
94
|
+
*/
|
|
95
|
+
export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
|
|
96
|
+
const planPath = join(plansDir, "plan.json");
|
|
97
|
+
const record = /** @type {ContestedPlanRecord} */ (/** @type {unknown} */ (readJson(planPath)));
|
|
98
|
+
if (record.status !== "contested") throw new Error(`plan at ${planPath} is not contested (status: ${record.status})`);
|
|
99
|
+
if (!record.plan) throw new Error(`plan at ${planPath} has no plan to resume: the draft or revise that produced it never validated; run faberun plan again instead of --resolve`);
|
|
100
|
+
if (!record.resume) throw new Error(`plan at ${planPath} predates faberun plan --resolve and carries no resume context; run faberun plan again`);
|
|
101
|
+
const { campaignId, phase, specPath, specDigest, runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts } = record.resume;
|
|
102
|
+
// R16: recomputed from the same spec bytes the contest recorded, so a plan
|
|
103
|
+
// resumed through --resolve carries the same declared operator steps a
|
|
104
|
+
// fresh freeze would.
|
|
105
|
+
const humanSteps = collectHumanSteps(parseSpec(readFileSync(resolve(cwd, specPath), "utf8")).requirements);
|
|
106
|
+
const plan = /** @type {PlanOutput} */ (record.plan);
|
|
107
|
+
const findings = /** @type {PlanFindingOutput[]} */ (record.findings ?? []);
|
|
108
|
+
const round = record.rounds ?? 0;
|
|
109
|
+
|
|
110
|
+
const campaignPath = campaignTree(cwd, campaignId);
|
|
111
|
+
const campaign = readCampaign(campaignPath);
|
|
112
|
+
if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
|
|
113
|
+
|
|
114
|
+
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
115
|
+
const criticalIds = new Set(criticalFindings.map((finding) => finding.id));
|
|
116
|
+
const unknown = [...answers.keys()].filter((id) => !criticalIds.has(id));
|
|
117
|
+
if (unknown.length > 0) throw new Error(`--answer names finding(s) that are not open critical findings on this plan: ${unknown.join(", ")}`);
|
|
118
|
+
const missing = criticalFindings.filter((finding) => !answers.has(finding.id)).map((finding) => finding.id);
|
|
119
|
+
if (missing.length > 0) throw new Error(`every critical finding needs an --answer before the plan can resolve: ${missing.join(", ")}`);
|
|
120
|
+
|
|
121
|
+
const pipelineLog = join(plansDir, "pipeline.jsonl");
|
|
122
|
+
/** @param {string} stage @param {Record<string, unknown>} [extra] */
|
|
123
|
+
const logStage = (stage, extra = {}) => appendJsonl(pipelineLog, { type: "plan.stage", at: new Date().toISOString(), campaignId, phase, stage, ...extra });
|
|
124
|
+
|
|
125
|
+
for (const finding of criticalFindings) {
|
|
126
|
+
const answer = /** @type {FindingAnswer} */ (answers.get(finding.id));
|
|
127
|
+
await campaignCli([
|
|
128
|
+
"note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
|
|
129
|
+
"--kind", "decision", "--decision-id", `plan-${phase}-${finding.id}`,
|
|
130
|
+
"--text", decisionText(finding, answer),
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
logStage("resolve", { round, answered: criticalFindings.length });
|
|
134
|
+
|
|
135
|
+
const freezeWarnings = verification.sharedVerification || verification.finalVerification
|
|
136
|
+
? []
|
|
137
|
+
: ["the frozen contract carries neither sharedVerification nor finalVerification, so no repository ratchet runs on its nodes and no final check closes the phase; pass --verification <file> if the target repository has ratchets every node must run"];
|
|
138
|
+
let stage = "sizing";
|
|
139
|
+
try {
|
|
140
|
+
const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
|
|
141
|
+
stage = "freeze";
|
|
142
|
+
logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
|
|
143
|
+
logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
|
|
144
|
+
const highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
|
|
145
|
+
const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification }), {
|
|
146
|
+
outDir: plansDir,
|
|
147
|
+
phases: assembled.phases,
|
|
148
|
+
spec: { path: specPath, digest: specDigest },
|
|
149
|
+
humanSteps,
|
|
150
|
+
facts: repoFacts,
|
|
151
|
+
provenance: {
|
|
152
|
+
targetGitHead: repoFacts.gitHead,
|
|
153
|
+
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
154
|
+
reviewer: reviewerProvenanceOf(reviewers, runtimes),
|
|
155
|
+
sizing: assembled.sizing.transformations,
|
|
156
|
+
findings,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier, ...(freezeWarnings.length ? { warnings: freezeWarnings } : {}) });
|
|
160
|
+
|
|
161
|
+
const approved = approveBelow === "high" ? true : approveBelow === "none" ? false : highestRiskTier !== "high";
|
|
162
|
+
writeFrozenPlan(plansDir, frozen, { status: "frozen", approved });
|
|
163
|
+
if (!approved) {
|
|
164
|
+
await campaignCli([
|
|
165
|
+
"note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
|
|
166
|
+
"--kind", "open-question", "--question-id", `plan-${phase}-approval`,
|
|
167
|
+
"--text", `Plan for phase ${phase} carries a ${highestRiskTier}-risk node; approval is required under --approve-below ${approveBelow}.`,
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
logStage("approval", { approved, approveBelow, highestRiskTier });
|
|
171
|
+
return { status: "frozen", plansDir, planPath, contractPath: join(plansDir, "contract.json"), approved, findings, warnings: freezeWarnings };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const failedStage = /** @type {{planStage?: string}} */ (error)?.planStage ?? stage;
|
|
174
|
+
logStage(failedStage, { failed: error instanceof Error ? error.message : String(error) });
|
|
175
|
+
const failureFinding = invalidPlanFinding(failedStage, error);
|
|
176
|
+
rmSync(join(plansDir, "contract.json"), { force: true });
|
|
177
|
+
return await contestPlan({
|
|
178
|
+
campaignId, phase, cwd, plansDir, sessionId: PLANNER_SESSION_ID,
|
|
179
|
+
findings: [...findings, failureFinding], round, plan, resume: record.resume, logStage,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R19: the planner's own ordered reviewer list, kept separate from R18's
|
|
3
|
+
* judge list (`src/engine/judge-list.mjs`) even when an operator names the
|
|
4
|
+
* same runtime in both. The frozen contract's judge is chosen at run time,
|
|
5
|
+
* per node, from the judge list; the plan's own `review`/`spec-review` stage
|
|
6
|
+
* is chosen once per stage, from this list, by `firstEligibleReviewer` alone
|
|
7
|
+
* -- no cross-vendor rule runs here, because a plan reviewer grades a
|
|
8
|
+
* planning artefact rather than a worker's node (D11), so vendor
|
|
9
|
+
* independence from a worker is not the property this list protects.
|
|
10
|
+
*
|
|
11
|
+
* Lives beside the pipeline rather than inside `judge-list.mjs` so the two
|
|
12
|
+
* lists cannot accidentally converge on one resolver: `judge-list.mjs` reads
|
|
13
|
+
* `contract.judges`/`config.judges`, this module reads `reviewers` from the
|
|
14
|
+
* CLI and `config.reviewers`, and neither import the other.
|
|
15
|
+
*/
|
|
16
|
+
import { validateRuntime } from "../contract/runtime.mjs";
|
|
17
|
+
import { getHarness } from "../harnesses/index.mjs";
|
|
18
|
+
import { availabilityKey, readRefusal } from "../run/availability.mjs";
|
|
19
|
+
import { modelOf } from "./pipeline-shape.mjs";
|
|
20
|
+
|
|
21
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
22
|
+
/** @typedef {{runtimeId: string, model: string}} ReviewerProvenance */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The reviewer list a plan pipeline reads: `--reviewers` wins over the
|
|
26
|
+
* machine default, exactly as `resolveJudgeList` (R18) prefers a contract's
|
|
27
|
+
* own list over the machine's.
|
|
28
|
+
*
|
|
29
|
+
* @param {{reviewers?: string[]}} options
|
|
30
|
+
* @param {{reviewers?: string[]}|null|undefined} config
|
|
31
|
+
* @returns {string[]|undefined}
|
|
32
|
+
*/
|
|
33
|
+
export function resolveReviewerList(options, config) {
|
|
34
|
+
return options.reviewers ?? config?.reviewers ?? undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first eligible entry of an ordered reviewer list: declared in the
|
|
39
|
+
* runtimes catalogue and carrying no recorded refusal. Unlike `selectListJudge`
|
|
40
|
+
* (R18), this never excludes a candidate for sharing the worker's provider --
|
|
41
|
+
* a plan reviewer is not judging the worker that authored the plan, so that
|
|
42
|
+
* independence rule does not apply here (D11).
|
|
43
|
+
*
|
|
44
|
+
* @param {string[]} list
|
|
45
|
+
* @param {Record<string, JsonObject>} runtimes
|
|
46
|
+
* @param {number} [now]
|
|
47
|
+
* @returns {string|null}
|
|
48
|
+
*/
|
|
49
|
+
export function firstEligibleReviewer(list, runtimes, now = Date.now()) {
|
|
50
|
+
for (const id of list) {
|
|
51
|
+
const raw = runtimes[id];
|
|
52
|
+
if (raw === undefined) continue;
|
|
53
|
+
/** @type {import("../contract/index.mjs").ValidatedRuntime} */
|
|
54
|
+
let runtime;
|
|
55
|
+
try {
|
|
56
|
+
runtime = validateRuntime(id, raw);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const key = availabilityKey({ harness: runtime.harness, model: runtime.model, executable: getHarness(runtime.harness).executable(runtime) });
|
|
61
|
+
if (readRefusal(key, now)) continue;
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The reviewer fact a frozen plan's provenance records: the first eligible
|
|
69
|
+
* entry of the list at the moment of freezing, and its model. An empty or
|
|
70
|
+
* exhausted list records an empty runtime id, exactly as an unset
|
|
71
|
+
* `runtimeDefaults.judge` did before R19 -- freezing never refuses on this
|
|
72
|
+
* alone, since a plan with no reviewer configured is legitimate.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]|undefined} reviewers
|
|
75
|
+
* @param {Record<string, JsonObject>} runtimes
|
|
76
|
+
* @param {number} [now]
|
|
77
|
+
* @returns {ReviewerProvenance}
|
|
78
|
+
*/
|
|
79
|
+
export function reviewerProvenanceOf(reviewers, runtimes, now = Date.now()) {
|
|
80
|
+
const id = firstEligibleReviewer(reviewers ?? [], runtimes, now);
|
|
81
|
+
return { runtimeId: id ?? "", model: modelOf(runtimes, id ?? undefined) };
|
|
82
|
+
}
|