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.
- package/README.md +9 -3
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +5 -0
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +67 -7
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +5 -1
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +15 -9
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +29 -0
- package/src/harnesses/claude/index.mjs +98 -1
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +14 -0
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/index.mjs +13 -0
- package/src/harnesses/index.mjs +21 -10
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +32 -9
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- 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
|
+
}
|
package/src/plan/proof-check.mjs
CHANGED
|
@@ -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];
|
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Repo facts: a deterministic, bounded inventory of the target repository —
|
|
3
|
-
* tracked paths, declared scripts,
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/src/plan/rounds.mjs
CHANGED
|
@@ -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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
package/src/repo/integrate.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/repo/signal.mjs
CHANGED
|
@@ -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.
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
-
|
|
319
|
-
const
|
|
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;
|