faberun 0.27.0 → 0.28.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 (73) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +5 -0
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +67 -7
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +5 -1
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +15 -9
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +29 -0
  45. package/src/harnesses/claude/index.mjs +98 -1
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +14 -0
  48. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  49. package/src/harnesses/fx/index.mjs +13 -0
  50. package/src/harnesses/index.mjs +21 -10
  51. package/src/harnesses/replay/index.mjs +12 -0
  52. package/src/harnesses/zcode/index.mjs +19 -0
  53. package/src/host/config.mjs +8 -2
  54. package/src/host/environment-report.mjs +85 -0
  55. package/src/host/home.mjs +27 -0
  56. package/src/host/install-registry.mjs +511 -0
  57. package/src/host/preflight.mjs +32 -9
  58. package/src/host/uninstall-roots.mjs +196 -0
  59. package/src/plan/proof-check.mjs +15 -0
  60. package/src/plan/repo-facts.mjs +95 -11
  61. package/src/plan/rounds.mjs +27 -0
  62. package/src/repo/declared-paths.mjs +74 -6
  63. package/src/repo/integrate.mjs +1 -1
  64. package/src/repo/scope-closure.mjs +59 -0
  65. package/src/repo/signal-block.mjs +26 -0
  66. package/src/repo/signal.mjs +9 -24
  67. package/src/repo/workspace.mjs +189 -14
  68. package/src/report/campaign-brief.mjs +4 -0
  69. package/src/report/message.mjs +37 -6
  70. package/src/report/progress.mjs +13 -2
  71. package/src/run/paths.mjs +14 -1
  72. package/src/run/usage.mjs +66 -1
  73. package/src/util.mjs +18 -2
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Root confinement for `faberun uninstall`.
3
+ *
4
+ * `uninstall` is the one verb that deletes outside the target repository, and
5
+ * the only thing between a stray link and the operator's home is this module.
6
+ * Every candidate is resolved through the links on the way to it -- a POSIX
7
+ * symlink or a Windows directory junction, which `realpathSync` follows the
8
+ * same way -- and compared against the effective `HOME` and `FABERUN_HOME`
9
+ * named by the *injected* environment. The roots come from
10
+ * `home.mjs`'s `effectiveHome`, not from `faberunHome`, because the latter may
11
+ * fall back to `os.homedir()`: a normal command legitimately acts on the
12
+ * operator's own home, but a delete scoped to a throwaway home must never
13
+ * silently aim at the real one. When the environment names neither root there
14
+ * is no allowed root and every removal is refused.
15
+ *
16
+ * The comparison is not only at the top of the tree. `removeWithinRemovalRoots`
17
+ * re-resolves each entry immediately before unlinking it, so a directory whose
18
+ * own name resolves outside the roots -- `$FABERUN_HOME/current` pointing at
19
+ * `/etc`, say -- cannot smuggle its target into the recursive delete. A link
20
+ * that resolves outside is refused even though unlinking it would not follow
21
+ * the link, because a caller that hands over a resolved path deserves the same
22
+ * answer as one that hands over the path the resolution produced.
23
+ *
24
+ * Isolation is a requirement of the command, not a convenience: every test and
25
+ * every attempt to run `uninstall` during the work points `HOME` and
26
+ * `FABERUN_HOME` at a throwaway directory, and this module's refusal of the
27
+ * empty environment is what makes the fallback impossible to reintroduce.
28
+ */
29
+ import { lstatSync, readdirSync, realpathSync, rmdirSync, unlinkSync } from "node:fs";
30
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
31
+ import { effectiveHome } from "./home.mjs";
32
+
33
+ /** The `code` a removal outside the effective roots carries. */
34
+ export const REMOVAL_OUTSIDE_ROOTS = "uninstall_outside_roots";
35
+
36
+ /** Raised when a removal target is not under the effective HOME/FABERUN_HOME. */
37
+ export class RemovalRefusedError extends Error {
38
+ /**
39
+ * @param {string} target the resolved path a caller asked to delete
40
+ * @param {string[]} roots the roots it was compared against
41
+ */
42
+ constructor(target, roots) {
43
+ super(
44
+ roots.length
45
+ ? `refusing to remove ${target}: it resolves outside ${roots.join(", ")}`
46
+ : `refusing to remove ${target}: the injected environment names no HOME or FABERUN_HOME`,
47
+ );
48
+ this.name = "RemovalRefusedError";
49
+ this.code = REMOVAL_OUTSIDE_ROOTS;
50
+ this.target = target;
51
+ this.roots = roots;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * `path` with every link on the way to it resolved.
57
+ *
58
+ * A path that does not exist yet is canonicalised through its deepest existing
59
+ * ancestor and the remaining names appended. That is what keeps a not-yet-made
60
+ * leaf under a symlinked parent comparable: `$TMPDIR` on macOS is `/var`, a
61
+ * link to `/private/var`, so `removalRoots` resolving `$FABERUN_HOME` while
62
+ * `$FABERUN_HOME/versions` does not exist still has to agree with the
63
+ * `/private/var/...` realpath of a target that does.
64
+ *
65
+ * @param {string} path
66
+ * @returns {string}
67
+ */
68
+ export function canonicalPath(path) {
69
+ const absolute = resolve(path);
70
+ try {
71
+ return realpathSync(absolute);
72
+ } catch {
73
+ const parent = dirname(absolute);
74
+ if (parent === absolute) return absolute;
75
+ return join(canonicalPath(parent), basename(absolute));
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The roots `uninstall` may delete inside, resolved and de-duplicated: the
81
+ * effective `HOME` and `FABERUN_HOME` the injected environment names. When
82
+ * `FABERUN_HOME` is unset and a home is known, the install root is that home's
83
+ * `.faberun`; when neither is known the list is empty and nothing may be
84
+ * removed.
85
+ *
86
+ * @param {NodeJS.ProcessEnv} [env]
87
+ * @returns {string[]}
88
+ */
89
+ export function removalRoots(env = process.env) {
90
+ const home = effectiveHome(env);
91
+ const canonicalHome = home === null ? null : canonicalPath(home);
92
+ const configured = env.FABERUN_HOME;
93
+ const canonicalFaberun = typeof configured === "string" && configured
94
+ ? canonicalPath(configured)
95
+ : canonicalHome === null
96
+ ? null
97
+ : canonicalPath(join(canonicalHome, ".faberun"));
98
+ return [...new Set([canonicalHome, canonicalFaberun].filter((root) => root !== null))];
99
+ }
100
+
101
+ /**
102
+ * Whether `root` contains `target` (or is it). The comparison is on resolved
103
+ * absolute paths, so `..` segments and case-normalised Windows spellings do
104
+ * not matter.
105
+ *
106
+ * @param {string} root
107
+ * @param {string} target
108
+ * @returns {boolean}
109
+ */
110
+ function contains(root, target) {
111
+ if (target === root) return true;
112
+ const step = relative(root, target);
113
+ return step !== "" && !step.startsWith("..") && !isAbsolute(step);
114
+ }
115
+
116
+ /**
117
+ * Whether `target`, once resolved, sits under one of `roots`.
118
+ *
119
+ * @param {string} target
120
+ * @param {string[]} roots
121
+ * @returns {boolean}
122
+ */
123
+ export function isWithinRemovalRoots(target, roots) {
124
+ const canonicalTarget = canonicalPath(target);
125
+ return roots.some((root) => contains(canonicalPath(root), canonicalTarget));
126
+ }
127
+
128
+ /**
129
+ * Resolve `target` and return it, or throw {@link RemovalRefusedError} when it
130
+ * is not under one of `roots`. The resolved path is what a caller should delete
131
+ * or report, so the resolution is not repeated at the deletion site.
132
+ *
133
+ * @param {string} target
134
+ * @param {string[]} roots
135
+ * @returns {string}
136
+ */
137
+ export function assertWithinRemovalRoots(target, roots) {
138
+ const canonicalTarget = canonicalPath(target);
139
+ const canonicalRoots = roots.map(canonicalPath);
140
+ if (!canonicalRoots.some((root) => contains(root, canonicalTarget))) {
141
+ throw new RemovalRefusedError(canonicalTarget, canonicalRoots);
142
+ }
143
+ return canonicalTarget;
144
+ }
145
+
146
+ /**
147
+ * Remove `target`, recursively, every deletion preceded by a fresh resolution
148
+ * and containment check. Links are unlinked, never followed: the check on a
149
+ * link is about where it points, and a link that points outside is refused, so
150
+ * its target cannot be reached through it.
151
+ *
152
+ * A target that does not exist is simply absent; only a target that resolves
153
+ * outside the effective roots raises. When the environment names no root, every
154
+ * target raises.
155
+ *
156
+ * @param {string} target
157
+ * @param {NodeJS.ProcessEnv} [env]
158
+ * @returns {void}
159
+ */
160
+ export function removeWithinRemovalRoots(target, env = process.env) {
161
+ const roots = removalRoots(env);
162
+ if (roots.length === 0) throw new RemovalRefusedError(canonicalPath(target), roots);
163
+ removeChecked(target, roots);
164
+ }
165
+
166
+ /**
167
+ * @param {string} target
168
+ * @param {string[]} roots canonical and already non-empty
169
+ * @returns {void}
170
+ */
171
+ function removeChecked(target, roots) {
172
+ /** @type {import("node:fs").Stats} */
173
+ let stats;
174
+ try {
175
+ stats = lstatSync(target);
176
+ } catch (error) {
177
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") return;
178
+ throw error;
179
+ }
180
+ // Resolve immediately before this entry's own deletion. `lstat` does not
181
+ // follow the link, so a directory link is unlinked below rather than walked.
182
+ assertWithinRemovalRoots(target, roots);
183
+ if (stats.isSymbolicLink()) {
184
+ unlinkSync(target);
185
+ return;
186
+ }
187
+ if (stats.isDirectory()) {
188
+ for (const name of readdirSync(target)) removeChecked(join(target, name), roots);
189
+ // Re-check after the children, so a directory swapped for an escaping link
190
+ // in the meantime is not removed by the `rmdir` either.
191
+ assertWithinRemovalRoots(target, roots);
192
+ rmdirSync(target);
193
+ return;
194
+ }
195
+ unlinkSync(target);
196
+ }
@@ -13,6 +13,7 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { shellWords } from "../util.mjs";
16
+ import { inlineScriptParseError } from "../contract/definition-of-done.mjs";
16
17
  import { NAME_PATTERN_FLAG } from "./proof-run.mjs";
17
18
 
18
19
  /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
@@ -135,6 +136,20 @@ export function checkPlanProofs(plan, repoFacts, cwd) {
135
136
  for (const item of node.definitionOfDone) {
136
137
  const commandText = proofCommandText(node, item.proof);
137
138
  if (commandText === null) continue;
139
+ // R34: a `node -e` body the parser rejects can never exit 0, so no
140
+ // revise of the work helps; the body has to change before the plan is
141
+ // frozen. Raises the finding the planner can act on, rather than
142
+ // spending a review round on a proof that is unsatisfiable by syntax.
143
+ const inlineScriptError = inlineScriptParseError(commandText);
144
+ if (inlineScriptError !== null) {
145
+ findings.push({
146
+ id: `proof-unparseable-${node.id}-${item.id}`,
147
+ severity: "critical",
148
+ nodeId: node.id,
149
+ text: `Node ${node.id}'s Definition of Done item "${item.id}" proves itself with an inline script that does not parse ("${inlineScriptError}"), so no delivery can make this proof exit 0. Fix the script body.`,
150
+ });
151
+ continue;
152
+ }
138
153
  const nameFlag = NAME_PATTERN_FLAG.exec(commandText);
139
154
  if (nameFlag) {
140
155
  const pattern = nameFlag[1] ?? nameFlag[2] ?? nameFlag[3];
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Repo facts: a deterministic, bounded inventory of the target repository —
3
- * tracked paths, declared scripts, timed verification candidates, and which
4
- * test file covers which source module — collected without invoking a model.
3
+ * tracked paths, declared scripts, verification candidates across the Node,
4
+ * Python, Go, Rust, Zig and Makefile ecosystems, and which test file covers
5
+ * which source module — collected without invoking a model. Every candidate
6
+ * records the manifest it was read from; only the Node candidates are timed,
7
+ * and no candidate for another ecosystem is ever executed to be found.
5
8
  * A planning stage's draft is authored against exactly this JSON instead of
6
9
  * the session reading the repository by hand.
7
10
  *
@@ -23,9 +26,27 @@ import { runShellCapture } from "./proof-run.mjs";
23
26
  /** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
24
27
  /** @typedef {import("./proof-run.mjs").MeasureProbes} MeasureProbes */
25
28
  /** @typedef {{requirementId: string|null, command: string, output: string, exitCode: number|null, truncated: boolean}} RequirementMeasurement */
26
- /** @typedef {{argv: string[], measuredMs: number, eligible: boolean}} VerificationCandidate */
29
+ /**
30
+ * A verification command repo facts measured: a timed Node candidate, the
31
+ * manifest file it was read from, and its eligibility. It keeps the shape
32
+ * `freeze.mjs`'s `MeasuredFacts` reads (`argv` plus a numeric `measuredMs`),
33
+ * so a repository's facts stay assignable there while carrying the manifest
34
+ * the planner needs.
35
+ *
36
+ * @typedef {{argv: string[], manifest: string, measuredMs: number, eligible: boolean}} VerificationCandidate
37
+ */
38
+ /**
39
+ * A verification command repo facts detected in a non-Node manifest it never
40
+ * ran: `pyproject.toml`/`pytest.ini` (Python), `go.mod`, `Cargo.toml`,
41
+ * `build.zig`, or a `Makefile` with a `test` target. Detection is file
42
+ * inspection alone, so `measuredMs` is the null "no measurement" sentinel
43
+ * `freeze.mjs` reads and `eligible` is true — the manifest's own presence is
44
+ * the evidence. `manifest` names the file the command was read from.
45
+ *
46
+ * @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} DetectedVerificationCandidate
47
+ */
27
48
  /** @typedef {{path: string, covers: string|null}} TestFileEntry */
28
- /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
49
+ /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], detectedVerificationCandidates?: DetectedVerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
29
50
 
30
51
  const FORMAT_VERSION = 1;
31
52
  const DEFAULT_MAX_PATHS = 2000;
@@ -87,16 +108,71 @@ function testFileEntries(paths, pathSet) {
87
108
  });
88
109
  }
89
110
 
111
+ /** The manifest the Node candidates — the test directories and the scripts — are read from. */
112
+ const PACKAGE_MANIFEST = "package.json";
113
+
90
114
  /**
115
+ * The Node candidates: one per first-level test directory, plus the `check`
116
+ * and `typecheck` scripts `package.json` declares. Every one is measured, so
117
+ * each is handed to the timed probe below; each names package.json as the
118
+ * manifest it came from.
119
+ *
91
120
  * @param {Record<string, string>} scripts
92
121
  * @param {string[]} paths
93
- * @returns {{argv: string[]}[]}
122
+ * @returns {{argv: string[], manifest: string}[]}
94
123
  */
95
- function candidateCommands(scripts, paths) {
96
- const commands = testDirectories(paths).map((directory) => ({ argv: ["node", "--test", directory] }));
124
+ function nodeCandidateCommands(scripts, paths) {
125
+ const commands = testDirectories(paths).map((directory) => ({ argv: ["node", "--test", directory], manifest: PACKAGE_MANIFEST }));
97
126
  for (const name of ["check", "typecheck"]) {
98
- if (typeof scripts[name] === "string") commands.push({ argv: ["npm", "run", name] });
127
+ if (typeof scripts[name] === "string") commands.push({ argv: ["npm", "run", name], manifest: PACKAGE_MANIFEST });
128
+ }
129
+ return commands;
130
+ }
131
+
132
+ /**
133
+ * The makefile that declares a `test` target, or null when none does. A rule
134
+ * line starts at column zero with the target name `test` followed by a colon;
135
+ * `test:=` is a variable assignment, `.PHONY: test` declares no rule, an
136
+ * indented recipe line never matches, and `test-all:` names another target.
137
+ * Pure text inspection of the first conventional makefile present, so a
138
+ * command is never run to discover the target.
139
+ *
140
+ * @param {string} cwd
141
+ * @returns {string|null}
142
+ */
143
+ function makefileWithTestTarget(cwd) {
144
+ for (const name of ["Makefile", "makefile", "GNUmakefile"]) {
145
+ const path = join(cwd, name);
146
+ if (!existsSync(path)) continue;
147
+ if (/^test[ \t]*:(?!=)/mu.test(readFileSync(path, "utf8"))) return name;
99
148
  }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * The verification commands the non-Node ecosystems declare, in a fixed order,
154
+ * each naming the manifest file it was read from. Detection is file inspection
155
+ * alone — existence for the manifest files, one read for a Makefile target —
156
+ * so no discovered command is executed, the order never depends on a directory
157
+ * listing, and two calls at the same HEAD return identical candidates.
158
+ * `pyproject.toml` wins over `pytest.ini` when both exist because it is the
159
+ * file pytest itself reads first.
160
+ *
161
+ * @param {string} cwd
162
+ * @returns {{argv: string[], manifest: string}[]}
163
+ */
164
+ function manifestCandidateCommands(cwd) {
165
+ /** @type {{argv: string[], manifest: string}[]} */
166
+ const commands = [];
167
+ /** @param {string} name @returns {boolean} */
168
+ const present = (name) => existsSync(join(cwd, name));
169
+ if (present("pyproject.toml")) commands.push({ argv: ["pytest"], manifest: "pyproject.toml" });
170
+ else if (present("pytest.ini")) commands.push({ argv: ["pytest"], manifest: "pytest.ini" });
171
+ if (present("go.mod")) commands.push({ argv: ["go", "test", "./..."], manifest: "go.mod" });
172
+ if (present("Cargo.toml")) commands.push({ argv: ["cargo", "test"], manifest: "Cargo.toml" });
173
+ if (present("build.zig")) commands.push({ argv: ["zig", "build", "test"], manifest: "build.zig" });
174
+ const makefile = makefileWithTestTarget(cwd);
175
+ if (makefile !== null) commands.push({ argv: ["make", "test"], manifest: makefile });
100
176
  return commands;
101
177
  }
102
178
 
@@ -111,7 +187,7 @@ function candidateCommands(scripts, paths) {
111
187
  * @param {string} cwd
112
188
  * @param {{argv: string[]}[]} commands
113
189
  * @param {MeasureProbes} probes
114
- * @returns {VerificationCandidate[]}
190
+ * @returns {{argv: string[], measuredMs: number, eligible: boolean}[]}
115
191
  */
116
192
  function measureCandidates(cwd, commands, probes) {
117
193
  if (commands.length === 0) return [];
@@ -179,7 +255,8 @@ export function collectRepoFacts(cwd, options = {}) {
179
255
  const allPaths = listTrackedPaths(cwd);
180
256
  const pathSet = new Set(allPaths);
181
257
  const scripts = readScripts(cwd);
182
- const commands = candidateCommands(scripts, allPaths);
258
+ const measured = measureCandidates(cwd, nodeCandidateCommands(scripts, allPaths), options.measure ?? {});
259
+ const detected = manifestCandidateCommands(cwd);
183
260
  const truncated = allPaths.length > maxPaths;
184
261
  return {
185
262
  formatVersion: FORMAT_VERSION,
@@ -187,7 +264,14 @@ export function collectRepoFacts(cwd, options = {}) {
187
264
  paths: truncated ? allPaths.slice(0, maxPaths) : allPaths,
188
265
  truncated,
189
266
  scripts,
190
- verificationCandidates: measureCandidates(cwd, commands, options.measure ?? {}),
267
+ // The measured Node candidates first, in their timed order, each naming
268
+ // package.json as the manifest it came from; then the manifest-only
269
+ // candidates in a fixed ecosystem order, each naming the manifest it was
270
+ // read from. Only the measured candidates carry a numeric `measuredMs`:
271
+ // a detected command is never run, so it lands in its own array with the
272
+ // null "no measurement" sentinel freeze.mjs reads and eligibility true.
273
+ verificationCandidates: measured.map((candidate) => ({ ...candidate, manifest: PACKAGE_MANIFEST })),
274
+ detectedVerificationCandidates: detected.map((candidate) => ({ ...candidate, measuredMs: null, eligible: true })),
191
275
  testFiles: testFileEntries(allPaths, pathSet),
192
276
  requirementMeasurements: measureRequirements(cwd, options.requirements ?? [], options.measure ?? {}),
193
277
  };
@@ -444,3 +444,30 @@ export async function runReviewRounds(options) {
444
444
  }
445
445
  return { resolved: true, plan, findings, roundsRun };
446
446
  }
447
+
448
+ /**
449
+ * A hard round budget around any discovery pass: at most `rounds` calls to
450
+ * `step`, stopping at the first result whose `accepted` flag is true. The
451
+ * reauthor flow spends this budget widening a refused packet; the planning
452
+ * pipeline's own review/revise loop keeps its bespoke bookkeeping and does not
453
+ * use it. `step` receives the round number and the results so far, so a round
454
+ * can feed the previous rounds' findings into its own prompt.
455
+ *
456
+ * @template T
457
+ * @param {number} rounds a positive integer; the budget is never exceeded
458
+ * @param {(round: number, history: T[]) => T|Promise<T>} step
459
+ * @returns {Promise<{accepted: boolean, roundsUsed: number, last: T|null, history: T[]}>}
460
+ */
461
+ export async function runBoundedRounds(rounds, step) {
462
+ if (!Number.isInteger(rounds) || rounds < 1) throw new TypeError(`rounds must be a positive integer: ${String(rounds)}`);
463
+ /** @type {T[]} */
464
+ const history = [];
465
+ for (let round = 1; round <= rounds; round += 1) {
466
+ const result = await step(round, history);
467
+ history.push(result);
468
+ if (/** @type {{accepted?: boolean}} */ (result).accepted === true) {
469
+ return { accepted: true, roundsUsed: round, last: result, history };
470
+ }
471
+ }
472
+ return { accepted: false, roundsUsed: rounds, last: history.at(-1) ?? null, history };
473
+ }
@@ -14,7 +14,7 @@ import { errorCode, exitStatus } from "../util.mjs";
14
14
  import { execFileSync } from "node:child_process";
15
15
  import { gitArguments } from "../host/platform.mjs";
16
16
  import { join, resolve } from "node:path";
17
- import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { RUNS_DIR_NAME } from "../run/paths.mjs";
20
20
  import { isIgnoreSource } from "./workspace.mjs";
@@ -54,16 +54,84 @@ export function unsnapshottedWriteWarnings(node, index, cwd) {
54
54
  * `writes_ignore_source`: a declared write the workspace snapshot fingerprints
55
55
  * as an ignore source. The node fails with `snapshot_ignore_changed` the
56
56
  * moment the worker changes it, and two campaigns each lost a node learning
57
- * that (RM-051), so the author hears it before dispatch.
57
+ * that (RM-051), so the author hears it before dispatch. A declared write root
58
+ * is covered too: a worker is free to edit a `.gitignore` anywhere under it,
59
+ * and the node would fail the same way.
58
60
  *
59
61
  * @param {ValidatedNode} node
60
62
  * @param {number} index
63
+ * @param {string} [cwd]
61
64
  * @returns {string[]}
62
65
  */
63
- export function ignoreSourceWriteWarnings(node, index) {
64
- const sources = (node.taskPacket.writeFiles ?? []).filter(isIgnoreSource);
65
- if (!sources.length) return [];
66
- return [`nodes[${index}] (${node.id}): writes_ignore_source: writeFiles ${sources.join(", ")} ${sources.length === 1 ? "is an ignore source" : "are ignore sources"} the workspace snapshot fingerprints; a worker that changes one fails the node with snapshot_ignore_changed, so make that edit outside the run`];
66
+ export function ignoreSourceWriteWarnings(node, index, cwd) {
67
+ const nodeCwd = /** @type {{cwd?: unknown}} */ (node).cwd;
68
+ const directory = typeof cwd === "string" ? cwd : (typeof nodeCwd === "string" ? nodeCwd : undefined);
69
+ const fileSources = (node.taskPacket.writeFiles ?? []).filter(isIgnoreSource);
70
+ const rootSources = [];
71
+ for (const root of node.taskPacket.writeRoots ?? []) {
72
+ const sources = ignoreSourcesUnderWriteRoot(root, directory);
73
+ if (sources.length) rootSources.push({ root, sources });
74
+ }
75
+ if (!fileSources.length && !rootSources.length) return [];
76
+ const clauses = [];
77
+ if (fileSources.length) {
78
+ clauses.push(`writeFiles ${fileSources.join(", ")} ${fileSources.length === 1 ? "is an ignore source" : "are ignore sources"}`);
79
+ }
80
+ for (const { root, sources } of rootSources) {
81
+ const literal = normalizeDeclaredPath(root);
82
+ clauses.push(isIgnoreSource(literal)
83
+ ? `writeRoots ${literal} is an ignore source`
84
+ : `writeRoots ${literal} contains ${sources.join(", ")}`);
85
+ }
86
+ return [`nodes[${index}] (${node.id}): writes_ignore_source: ${clauses.join("; ")} the workspace snapshot fingerprints; a worker that changes one fails the node with snapshot_ignore_changed, so make that edit outside the run`];
87
+ }
88
+ /** Directories a write-root scan never descends into, mirroring `isIgnoreSource`. */
89
+ const UNSCANNED_WRITE_ROOT_DIRS = new Set([".git", RUNS_DIR_NAME, "node_modules", ".venv", "venv"]);
90
+ /**
91
+ * The ignore sources inside a declared write root. The root itself counts; a
92
+ * directory is walked with the same exclusions `isIgnoreSource` applies, and
93
+ * the walk is bounded so a broad root cannot stall validation.
94
+ *
95
+ * @param {string} root
96
+ * @param {string|undefined} cwd
97
+ * @returns {string[]}
98
+ */
99
+ function ignoreSourcesUnderWriteRoot(root, cwd) {
100
+ const literal = normalizeDeclaredPath(root);
101
+ if (!literal) return [];
102
+ if (isIgnoreSource(literal)) return [literal];
103
+ if (!cwd) return [];
104
+ /** @type {string[]} */
105
+ const found = [];
106
+ /** @param {string} directory @param {number} budget @returns {number} */
107
+ const walk = (directory, budget) => {
108
+ if (budget <= 0) return budget;
109
+ let entries;
110
+ try {
111
+ entries = readdirSync(join(cwd, directory), { withFileTypes: true });
112
+ } catch {
113
+ return budget;
114
+ }
115
+ for (const entry of entries) {
116
+ if (budget <= 0) break;
117
+ const relative = `${directory}/${entry.name}`;
118
+ if (isIgnoreSource(relative)) {
119
+ found.push(relative);
120
+ budget -= 1;
121
+ continue;
122
+ }
123
+ if (entry.isDirectory() && !UNSCANNED_WRITE_ROOT_DIRS.has(entry.name) && !(directory === literal && (entry.name === ".claude" || entry.name === ".codex"))) {
124
+ budget = walk(relative, budget - 1);
125
+ }
126
+ }
127
+ return budget;
128
+ };
129
+ walk(literal, 200);
130
+ return found;
131
+ }
132
+ /** @param {string} path @returns {string} */
133
+ function normalizeDeclaredPath(path) {
134
+ return (process.platform === "win32" ? String(path).replaceAll("\\", "/") : String(path)).replace(/\/+$/u, "");
67
135
  }
68
136
  /**
69
137
  * @param {string|undefined} cwd
@@ -425,7 +425,7 @@ function gitSetRef(repo, ref, sha) {
425
425
 
426
426
  /** @param {string} repo @param {string} tree @param {string} firstParent @param {string} secondParent @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
427
427
  function gitCommitTree(repo, tree, firstParent, secondParent, runId, nodeId, attempt) {
428
- const result = boundedGitSync(["-C", repo, "commit-tree", tree, "-p", firstParent, "-p", secondParent, "-m", `faberun candidate ${runId} ${nodeId} attempt ${attempt}`], {
428
+ const result = boundedGitSync(["-C", repo, "commit-tree", tree, "-p", firstParent, "-p", secondParent, "-m", `chore(faberun): integrate ${nodeId} attempt ${attempt}`, "-m", `faberun run ${runId}`], {
429
429
  encoding: "utf8",
430
430
  env: {
431
431
  ...process.env,
@@ -231,6 +231,65 @@ export function crossNodeScopeFindings(nodes, cwd) {
231
231
  return findings;
232
232
  }
233
233
 
234
+ /**
235
+ * Every scope-closure obligation of a whole contract at once: the per-node
236
+ * detectors and the cross-node pair. `validateContract` runs exactly this pair
237
+ * as a refusal; a widening pass needs the same findings without throwing, so
238
+ * it can show them to the operator and hand them to the next discovery round.
239
+ *
240
+ * @param {ValidatedNode[]} nodes every node of one contract
241
+ * @param {string} cwd
242
+ * @returns {(ScopeClosureFinding|CrossNodeScopeFinding)[]}
243
+ */
244
+ export function contractScopeFindings(nodes, cwd) {
245
+ return [
246
+ ...nodes.flatMap((node, index) => scopeClosureFindings(node, index, cwd)),
247
+ ...crossNodeScopeFindings(nodes, cwd),
248
+ ];
249
+ }
250
+
251
+ /**
252
+ * Widen a task packet with a discovery pass's proposed additions. Only unions:
253
+ * a reauthor may add a read, a write, an acknowledgement, or a symbol, never
254
+ * remove one -- a shrink clears a closure finding while leaving the worker
255
+ * unable to do the work.
256
+ *
257
+ * @param {TaskPacket} packet
258
+ * @param {import("../contract/scope-findings.mjs").ReauthorAdditions} additions
259
+ * @returns {TaskPacket}
260
+ */
261
+ export function widenTaskPacket(packet, additions) {
262
+ /** @param {string[]|undefined} base @param {string[]|undefined} extra @returns {string[]} */
263
+ const union = (base, extra) => [...new Set([...(base ?? []), ...(extra ?? [])])];
264
+ const acknowledged = union(packet.scopeAcknowledged, additions.scopeAcknowledged);
265
+ return {
266
+ ...packet,
267
+ readFiles: union(packet.readFiles, additions.readFiles),
268
+ writeFiles: union(packet.writeFiles, additions.writeFiles),
269
+ symbols: union(packet.symbols, additions.symbols),
270
+ ...(acknowledged.length ? { scopeAcknowledged: acknowledged } : {}),
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Replace one node's inline packet in a raw contract and drop its `packetHash`,
276
+ * which `validateContract` recomputes for the widened packet. The caller
277
+ * re-stamps the new hash before persisting the accepted widening.
278
+ *
279
+ * @param {Record<string, unknown>} raw
280
+ * @param {string} nodeId
281
+ * @param {TaskPacket} packet
282
+ * @returns {Record<string, unknown>}
283
+ */
284
+ export function replacePacketInContract(raw, nodeId, packet) {
285
+ const candidate = /** @type {any} */ (structuredClone(raw));
286
+ const record = candidate.nodes.find((/** @type {any} */ node) => node.id === nodeId);
287
+ if (!record) throw new Error(`unknown node id: ${nodeId}`);
288
+ record.taskPacket = packet;
289
+ delete record.packetHash;
290
+ return candidate;
291
+ }
292
+
234
293
  /**
235
294
  * DETECTOR 1. An importer of a written module must change with it, so it is in
236
295
  * scope unless declared. A packet that names `symbols` narrows the rule to
@@ -21,3 +21,29 @@ export function normalizeManagedSignalBlock(text) {
21
21
  const after = text.slice(end + SIGNAL_END.length).replace(/^\s+/u, "");
22
22
  return after ? `${before}\n\n${after}` : `${before}\n`;
23
23
  }
24
+
25
+ /**
26
+ * Apply a freshly rendered managed block to `text`, but only when the start
27
+ * marker is already present. A document without the marker is the operator's
28
+ * file, not the runner's: it is returned byte-identical so no campaign
29
+ * operation can quietly append a managed block to an `AGENTS.md` that never
30
+ * opted in. The operator opts in by pasting the start marker once, and this
31
+ * repository already carries it.
32
+ *
33
+ * `block` is the renderer's output, or `""` to remove a block that is present
34
+ * once the run settled. The surrounding text is joined with the same blank
35
+ * line the writer always used, so a rewrite of an existing block and its
36
+ * removal both normalise to the file's committed shape.
37
+ *
38
+ * @param {string} text
39
+ * @param {string} block
40
+ * @returns {string}
41
+ */
42
+ export function applyManagedSignalBlock(text, block) {
43
+ const start = text.indexOf(SIGNAL_START);
44
+ if (start < 0) return text;
45
+ const end = text.indexOf(SIGNAL_END, start + SIGNAL_START.length);
46
+ const before = text.slice(0, start).trimEnd();
47
+ const after = end < 0 ? "" : text.slice(end + SIGNAL_END.length);
48
+ return [before, block, after.trimStart()].filter((part) => part.length).join("\n\n") + "\n";
49
+ }
@@ -40,7 +40,7 @@ import { discoverCampaigns } from "../campaign/index.mjs";
40
40
  import { runProgress } from "../engine/supervise.mjs";
41
41
  import { readInbox } from "../notify/index.mjs";
42
42
  import { repositoryForRunsDir } from "../run/paths.mjs";
43
- import { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
43
+ import { applyManagedSignalBlock, SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
44
44
  import { HANDOFF_FILE } from "../campaign/layout.mjs";
45
45
  import { classifyRunProgress } from "../campaign/chain.mjs";
46
46
 
@@ -298,9 +298,12 @@ function boundLines(lines) {
298
298
 
299
299
  /**
300
300
  * Rewrites the managed signal block at the bottom of the repository's
301
- * AGENTS.md from the current runs state. Leaves the file untouched when there
302
- * is no AGENTS.md, no active work, or nothing changed. Returns true when the
303
- * file was written.
301
+ * AGENTS.md from the current runs state. The file must already contain the
302
+ * start marker: the operator opts in by pasting it once, and this repository
303
+ * already carries it, so no campaign operation can append a managed block to a
304
+ * document that never asked for one. Leaves the file untouched when there is
305
+ * no AGENTS.md, no start marker, no active work, or nothing changed. Returns
306
+ * true when the file was written.
304
307
  *
305
308
  * The repository is named by the project registry, not derived as a sibling
306
309
  * of `runsDir`: since the runs directory moved under the home, a directory
@@ -315,26 +318,8 @@ export function syncAgentSignal(runsDir) {
315
318
  const agentsPath = join(repositoryForRunsDir(runsDir) ?? dirname(runsDir), "AGENTS.md");
316
319
  if (!existsSync(agentsPath)) return false;
317
320
  const current = readFileSync(agentsPath, "utf8");
318
- const block = renderAgentSignalBlock(runsDir);
319
- const start = current.indexOf(SIGNAL_START);
320
- const end = current.indexOf(SIGNAL_END);
321
- if (!block && start < 0 && end < 0) return false;
322
- let before;
323
- let after;
324
- if (start >= 0 && end > start) {
325
- before = current.slice(0, start).trimEnd();
326
- after = current.slice(end + SIGNAL_END.length);
327
- } else if (start >= 0) {
328
- before = current.slice(0, start).trimEnd();
329
- after = "";
330
- } else if (end >= 0) {
331
- before = current.slice(0, end).trimEnd();
332
- after = current.slice(end + SIGNAL_END.length);
333
- } else {
334
- before = current.trimEnd();
335
- after = "";
336
- }
337
- const next = [before, block, after.trimStart()].filter((part) => part.length).join("\n\n") + "\n";
321
+ if (!current.includes(SIGNAL_START)) return false;
322
+ const next = applyManagedSignalBlock(current, renderAgentSignalBlock(runsDir));
338
323
  if (next === current) return false;
339
324
  writeFileSync(agentsPath, next);
340
325
  return true;