faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace as evidence: what a worktree held before a worker ran, what it
|
|
3
|
+
* held after, and whether the difference stayed inside the paths the packet
|
|
4
|
+
* declared.
|
|
5
|
+
*
|
|
6
|
+
* Snapshots are of the *tracked and untracked* tree minus what git ignores, and
|
|
7
|
+
* `captureIgnoreSources` is why: the ignore rules themselves are hashed, so a
|
|
8
|
+
* worker that edits `.gitignore` mid-run cannot quietly move a file out of view.
|
|
9
|
+
* That check is the reason a package install inside a worktree fails a node --
|
|
10
|
+
* `snapshot_ignore_changed` -- rather than passing with an unexplained diff.
|
|
11
|
+
*
|
|
12
|
+
* This is repository knowledge, not contract knowledge: it stats files, reads
|
|
13
|
+
* ignore rules and resolves symlinks. It lived in `contract/verification.mjs`
|
|
14
|
+
* because the verification schema happened to be in the same file.
|
|
15
|
+
*/
|
|
16
|
+
import { Buffer } from "node:buffer";
|
|
17
|
+
import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
|
|
18
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
19
|
+
import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { errorCode } from "../util.mjs";
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { fail, isContained } from "../util.mjs";
|
|
24
|
+
import { normalizeManagedSignalBlock } from "./signal-block.mjs";
|
|
25
|
+
|
|
26
|
+
/** A `node_modules` directory at any depth, matched as a whole path segment. */
|
|
27
|
+
const NODE_MODULES_SEGMENT = /(?:^|\/)node_modules(?:\/|$)/u;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One entry of a workspace snapshot.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {{path: string, kind: "file"|"symlink"|"missing", digest?: string, size?: number}} SnapshotEntry
|
|
33
|
+
*/
|
|
34
|
+
/** @typedef {{entries: SnapshotEntry[], ignoreSources: SnapshotEntry[], truncated: boolean}} WorkspaceSnapshot */
|
|
35
|
+
/** @typedef {{literal: string, paths: string[]}} WorkspaceScopeOrigin */
|
|
36
|
+
/** @typedef {{schemaVersion: 1, files: string[], roots: string[], fileRoots?: string[], fileOrigins: WorkspaceScopeOrigin[], rootOrigins: WorkspaceScopeOrigin[]}} WorkspaceScopeBoundary */
|
|
37
|
+
/** @typedef {{files?: string[], roots?: string[], boundary?: WorkspaceScopeBoundary}} WorkspaceScope */
|
|
38
|
+
/**
|
|
39
|
+
* Result of comparing a baseline snapshot against the current workspace.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {{after: WorkspaceSnapshot, changedPaths: string[], unexpectedPaths: string[]}} ScopeComparison
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} cwd
|
|
47
|
+
* @param {SnapshotEntry[]} [expectedIgnoreSources]
|
|
48
|
+
* @returns {WorkspaceSnapshot}
|
|
49
|
+
*/
|
|
50
|
+
export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
|
|
51
|
+
const root = realpathSync(cwd);
|
|
52
|
+
const ignoreSources = captureIgnoreSources(root);
|
|
53
|
+
if (expectedIgnoreSources !== undefined && !sameSnapshotEntries(expectedIgnoreSources, ignoreSources)) {
|
|
54
|
+
throw fail("snapshot_ignore_changed", "workspace ignore sources changed during worker execution");
|
|
55
|
+
}
|
|
56
|
+
/** @type {SnapshotEntry[]} */
|
|
57
|
+
const entries = [];
|
|
58
|
+
const add = (/** @type {SnapshotEntry} */ entry) => {
|
|
59
|
+
if (entries.length >= VERIFICATION_LIMITS.snapshotEntries) {
|
|
60
|
+
throw fail("snapshot_too_large", `workspace snapshot exceeds ${VERIFICATION_LIMITS.snapshotEntries} entries`);
|
|
61
|
+
}
|
|
62
|
+
entries.push(entry);
|
|
63
|
+
};
|
|
64
|
+
for (const rel of relevantWorkspacePaths(root)) {
|
|
65
|
+
if (Buffer.byteLength(rel, "utf8") > VERIFICATION_LIMITS.snapshotPathBytes) {
|
|
66
|
+
throw fail("snapshot_path_too_long", `workspace path exceeds ${VERIFICATION_LIMITS.snapshotPathBytes} bytes: ${rel.slice(0, 128)}`);
|
|
67
|
+
}
|
|
68
|
+
const child = resolve(root, rel);
|
|
69
|
+
let metadata;
|
|
70
|
+
try {
|
|
71
|
+
metadata = lstatSync(child);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
74
|
+
add({ path: rel, kind: "missing" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
throw fail("snapshot_read_error", `cannot inspect workspace path ${rel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
if (metadata.isDirectory()) continue;
|
|
80
|
+
if (metadata.isSymbolicLink()) {
|
|
81
|
+
let target;
|
|
82
|
+
let targetReal;
|
|
83
|
+
try {
|
|
84
|
+
target = readlinkSync(child);
|
|
85
|
+
targetReal = realpathSync(child);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw fail("snapshot_read_error", `cannot resolve workspace symlink ${rel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
if (!isContained(root, targetReal)) {
|
|
90
|
+
throw fail("snapshot_symlink_escape", `workspace symlink escapes workspace: ${rel}`);
|
|
91
|
+
}
|
|
92
|
+
add({ path: rel, kind: "symlink", digest: `link:${target}:${targetReal}` });
|
|
93
|
+
} else if (metadata.isFile()) {
|
|
94
|
+
const identity = fileIdentity(child, metadata);
|
|
95
|
+
add({ path: rel, kind: "file", digest: identity.digest, size: identity.size });
|
|
96
|
+
} else {
|
|
97
|
+
throw fail("snapshot_unsupported_entry", `unsupported workspace entry: ${rel}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { entries, ignoreSources, truncated: false };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* @param {WorkspaceSnapshot|undefined} before
|
|
104
|
+
* @param {string} cwd
|
|
105
|
+
* @param {WorkspaceScope} scope
|
|
106
|
+
* @returns {ScopeComparison}
|
|
107
|
+
*/
|
|
108
|
+
export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
|
|
109
|
+
if (!before || !Array.isArray(before.entries) || !Array.isArray(before.ignoreSources) || before.truncated) {
|
|
110
|
+
throw fail("snapshot_invalid", "workspace baseline snapshot is missing or truncated");
|
|
111
|
+
}
|
|
112
|
+
if (!scope || typeof scope !== "object" || Array.isArray(scope)) {
|
|
113
|
+
throw fail("scope_invalid", "workspace scope must be an object");
|
|
114
|
+
}
|
|
115
|
+
const root = realpathSync(cwd);
|
|
116
|
+
const allowed = /** @type {WorkspaceScopeBoundary} */ (scope.boundary
|
|
117
|
+
? validateWorkspaceScopeBoundary(root, scope.boundary, scope)
|
|
118
|
+
: {
|
|
119
|
+
files: expandScopePaths(root, normalizeScopePaths(scope.files ?? [], "files")),
|
|
120
|
+
roots: expandScopePaths(root, normalizeScopePaths(scope.roots ?? [], "roots")),
|
|
121
|
+
});
|
|
122
|
+
const after = captureWorkspaceSnapshot(cwd, before.ignoreSources);
|
|
123
|
+
if (!sameSnapshotEntries(before.ignoreSources, after.ignoreSources)) {
|
|
124
|
+
throw fail("snapshot_ignore_changed", "workspace ignore sources changed during worker execution");
|
|
125
|
+
}
|
|
126
|
+
const prior = new Map(before.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
127
|
+
const current = new Map(after.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
128
|
+
const changed = new Set();
|
|
129
|
+
for (const path of new Set([...prior.keys(), ...current.keys()])) {
|
|
130
|
+
if (prior.get(path) !== current.get(path)) changed.add(path);
|
|
131
|
+
}
|
|
132
|
+
const changedPaths = [...changed].sort();
|
|
133
|
+
// A root that names a regular file authorizes exactly that path; only a
|
|
134
|
+
// directory root covers what is beneath it (TECH-SPEC lean, rule 1).
|
|
135
|
+
const fileRoots = new Set(allowed.fileRoots ?? []);
|
|
136
|
+
const directoryRoots = allowed.roots.filter((scopeRoot) => !fileRoots.has(scopeRoot));
|
|
137
|
+
const unexpectedPaths = changedPaths.filter((path) =>
|
|
138
|
+
!allowed.files.includes(path) &&
|
|
139
|
+
!fileRoots.has(path) &&
|
|
140
|
+
!directoryRoots.some((scopeRoot) => path === scopeRoot || path.startsWith(`${scopeRoot}/`)));
|
|
141
|
+
return { after, changedPaths, unexpectedPaths };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a declared worker scope before an untrusted worker starts. The
|
|
145
|
+
* returned paths are the literal declarations plus the contained target paths
|
|
146
|
+
* reached through symlinks that exist at capture time.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} cwd
|
|
149
|
+
* @param {WorkspaceScope} scope
|
|
150
|
+
* @returns {WorkspaceScopeBoundary}
|
|
151
|
+
*/
|
|
152
|
+
export function captureWorkspaceScope(cwd, scope = {}) {
|
|
153
|
+
const root = realpathSync(cwd);
|
|
154
|
+
const declaredFiles = normalizeScopePaths(scope.files ?? [], "files");
|
|
155
|
+
const declaredRoots = normalizeScopePaths(scope.roots ?? [], "roots");
|
|
156
|
+
const fileOrigins = declaredFiles.map((literal) => ({ literal, paths: expandScopePaths(root, [literal]) }));
|
|
157
|
+
const rootOrigins = declaredRoots.map((literal) => ({ literal, paths: expandScopePaths(root, [literal]) }));
|
|
158
|
+
const rootPaths = [...new Set(rootOrigins.flatMap((origin) => origin.paths))];
|
|
159
|
+
return validateWorkspaceScopeBoundary(cwd, {
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
files: [...new Set(fileOrigins.flatMap((origin) => origin.paths))],
|
|
162
|
+
roots: rootPaths,
|
|
163
|
+
fileRoots: rootPaths.filter((path) => isRegularWorkspaceFile(root, path)),
|
|
164
|
+
fileOrigins,
|
|
165
|
+
rootOrigins,
|
|
166
|
+
}, { files: declaredFiles, roots: declaredRoots });
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Whether a declared scope root is an existing regular file. Decided once at
|
|
170
|
+
* capture time and carried in the persisted boundary: a worker that later
|
|
171
|
+
* replaces the file with a same-named directory must not win directory
|
|
172
|
+
* authority over the path.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} root
|
|
175
|
+
* @param {string} path
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function isRegularWorkspaceFile(root, path) {
|
|
179
|
+
try {
|
|
180
|
+
return statSync(resolve(root, path)).isFile();
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Validate a persisted worker scope without following its current symlink
|
|
187
|
+
* graph. Only lexical, relative paths from the captured boundary are used.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} cwd
|
|
190
|
+
* @param {unknown} boundary
|
|
191
|
+
* @param {WorkspaceScope} [declared]
|
|
192
|
+
* @returns {WorkspaceScopeBoundary}
|
|
193
|
+
*/
|
|
194
|
+
export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
|
|
195
|
+
if (!boundary || typeof boundary !== "object" || Array.isArray(boundary)) {
|
|
196
|
+
throw fail("scope_boundary_invalid", "persisted worker scope boundary must be an object");
|
|
197
|
+
}
|
|
198
|
+
const value = /** @type {Record<string, unknown>} */ (boundary);
|
|
199
|
+
if (value.schemaVersion !== 1 || !Array.isArray(value.files) || !Array.isArray(value.roots) || !Array.isArray(value.fileOrigins) || !Array.isArray(value.rootOrigins)) {
|
|
200
|
+
throw fail("scope_boundary_invalid", "persisted worker scope boundary is malformed");
|
|
201
|
+
}
|
|
202
|
+
const root = realpathSync(cwd);
|
|
203
|
+
const files = normalizeScopePaths(value.files, "boundary.files");
|
|
204
|
+
const roots = normalizeScopePaths(value.roots, "boundary.roots");
|
|
205
|
+
const fileRoots = value.fileRoots === undefined ? [] : normalizeScopePaths(value.fileRoots, "boundary.fileRoots");
|
|
206
|
+
if (files.length + roots.length + fileRoots.length + value.fileOrigins.length + value.rootOrigins.length > VERIFICATION_LIMITS.snapshotEntries) {
|
|
207
|
+
throw fail("scope_boundary_too_large", "persisted worker scope boundary is too large");
|
|
208
|
+
}
|
|
209
|
+
if (fileRoots.some((path) => !roots.includes(path))) {
|
|
210
|
+
throw fail("scope_boundary_invalid", "persisted worker scope file roots must be declared roots");
|
|
211
|
+
}
|
|
212
|
+
const declaredFiles = normalizeScopePaths(declared.files ?? [], "files");
|
|
213
|
+
const declaredRoots = normalizeScopePaths(declared.roots ?? [], "roots");
|
|
214
|
+
/**
|
|
215
|
+
* @param {unknown[]} rawOrigins
|
|
216
|
+
* @param {"file"|"root"} kind
|
|
217
|
+
* @param {string[]} declaredPaths
|
|
218
|
+
* @param {string[]} boundedPaths
|
|
219
|
+
* @returns {WorkspaceScopeOrigin[]}
|
|
220
|
+
*/
|
|
221
|
+
const validateOrigins = (rawOrigins, kind, declaredPaths, boundedPaths) => {
|
|
222
|
+
const origins = [];
|
|
223
|
+
const literals = new Set();
|
|
224
|
+
const union = [];
|
|
225
|
+
for (const raw of /** @type {unknown[]} */ (rawOrigins)) {
|
|
226
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw fail("scope_boundary_invalid", `persisted ${kind} scope origins are malformed`);
|
|
227
|
+
const origin = /** @type {Record<string, unknown>} */ (raw);
|
|
228
|
+
if (Object.keys(origin).some((key) => key !== "literal" && key !== "paths")) throw fail("scope_boundary_invalid", `persisted ${kind} scope origins are malformed`);
|
|
229
|
+
const literal = normalizeScopePaths([origin.literal], `${kind}Origins.literal`)[0];
|
|
230
|
+
const paths = normalizeScopePaths(origin.paths, `${kind}Origins.paths`);
|
|
231
|
+
if (literals.has(literal) || !declaredPaths.includes(literal) || !paths.includes(literal)) {
|
|
232
|
+
throw fail("scope_boundary_inconsistent", `persisted ${kind} scope origins do not match the declared scope`);
|
|
233
|
+
}
|
|
234
|
+
literals.add(literal);
|
|
235
|
+
union.push(...paths);
|
|
236
|
+
origins.push({ literal, paths: [...new Set(paths)] });
|
|
237
|
+
}
|
|
238
|
+
if (literals.size !== declaredPaths.length || declaredPaths.some((path) => !literals.has(path))) {
|
|
239
|
+
throw fail("scope_boundary_inconsistent", `persisted ${kind} scope origins do not match the declared scope`);
|
|
240
|
+
}
|
|
241
|
+
const actual = [...new Set(boundedPaths)];
|
|
242
|
+
const expected = [...new Set(union)];
|
|
243
|
+
if (actual.length !== expected.length || actual.some((path) => !expected.includes(path))) {
|
|
244
|
+
throw fail("scope_boundary_inconsistent", `persisted ${kind} scope boundary paths do not match their origins`);
|
|
245
|
+
}
|
|
246
|
+
return origins;
|
|
247
|
+
};
|
|
248
|
+
const fileOrigins = validateOrigins(value.fileOrigins, "file", declaredFiles, files);
|
|
249
|
+
const rootOrigins = validateOrigins(value.rootOrigins, "root", declaredRoots, roots);
|
|
250
|
+
// Keep the cwd resolution as an explicit portability/containment check. The
|
|
251
|
+
// paths themselves are relative and must never be converted through the
|
|
252
|
+
// current symlink graph during validation.
|
|
253
|
+
if (!root) throw fail("scope_boundary_invalid", "workspace cwd is unavailable");
|
|
254
|
+
return {
|
|
255
|
+
schemaVersion: 1,
|
|
256
|
+
files: [...new Set(files)],
|
|
257
|
+
roots: [...new Set(roots)],
|
|
258
|
+
fileRoots: [...new Set(fileRoots)],
|
|
259
|
+
fileOrigins,
|
|
260
|
+
rootOrigins,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Snapshot the files Git can use to hide workspace changes. These entries are
|
|
265
|
+
* kept separate from the relevant-file entry cap. A worker cannot replace
|
|
266
|
+
* ignore rules and then make the new rules authoritative for the comparison.
|
|
267
|
+
* Any source change fails closed before scope matching.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} root
|
|
270
|
+
* @returns {SnapshotEntry[]}
|
|
271
|
+
*/
|
|
272
|
+
function captureIgnoreSources(root) {
|
|
273
|
+
/** @type {Set<string>} */
|
|
274
|
+
const paths = new Set([".faberunignore", ".gitignore", ".git/config"]);
|
|
275
|
+
/** @type {Map<string, string>} */
|
|
276
|
+
const gitPaths = new Map();
|
|
277
|
+
/** @param {string} name @param {string} logical @returns {string|null} */
|
|
278
|
+
const resolveGitPath = (name, logical) => {
|
|
279
|
+
try {
|
|
280
|
+
const value = execFileSync("git", ["-C", root, "rev-parse", "--git-path", name], {
|
|
281
|
+
encoding: "utf8",
|
|
282
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
283
|
+
}).trim();
|
|
284
|
+
if (!value) return null;
|
|
285
|
+
const actual = isAbsolute(value) ? value : resolve(root, value);
|
|
286
|
+
gitPaths.set(logical, actual);
|
|
287
|
+
return actual;
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
resolveGitPath("config", ".git/config");
|
|
293
|
+
const excludePath = resolveGitPath("info/exclude", ".git/info/exclude");
|
|
294
|
+
try {
|
|
295
|
+
if (lstatSync(resolve(root, ".git")).isFile()) paths.add(".git");
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (errorCode(error) !== "ENOENT") {
|
|
298
|
+
throw fail("snapshot_read_error", `cannot inspect Git repository identity: ${error instanceof Error ? error.message : String(error)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const walk = (/** @type {string} */ directory) => {
|
|
302
|
+
let entries;
|
|
303
|
+
try {
|
|
304
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// A directory that vanished between the parent's readdir and this one
|
|
307
|
+
// (a test suite's temporary tree under an ignored `target/`, a build
|
|
308
|
+
// cache being rotated) is not a snapshot failure: it holds no ignore
|
|
309
|
+
// source any more. Only a directory that exists and cannot be read is.
|
|
310
|
+
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return;
|
|
311
|
+
throw fail("snapshot_read_error", `cannot inspect workspace directory ${relativeWorkspacePath(root, directory)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
312
|
+
}
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
if (entry.name === ".git" || entry.name === ".runs") continue;
|
|
315
|
+
// Same exclusions as the entries snapshot (see the task-packet
|
|
316
|
+
// reference): `node_modules` at any depth, and the agent runtimes'
|
|
317
|
+
// scratch state at the root (`.claude`, `.codex`). Ignore files inside
|
|
318
|
+
// them never change what git sees of the workspace, while a package
|
|
319
|
+
// install or an agent worktree under `.claude/worktrees/` adds and
|
|
320
|
+
// removes `.gitignore` files mid-node — which used to fail the node
|
|
321
|
+
// with `snapshot_ignore_changed`.
|
|
322
|
+
// A virtual environment is the same class as node_modules: it and its
|
|
323
|
+
// packages carry .gitignore files that would move the fingerprint.
|
|
324
|
+
if (entry.name === "node_modules" || entry.name === ".venv" || entry.name === "venv") continue;
|
|
325
|
+
if (directory === root && (entry.name === ".claude" || entry.name === ".codex")) continue;
|
|
326
|
+
const child = resolve(directory, entry.name);
|
|
327
|
+
if (entry.isDirectory() && isVirtualEnv(child)) continue;
|
|
328
|
+
if (entry.isDirectory()) {
|
|
329
|
+
walk(child);
|
|
330
|
+
} else if (entry.name === ".gitignore") {
|
|
331
|
+
paths.add(relativeWorkspacePath(root, child));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
walk(root);
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
if (excludePath && (lstatSync(excludePath).isFile() || lstatSync(excludePath).isSymbolicLink())) paths.add(".git/info/exclude");
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (errorCode(error) !== "ENOENT" && errorCode(error) !== "ENOTDIR") {
|
|
341
|
+
throw fail("snapshot_read_error", `cannot inspect Git exclude source: ${error instanceof Error ? error.message : String(error)}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** @param {string} path @returns {SnapshotEntry[]} */
|
|
346
|
+
const capturePath = (path) => {
|
|
347
|
+
const child = gitPaths.get(path) ?? resolve(root, path);
|
|
348
|
+
const isEffectiveGitPath = gitPaths.has(path);
|
|
349
|
+
let metadata;
|
|
350
|
+
try {
|
|
351
|
+
metadata = lstatSync(child);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return [];
|
|
354
|
+
throw fail("snapshot_read_error", `cannot inspect ignore source ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
355
|
+
}
|
|
356
|
+
if (Buffer.byteLength(path, "utf8") > VERIFICATION_LIMITS.snapshotPathBytes) {
|
|
357
|
+
throw fail("snapshot_path_too_long", `workspace path exceeds ${VERIFICATION_LIMITS.snapshotPathBytes} bytes: ${path.slice(0, 128)}`);
|
|
358
|
+
}
|
|
359
|
+
let sourceReal;
|
|
360
|
+
try {
|
|
361
|
+
sourceReal = realpathSync(child);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
throw fail("snapshot_read_error", `cannot resolve ignore source ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
364
|
+
}
|
|
365
|
+
if (!isEffectiveGitPath && !isContained(root, sourceReal)) throw fail("snapshot_symlink_escape", `workspace symlink escapes workspace: ${path}`);
|
|
366
|
+
if (metadata.isSymbolicLink()) {
|
|
367
|
+
let target;
|
|
368
|
+
let targetReal;
|
|
369
|
+
try {
|
|
370
|
+
target = readlinkSync(child);
|
|
371
|
+
targetReal = realpathSync(child);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw fail("snapshot_read_error", `cannot resolve ignore source symlink ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
374
|
+
}
|
|
375
|
+
if (!isEffectiveGitPath && !isContained(root, targetReal)) throw fail("snapshot_symlink_escape", `workspace symlink escapes workspace: ${path}`);
|
|
376
|
+
return [{ path, kind: "symlink", digest: `link:${target}:${targetReal}` }];
|
|
377
|
+
}
|
|
378
|
+
if (!metadata.isFile()) throw fail("snapshot_unsupported_entry", `unsupported ignore source: ${path}`);
|
|
379
|
+
if (path === ".git/config") {
|
|
380
|
+
// Only the ignore-relevant settings of the repository config are part
|
|
381
|
+
// of the fingerprint. The file also carries branch tracking, remotes and
|
|
382
|
+
// worktree bookkeeping that any git client edits at will — a sibling
|
|
383
|
+
// session creating a tracking branch mid-node used to fail the node with
|
|
384
|
+
// `snapshot_ignore_changed` although no ignore rule had moved.
|
|
385
|
+
const relevant = readFileSync(child, "utf8")
|
|
386
|
+
.split(/\r?\n/u)
|
|
387
|
+
.map((line) => line.trim())
|
|
388
|
+
.filter((line) => /excludesfile/iu.test(line))
|
|
389
|
+
.sort()
|
|
390
|
+
.join("\n");
|
|
391
|
+
const bytes = Buffer.byteLength(relevant, "utf8");
|
|
392
|
+
return [{ path, kind: "file", digest: `config:${createHash("sha256").update(relevant).digest("hex")}:${bytes}`, size: bytes }];
|
|
393
|
+
}
|
|
394
|
+
return [{ path, kind: "file", digest: fileIdentity(child, metadata).digest, size: metadata.size }];
|
|
395
|
+
};
|
|
396
|
+
return [...paths].sort().flatMap(capturePath);
|
|
397
|
+
}
|
|
398
|
+
/** @param {SnapshotEntry[]} before @param {SnapshotEntry[]} after @returns {boolean} */
|
|
399
|
+
function sameSnapshotEntries(before, after) {
|
|
400
|
+
if (before.length !== after.length) return false;
|
|
401
|
+
return before.every((entry, index) => JSON.stringify(entry) === JSON.stringify(after[index]));
|
|
402
|
+
}
|
|
403
|
+
/** @param {string} root @param {string[]} paths @returns {string[]} */
|
|
404
|
+
function expandScopePaths(root, paths) {
|
|
405
|
+
return [...new Set(paths.flatMap((path) => {
|
|
406
|
+
const resolvedPath = resolveScopePath(root, path);
|
|
407
|
+
return resolvedPath === path ? [path] : [path, resolvedPath];
|
|
408
|
+
}))];
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a declared path through existing symlinks while retaining any new
|
|
412
|
+
* trailing components. Git reports files below a symlink using the target's
|
|
413
|
+
* path, so both the declared spelling and its contained target spelling are
|
|
414
|
+
* accepted for that explicit scope.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} root
|
|
417
|
+
* @param {string} path
|
|
418
|
+
* @returns {string}
|
|
419
|
+
*/
|
|
420
|
+
function resolveScopePath(root, path) {
|
|
421
|
+
const components = workspacePathComponents(path);
|
|
422
|
+
let current = root;
|
|
423
|
+
let index = 0;
|
|
424
|
+
for (; index < components.length; index += 1) {
|
|
425
|
+
const candidate = resolve(current, components[index]);
|
|
426
|
+
try {
|
|
427
|
+
const target = realpathSync(candidate);
|
|
428
|
+
if (!isContained(root, target)) throw fail("scope_symlink_escape", `workspace scope path escapes workspace: ${path}`);
|
|
429
|
+
current = target;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (errorCode(error) === "ENOTDIR") throw fail("scope_invalid", `workspace scope path is not a directory: ${path}`);
|
|
432
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
433
|
+
current = resolve(current, ...components.slice(index));
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const resolvedPath = relativeWorkspacePath(root, current);
|
|
438
|
+
return resolvedPath || path;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* @param {string} cwd
|
|
442
|
+
* @returns {string[]}
|
|
443
|
+
*/
|
|
444
|
+
function relevantWorkspacePaths(cwd) {
|
|
445
|
+
const args = ["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard"];
|
|
446
|
+
try {
|
|
447
|
+
lstatSync(resolve(cwd, ".faberunignore"));
|
|
448
|
+
args.push("--exclude-from=.faberunignore");
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
|
|
451
|
+
throw fail("snapshot_read_error", `cannot inspect .faberunignore: ${error instanceof Error ? error.message : String(error)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
args.push("-z");
|
|
455
|
+
let output;
|
|
456
|
+
try {
|
|
457
|
+
output = execFileSync("git", args, {
|
|
458
|
+
cwd,
|
|
459
|
+
encoding: "buffer",
|
|
460
|
+
maxBuffer: VERIFICATION_LIMITS.snapshotEntries * (VERIFICATION_LIMITS.snapshotPathBytes + 1) + 1,
|
|
461
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw fail("snapshot_git_error", `cannot enumerate relevant workspace files: ${error instanceof Error ? error.message : String(error)}`);
|
|
465
|
+
}
|
|
466
|
+
const paths = new Set();
|
|
467
|
+
for (const value of output.toString("utf8").split("\0")) {
|
|
468
|
+
if (!value) continue;
|
|
469
|
+
if (value === ".runs" || value.startsWith(".runs/")) continue;
|
|
470
|
+
// Same exclusion as the ignore-source walk above: a repository's
|
|
471
|
+
// gitignore conventionally excludes `node_modules/` as a directory
|
|
472
|
+
// pattern, which does not match the symlink an attempt worktree links it
|
|
473
|
+
// in as (TECH-SPEC lean v0.3 section 3 rule 4). Exclude it here too, so
|
|
474
|
+
// linking never turns an installed dependency tree into an unexpected
|
|
475
|
+
// workspace write or a symlink escape.
|
|
476
|
+
//
|
|
477
|
+
// At any depth, which the ignore-source walk already did and this did not,
|
|
478
|
+
// despite the comment claiming parity. A monorepo that anchors the pattern
|
|
479
|
+
// to the root (`/node_modules/`) leaves `packages/*/node_modules` visible,
|
|
480
|
+
// so a per-package install mid-node walked thousands of dependency files
|
|
481
|
+
// into the snapshot and could trip `snapshot_too_large`.
|
|
482
|
+
if (NODE_MODULES_SEGMENT.test(value)) continue;
|
|
483
|
+
if (paths.size >= VERIFICATION_LIMITS.snapshotEntries) {
|
|
484
|
+
throw fail("snapshot_too_large", `workspace snapshot exceeds ${VERIFICATION_LIMITS.snapshotEntries} entries`);
|
|
485
|
+
}
|
|
486
|
+
paths.add(value);
|
|
487
|
+
}
|
|
488
|
+
return [...paths].sort();
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* @param {unknown} paths
|
|
492
|
+
* @param {string} label
|
|
493
|
+
* @returns {string[]}
|
|
494
|
+
*/
|
|
495
|
+
function normalizeScopePaths(paths, label) {
|
|
496
|
+
if (!Array.isArray(paths)) throw fail("scope_invalid", `workspace scope.${label} must be an array`);
|
|
497
|
+
return paths.map((path) => {
|
|
498
|
+
const separators = process.platform === "win32" ? /[\\/]/u : /\//u;
|
|
499
|
+
if (typeof path !== "string" || path.length === 0 || isAbsolute(path) || /^[A-Za-z]:[\\/]/u.test(path) || new RegExp(`(?:^|${separators.source})\\.\\.(?:${separators.source}|$)`, "u").test(path)) {
|
|
500
|
+
throw fail("scope_invalid", `workspace scope.${label} contains an invalid path`);
|
|
501
|
+
}
|
|
502
|
+
const normalized = (process.platform === "win32" ? path.replaceAll("\\", "/") : path).replace(/\/+$/u, "");
|
|
503
|
+
if (!normalized || normalized === ".") throw fail("scope_invalid", `workspace scope.${label} contains an invalid path`);
|
|
504
|
+
if (Buffer.byteLength(normalized, "utf8") > VERIFICATION_LIMITS.snapshotPathBytes) {
|
|
505
|
+
throw fail("scope_path_too_long", `workspace scope.${label} path exceeds ${VERIFICATION_LIMITS.snapshotPathBytes} bytes`);
|
|
506
|
+
}
|
|
507
|
+
return normalized;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/** @param {string} root @param {string} path @returns {string} */
|
|
511
|
+
function relativeWorkspacePath(root, path) {
|
|
512
|
+
const value = relative(root, path);
|
|
513
|
+
return process.platform === "win32" ? value.replaceAll("\\", "/") : value;
|
|
514
|
+
}
|
|
515
|
+
/** @param {string} path @returns {string[]} */
|
|
516
|
+
function workspacePathComponents(path) {
|
|
517
|
+
return (process.platform === "win32" ? path.replaceAll("\\", "/") : path).split("/").filter((component) => component && component !== ".");
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* @param {string} path
|
|
521
|
+
* @param {import("node:fs").Stats} metadata
|
|
522
|
+
* @returns {{digest: string, size: number}}
|
|
523
|
+
*/
|
|
524
|
+
function fileIdentity(path, metadata) {
|
|
525
|
+
if (basename(path) === "AGENTS.md") {
|
|
526
|
+
// The runner rewrites the machine-managed signal block as run state
|
|
527
|
+
// changes. Hash the file with only that complete block normalized so
|
|
528
|
+
// runner-owned block edits are not mistaken for worker scope drift, while
|
|
529
|
+
// human-authored guidance outside the block still changes the identity.
|
|
530
|
+
const normalized = normalizeManagedSignalBlock(readFileSync(path, "utf8"));
|
|
531
|
+
const bytes = Buffer.from(normalized, "utf8");
|
|
532
|
+
return {
|
|
533
|
+
digest: `file:${createHash("sha256").update(normalized).digest("hex")}:${bytes.byteLength}:${metadata.mode}`,
|
|
534
|
+
size: bytes.byteLength,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const hash = createHash("sha256");
|
|
538
|
+
let fd;
|
|
539
|
+
try {
|
|
540
|
+
fd = openSync(path, "r");
|
|
541
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
542
|
+
let position = 0;
|
|
543
|
+
while (position < metadata.size) {
|
|
544
|
+
const read = readSync(fd, buffer, 0, Math.min(buffer.length, metadata.size - position), position);
|
|
545
|
+
if (read <= 0) throw fail("snapshot_read_error", `short read for workspace file ${path}`);
|
|
546
|
+
hash.update(buffer.subarray(0, read));
|
|
547
|
+
position += read;
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
throw error && typeof error === "object" && "code" in error ? error : fail("snapshot_read_error", `cannot hash workspace file ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
551
|
+
} finally {
|
|
552
|
+
if (fd !== undefined) closeSync(fd);
|
|
553
|
+
}
|
|
554
|
+
return { digest: `file:${hash.digest("hex")}:${metadata.size}:${metadata.mode}`, size: metadata.size };
|
|
555
|
+
}
|
|
556
|
+
/** A virtual environment under any name, identified by its marker file. @param {string} directory @returns {boolean} */
|
|
557
|
+
function isVirtualEnv(directory) { try { return statSync(resolve(directory, "pyvenv.cfg")).isFile(); } catch { return false; } }
|