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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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; } }