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,495 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { Buffer } from "node:buffer";
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { validateVerificationCommands } from "./verification.mjs";
5
+ import { errorCode } from "../util.mjs";
6
+ import { requireString, requireStringArray } from "./assert.mjs";
7
+
8
+ const FIELDS = new Set([
9
+ "mode",
10
+ "objective",
11
+ "instructions",
12
+ "readFiles",
13
+ "writeFiles",
14
+ "writeRoots",
15
+ "symbols",
16
+ "scopeAcknowledged",
17
+ "decisions",
18
+ "nonGoals",
19
+ "verification",
20
+ ]);
21
+ const PROMPT_MAX_BYTES = 64 * 1024;
22
+
23
+ /**
24
+ * `validateRelativePath`'s answer when a read path is absent and the caller
25
+ * asked to defer the missing-read verdict rather than throw it. Only contract
26
+ * loading opts in; every other caller throws the missing-read error in place.
27
+ */
28
+ const DEFERRED_MISSING = Symbol("deferred-missing");
29
+
30
+ /** @typedef {import("./verification.mjs").VerificationCommand} VerificationCommand */
31
+
32
+ /**
33
+ * A closed task packet: the durable scope and instructions for one node.
34
+ *
35
+ * @typedef {{mode: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], readFiles: string[], writeFiles?: string[], writeRoots?: string[], symbols: string[], scopeAcknowledged?: string[], decisions: string[], nonGoals: string[], verification: VerificationCommand[]}} TaskPacket
36
+ */
37
+
38
+ /**
39
+ * Node fields consumed by packet loading. Exactly one of `taskPacket` or
40
+ * `taskPacketFile` must be present; `prompt` and `promptFile` are rejected.
41
+ *
42
+ * @typedef {{taskPacket?: TaskPacket, taskPacketFile?: string, prompt?: unknown, promptFile?: unknown}} TaskPacketNode
43
+ */
44
+
45
+ /**
46
+ * @param {TaskPacketNode} node
47
+ * @param {string} contractDir
48
+ * @param {string} cwd
49
+ * @param {number} index
50
+ * @param {{deferMissingReads?: boolean, deferredReads?: {path: string, label: string}[], persisted?: boolean}} [options]
51
+ * @returns {TaskPacket}
52
+ */
53
+ export function loadTaskPacket(node, contractDir, cwd, index, options = {}) {
54
+ if (node.prompt !== undefined || node.promptFile !== undefined) {
55
+ throw new TypeError(
56
+ `nodes[${index}] must not use prompt or promptFile; provide exactly one of taskPacket or taskPacketFile`,
57
+ );
58
+ }
59
+ const inline = node.taskPacket !== undefined && node.taskPacket !== null;
60
+ const fromFile = typeof node.taskPacketFile === "string" && Boolean(node.taskPacketFile);
61
+ if (inline === fromFile) {
62
+ throw new TypeError(`nodes[${index}] needs exactly one of taskPacket or taskPacketFile`);
63
+ }
64
+ let packet = /** @type {TaskPacket|undefined} */ (node.taskPacket);
65
+ if (fromFile) {
66
+ const taskPacketFile = /** @type {string} */ (node.taskPacketFile);
67
+ // A persisted contract always stores its packet inline, so this path is
68
+ // authoring-only; skipping the probe keeps the load off the tree anyway.
69
+ if (options.persisted !== true) validateRelativePath(taskPacketFile, `nodes[${index}].taskPacketFile`, contractDir, true);
70
+ const path = resolve(contractDir, taskPacketFile);
71
+ try {
72
+ packet = JSON.parse(readFileSync(path, "utf8"));
73
+ } catch (error) {
74
+ if (errorCode(error) === "ENOENT") {
75
+ throw new TypeError(`nodes[${index}].taskPacketFile does not exist: ${path}`);
76
+ }
77
+ if (error instanceof SyntaxError) throw new TypeError(`nodes[${index}].taskPacketFile is not valid JSON: ${error.message}`);
78
+ throw error;
79
+ }
80
+ }
81
+ return validateTaskPacket(packet, index, cwd, options);
82
+ }
83
+
84
+ /**
85
+ * @param {TaskPacket} packet
86
+ * @param {string} nodeId
87
+ * @returns {string}
88
+ */
89
+ export function renderWorkerPrompt(packet, nodeId) {
90
+ if (packet.mode === "discovery") return renderDiscoveryPrompt(packet, nodeId);
91
+ if (packet.mode === "autonomous") return renderAutonomousPrompt(packet, nodeId);
92
+ const lines = [
93
+ `# Node ${nodeId}`,
94
+ "",
95
+ "## Objective",
96
+ packet.objective,
97
+ "",
98
+ "## Closed context",
99
+ "This execution context is closed. Inspect only the listed read files, edit only the listed write files, and do not perform repository-wide discovery. If required context is unavailable, return the blocked_context worker-result object below.",
100
+ "",
101
+ "## Instructions",
102
+ ...numbered(packet.instructions),
103
+ "",
104
+ "## Read files",
105
+ ...bulletOrNone(packet.readFiles),
106
+ "",
107
+ "## Write files",
108
+ ...bulletOrNone(/** @type {string[]} */ (packet.writeFiles)),
109
+ "",
110
+ "## Symbols",
111
+ ...bulletOrNone(packet.symbols),
112
+ "",
113
+ "## Decisions already made",
114
+ ...bulletOrNone(packet.decisions),
115
+ "",
116
+ "## Non-goals",
117
+ ...bulletOrNone(packet.nonGoals),
118
+ "",
119
+ "## Verification",
120
+ "Run each command yourself before reporting done. Keep command output bounded: pipe long output through `| tail -n 200` (or similar) and never paste full test or fuzz logs into your context or results.",
121
+ ...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
122
+ "",
123
+ "## Required output",
124
+ 'Return exactly one JSON object, with no markdown or prose: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.',
125
+ ];
126
+ const prompt = `${lines.join("\n")}\n`;
127
+ if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) {
128
+ throw new TypeError(`worker prompt exceeds ${PROMPT_MAX_BYTES} bytes`);
129
+ }
130
+ return prompt;
131
+ }
132
+
133
+ /**
134
+ * @param {unknown} packet
135
+ * @param {number} index
136
+ * @param {string} cwd
137
+ * @param {{deferMissingReads?: boolean, deferredReads?: {path: string, label: string}[], persisted?: boolean}} [options]
138
+ * @returns {TaskPacket}
139
+ */
140
+ export function validateTaskPacket(packet, index, cwd, options = {}) {
141
+ if (!packet || typeof packet !== "object" || Array.isArray(packet)) {
142
+ throw new TypeError(`nodes[${index}].taskPacket must be a JSON object`);
143
+ }
144
+ const record = /** @type {Record<string, unknown>} */ (packet);
145
+ for (const field of Object.keys(record)) {
146
+ if (!FIELDS.has(field)) throw new TypeError(`nodes[${index}].taskPacket has unexpected field ${field}`);
147
+ }
148
+ for (const field of ["mode", "objective", "instructions", "symbols", "decisions", "nonGoals", "verification"]) {
149
+ if (record[field] === undefined) throw new TypeError(`nodes[${index}].taskPacket.${field} is required`);
150
+ }
151
+ const mode = record.mode;
152
+ if (mode !== "execution" && mode !== "discovery" && mode !== "autonomous") {
153
+ throw new TypeError(`nodes[${index}].taskPacket.mode must be execution, discovery, or autonomous`);
154
+ }
155
+ requireString(record.objective, `nodes[${index}].taskPacket.objective`);
156
+ requireStringArray(record.instructions, `nodes[${index}].taskPacket.instructions`, true);
157
+ const readFiles = record.readFiles === undefined ? [] : record.readFiles;
158
+ requireStringArray(readFiles, `nodes[${index}].taskPacket.readFiles`);
159
+ requireStringArray(record.symbols, `nodes[${index}].taskPacket.symbols`);
160
+ const scopeAcknowledged = record.scopeAcknowledged === undefined ? [] : record.scopeAcknowledged;
161
+ requireStringArray(scopeAcknowledged, `nodes[${index}].taskPacket.scopeAcknowledged`);
162
+ requireStringArray(record.decisions, `nodes[${index}].taskPacket.decisions`);
163
+ requireStringArray(record.nonGoals, `nodes[${index}].taskPacket.nonGoals`);
164
+ if (!Array.isArray(record.verification)) {
165
+ throw new TypeError(`nodes[${index}].taskPacket.verification must be an array of argv command objects`);
166
+ }
167
+ const verification = validateVerificationCommands(record.verification, `nodes[${index}].taskPacket.verification`);
168
+ const normalizedReadFiles = /** @type {string[]} */ (readFiles);
169
+ const writeFiles = record.writeFiles;
170
+ const writeRoots = record.writeRoots;
171
+ // The persisted load replays decisions already made: every path probe below
172
+ // reads the live tree and is skipped, so a declared readFile that has since
173
+ // been deleted, or a writeRoot that became a symlink, cannot unload a run.
174
+ const persisted = options.persisted === true;
175
+
176
+ if (mode === "execution" || mode === "discovery") {
177
+ if (record.readFiles === undefined) {
178
+ throw new TypeError(`nodes[${index}].taskPacket.readFiles is required`);
179
+ }
180
+ if (writeRoots !== undefined) {
181
+ throw new TypeError(`nodes[${index}].taskPacket.writeRoots is only allowed for autonomous packets`);
182
+ }
183
+ requireStringArray(writeFiles, `nodes[${index}].taskPacket.writeFiles`);
184
+ if (mode === "execution" && !normalizedReadFiles.length) {
185
+ throw new TypeError(`nodes[${index}].taskPacket.readFiles must not be empty for an execution packet`);
186
+ }
187
+ if (mode === "execution" && !/** @type {string[]} */ (writeFiles).length) {
188
+ throw new TypeError(`nodes[${index}].taskPacket.writeFiles must not be empty for an execution packet`);
189
+ }
190
+ } else {
191
+ if (writeFiles !== undefined) {
192
+ throw new TypeError(`nodes[${index}].taskPacket.writeFiles must not be used for an autonomous packet`);
193
+ }
194
+ requireStringArray(writeRoots, `nodes[${index}].taskPacket.writeRoots`);
195
+ if (!/** @type {string[]} */ (writeRoots).length) {
196
+ throw new TypeError(`nodes[${index}].taskPacket.writeRoots must not be empty for an autonomous packet`);
197
+ }
198
+ if (!persisted) {
199
+ /** @type {string[]} */ (writeRoots).forEach((root, rootIndex) => {
200
+ validateWriteRoot(root, `nodes[${index}].taskPacket.writeRoots[${rootIndex}]`, cwd);
201
+ });
202
+ }
203
+ }
204
+ if (mode === "discovery" && /** @type {string[]} */ (writeFiles).length) {
205
+ throw new TypeError(`nodes[${index}].taskPacket.writeFiles must be empty for a discovery packet`);
206
+ }
207
+
208
+ if (!persisted) {
209
+ const deferMissingReads = options.deferMissingReads === true;
210
+ /** @type {{path: string, label: string}[]} */
211
+ const deferredReads = options.deferredReads ?? [];
212
+ normalizedReadFiles.forEach((path, pathIndex) => {
213
+ const label = `nodes[${index}].taskPacket.readFiles[${pathIndex}]`;
214
+ const result = validateRelativePath(path, label, cwd, true, { deferMissing: deferMissingReads });
215
+ if (result === DEFERRED_MISSING) deferredReads.push({ path, label });
216
+ });
217
+ if (writeFiles !== undefined) /** @type {string[]} */ (writeFiles).forEach((path, pathIndex) => {
218
+ validateRelativePath(path, `nodes[${index}].taskPacket.writeFiles[${pathIndex}]`, cwd, false);
219
+ });
220
+ /** @type {string[]} */ (scopeAcknowledged).forEach((path, pathIndex) => {
221
+ validateRelativePath(path, `nodes[${index}].taskPacket.scopeAcknowledged[${pathIndex}]`, cwd, true);
222
+ });
223
+ for (const [commandIndex, command] of verification.entries()) {
224
+ if (command.cwd !== undefined) {
225
+ validateDirectoryPath(command.cwd, `nodes[${index}].taskPacket.verification[${commandIndex}].cwd`, cwd);
226
+ }
227
+ }
228
+ }
229
+ return {
230
+ mode,
231
+ objective: /** @type {string} */ (record.objective),
232
+ instructions: [.../** @type {string[]} */ (record.instructions)],
233
+ readFiles: normalizedReadFiles,
234
+ ...(writeFiles === undefined ? {} : { writeFiles: [.../** @type {string[]} */ (writeFiles)] }),
235
+ ...(writeRoots === undefined ? {} : { writeRoots: [.../** @type {string[]} */ (writeRoots)] }),
236
+ symbols: [.../** @type {string[]} */ (record.symbols)],
237
+ // Kept absent rather than defaulted to [] in the returned packet: the packet
238
+ // hash of every already-authored contract has to stay identical, and a
239
+ // resume compares the recomputed hash to the snapshot's.
240
+ ...(record.scopeAcknowledged === undefined ? {} : { scopeAcknowledged: [.../** @type {string[]} */ (scopeAcknowledged)] }),
241
+ decisions: [.../** @type {string[]} */ (record.decisions)],
242
+ nonGoals: [.../** @type {string[]} */ (record.nonGoals)],
243
+ verification,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * @param {string} path
249
+ * @param {string} label
250
+ * @param {string} cwd
251
+ */
252
+ function validateWriteRoot(path, label, cwd) {
253
+ if (isAbsolute(path)) throw new TypeError(`${label} must be relative to cwd`);
254
+ const absolute = resolve(cwd, path);
255
+ if (path === "." || path === "" || !pathInside(absolute, cwd) || absolute === resolve(cwd)) {
256
+ throw new TypeError(`${label} must name a non-root directory inside cwd`);
257
+ }
258
+
259
+ const realCwd = realpathSync(cwd);
260
+ const actual = resolveActualPath(realCwd, path);
261
+ if (actual === realCwd || !pathInside(actual, realCwd)) throw new TypeError(`${label} escapes cwd`);
262
+
263
+ const anchor = findExistingPath(absolute);
264
+ if (!anchor) throw new TypeError(`${label} has no containing directory inside cwd`);
265
+ const realAnchor = realpathSync(anchor);
266
+ if (!pathInside(realAnchor, realCwd)) throw new TypeError(`${label} escapes cwd`);
267
+ // A file root matches exactly that path in the scope gate; a directory
268
+ // root matches itself and everything beneath it.
269
+ if (existsSync(absolute) && !statSync(absolute).isDirectory() && !statSync(absolute).isFile()) {
270
+ throw new TypeError(`${label} must name a directory or an existing file: ${path}`);
271
+ }
272
+ if (!existsSync(absolute) && !statSync(anchor).isDirectory()) {
273
+ throw new TypeError(`${label} must name a directory: ${path}`);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * @param {string} path
279
+ * @param {string} label
280
+ * @param {string} cwd
281
+ * @param {boolean} mustExist
282
+ * @param {{deferMissing?: boolean}} [options]
283
+ * @returns {symbol|undefined} `DEFERRED_MISSING` when the path is absent and the
284
+ * caller opted into deferring the missing-read verdict; otherwise nothing.
285
+ */
286
+ function validateRelativePath(path, label, cwd, mustExist, options = {}) {
287
+ if (isAbsolute(path)) throw new TypeError(`${label} must be relative to cwd`);
288
+ const absolute = resolve(cwd, path);
289
+ if (!pathInside(absolute, cwd)) throw new TypeError(`${label} escapes cwd`);
290
+
291
+ const realCwd = realpathSync(cwd);
292
+ const actual = resolveActualPath(realCwd, path);
293
+ if (!pathInside(actual, realCwd)) throw new TypeError(`${label} escapes cwd`);
294
+
295
+ const anchor = findExistingPath(absolute);
296
+ if (!anchor) throw new TypeError(`${label} escapes cwd`);
297
+ let realAnchor;
298
+ try {
299
+ realAnchor = realpathSync(anchor);
300
+ } catch (error) {
301
+ if (errorCode(error) === "ENOENT") throw new TypeError(`${label} is a broken symbolic link: ${path}`);
302
+ throw error;
303
+ }
304
+ if (!pathInside(realAnchor, realCwd)) throw new TypeError(`${label} escapes cwd`);
305
+
306
+ if (!mustExist && !existsSync(absolute)) return;
307
+ if (!existsSync(absolute)) {
308
+ if (options.deferMissing) return DEFERRED_MISSING;
309
+ throw new TypeError(`${label} does not exist: ${path}`);
310
+ }
311
+ const realAbsolute = realpathSync(absolute);
312
+ if (!pathInside(realAbsolute, realCwd)) throw new TypeError(`${label} escapes cwd`);
313
+ if (!statSync(absolute).isFile()) {
314
+ throw new TypeError(`${label} ${mustExist ? "is not" : "must name"} a file: ${path}`);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * @param {string} path
320
+ * @param {string} label
321
+ * @param {string} cwd
322
+ */
323
+ function validateDirectoryPath(path, label, cwd) {
324
+ if (isAbsolute(path)) throw new TypeError(`${label} must be relative to cwd`);
325
+ const absolute = resolve(cwd, path);
326
+ if (!pathInside(absolute, cwd)) throw new TypeError(`${label} escapes cwd`);
327
+ const realCwd = realpathSync(cwd);
328
+ const actual = resolveActualPath(realCwd, path);
329
+ if (!pathInside(actual, realCwd) || !existsSync(absolute) || !statSync(absolute).isDirectory()) {
330
+ throw new TypeError(`${label} must name a directory inside cwd`);
331
+ }
332
+ if (!pathInside(realpathSync(absolute), realCwd)) throw new TypeError(`${label} escapes cwd`);
333
+ }
334
+
335
+ /**
336
+ * @param {string} root
337
+ * @param {string} path
338
+ * @returns {string}
339
+ */
340
+ function resolveActualPath(root, path) {
341
+ const segments = path.split(/[\\/]+/u).filter((segment) => segment && segment !== ".");
342
+ let current = root;
343
+ for (const segment of segments) {
344
+ if (segment === "..") {
345
+ current = dirname(current);
346
+ continue;
347
+ }
348
+ const candidate = `${current}${sep}${segment}`;
349
+ try {
350
+ current = realpathSync(candidate);
351
+ } catch (error) {
352
+ const code = errorCode(error);
353
+ if (code === "ENOENT" || code === "ENOTDIR") current = candidate;
354
+ else throw error;
355
+ }
356
+ }
357
+ return current;
358
+ }
359
+
360
+ /**
361
+ * @param {string} path
362
+ * @returns {string|null}
363
+ */
364
+ function findExistingPath(path) {
365
+ let current = path;
366
+ for (;;) {
367
+ try {
368
+ lstatSync(current);
369
+ return current;
370
+ } catch (error) {
371
+ if (errorCode(error) !== "ENOENT") throw error;
372
+ }
373
+ const parent = dirname(current);
374
+ if (parent === current) return null;
375
+ current = parent;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * @param {string} path
381
+ * @param {string} root
382
+ * @returns {boolean}
383
+ */
384
+ function pathInside(path, root) {
385
+ const rel = relative(root, path);
386
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
387
+ }
388
+
389
+ /**
390
+ * @param {TaskPacket} packet
391
+ * @param {string} nodeId
392
+ * @returns {string}
393
+ */
394
+ function renderDiscoveryPrompt(packet, nodeId) {
395
+ const closedContext = packet.readFiles.length
396
+ ? "This discovery context is read-only and closed to the listed read files. Inspect only those files and do not perform repository-wide exploration. Return the worker-result JSON object below; put exactly one execution task packet JSON string in artifacts[0]."
397
+ : "This discovery context is read-only and is the one exception to closed inspection: no read files were pre-supplied, so you may inspect the repository read-only only as needed to produce the packet. Do not edit the repository. Return the worker-result JSON object below; put exactly one execution task packet JSON string in artifacts[0].";
398
+ const lines = [
399
+ `# Node ${nodeId} (discovery)`,
400
+ "",
401
+ "## Objective",
402
+ packet.objective,
403
+ "",
404
+ "## Closed context",
405
+ closedContext,
406
+ "",
407
+ "## Instructions",
408
+ ...numbered(packet.instructions),
409
+ "",
410
+ "## Read files",
411
+ ...bulletOrNone(packet.readFiles),
412
+ "",
413
+ "## Write files",
414
+ "- (none: discovery is read-only)",
415
+ "",
416
+ "## Symbols",
417
+ ...bulletOrNone(packet.symbols),
418
+ "",
419
+ "## Decisions already made",
420
+ ...bulletOrNone(packet.decisions),
421
+ "",
422
+ "## Non-goals",
423
+ ...bulletOrNone(packet.nonGoals),
424
+ "",
425
+ "## Required output",
426
+ 'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository.',
427
+ "",
428
+ "## Verification",
429
+ ...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
430
+ ];
431
+ const prompt = `${lines.join("\n")}\n`;
432
+ if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) throw new TypeError(`worker prompt exceeds ${PROMPT_MAX_BYTES} bytes`);
433
+ return prompt;
434
+ }
435
+
436
+ /**
437
+ * @param {TaskPacket} packet
438
+ * @param {string} nodeId
439
+ * @returns {string}
440
+ */
441
+ function renderAutonomousPrompt(packet, nodeId) {
442
+ const lines = [
443
+ `# Node ${nodeId} (autonomous)`,
444
+ "",
445
+ "## Objective",
446
+ packet.objective,
447
+ "",
448
+ "## Closed context",
449
+ "This autonomous context may inspect the repository read-only as needed. Edit only files beneath the listed write roots, and do not write outside those directory boundaries. If required context is unavailable, return the blocked_context worker-result object below.",
450
+ "",
451
+ "## Instructions",
452
+ ...numbered(packet.instructions),
453
+ "",
454
+ "## Read files",
455
+ ...bulletOrNone(packet.readFiles),
456
+ "",
457
+ "## Write roots",
458
+ ...bulletOrNone(/** @type {string[]} */ (packet.writeRoots)),
459
+ "",
460
+ "## Symbols",
461
+ ...bulletOrNone(packet.symbols),
462
+ "",
463
+ "## Decisions already made",
464
+ ...bulletOrNone(packet.decisions),
465
+ "",
466
+ "## Non-goals",
467
+ ...bulletOrNone(packet.nonGoals),
468
+ "",
469
+ "## Verification",
470
+ ...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
471
+ "",
472
+ "## Required output",
473
+ 'Return exactly one JSON object, with no markdown or prose: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.',
474
+ ];
475
+ const prompt = `${lines.join("\n")}\n`;
476
+ if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) throw new TypeError(`worker prompt exceeds ${PROMPT_MAX_BYTES} bytes`);
477
+ return prompt;
478
+ }
479
+
480
+ /**
481
+ * @param {string[]} items
482
+ * @returns {string[]}
483
+ */
484
+ function numbered(items) {
485
+ return items.map((item, index) => `${index + 1}. ${item}`);
486
+ }
487
+
488
+ /**
489
+ * @param {string[]} items
490
+ * @returns {string[]}
491
+ */
492
+ function bulletOrNone(items) {
493
+ return items.length ? items.map((item) => `- ${item}`) : ["- (none)"];
494
+ }
495
+
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Untrusted-field marking (rule 14): a field a model wrote is data, never an
3
+ * instruction. findings.json, a run-event summary and a worker summary are all
4
+ * prose a model produced; a privileged reader -- another model, the judge, a
5
+ * notification client -- that receives them unmarked treats them as the
6
+ * controller's own voice and opens an injection path from the model to the
7
+ * reader.
8
+ *
9
+ * This module never inspects, filters or rewrites the text. Sanitizing prose is
10
+ * a race that cannot be won; saying where the text came from is a true and
11
+ * cheap claim. The text arrives whole, carrying its provenance.
12
+ *
13
+ * The marking is explicit in the value, not a convention about field names: a
14
+ * marked field becomes `{untrusted: true, source, text}`, so a reader can tell
15
+ * prose from a fact without knowing `UNTRUSTED_FIELDS` -- a list that grows
16
+ * every time a new generated field lands, and that no client can be expected to
17
+ * track. Fact fields -- counters, ids, timestamps, cost, an enumerated verdict,
18
+ * a severity -- are never marked, because marking everything marks nothing and
19
+ * sends the reader back to guessing.
20
+ */
21
+
22
+ /**
23
+ * Field names whose values are generated text. This list is the writer's; a
24
+ * reader must not depend on it, which is exactly why the marked value carries
25
+ * its own provenance instead.
26
+ */
27
+ export const UNTRUSTED_FIELDS = Object.freeze([
28
+ "summary",
29
+ "description",
30
+ "evidence",
31
+ "text",
32
+ "unexpectedPaths",
33
+ ]);
34
+
35
+ /**
36
+ * Return a copy of `payload` with every generated-text field replaced by an
37
+ * explicit `{untrusted: true, source, text}` marker. Arrays and nested objects
38
+ * are walked, so a judge finding's `description` and `evidence` are marked
39
+ * where they sit; fact fields and command lists are copied through untouched.
40
+ * The input is never mutated, and an already-marked value is left alone.
41
+ *
42
+ * @param {unknown} payload
43
+ * @param {string} [source] Where the prose came from ("worker", "judge", ...).
44
+ * @returns {unknown}
45
+ */
46
+ export function markUntrusted(payload, source = "model") {
47
+ return markUntrustedValue(payload, source);
48
+ }
49
+
50
+ /**
51
+ * @param {unknown} value
52
+ * @param {string} source
53
+ * @returns {unknown}
54
+ */
55
+ function markUntrustedValue(value, source) {
56
+ if (Array.isArray(value)) return value.map((item) => markUntrustedValue(item, source));
57
+ if (value === null || typeof value !== "object") return value;
58
+ /** @type {Record<string, unknown>} */
59
+ const marked = {};
60
+ for (const [key, item] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
61
+ marked[key] = UNTRUSTED_FIELDS.includes(key) ? markUntrustedField(item, source) : markUntrustedValue(item, source);
62
+ }
63
+ return marked;
64
+ }
65
+
66
+ /**
67
+ * @param {unknown} value
68
+ * @param {string} source
69
+ * @returns {unknown}
70
+ */
71
+ function markUntrustedField(value, source) {
72
+ if (typeof value === "string") return { untrusted: true, source, text: value };
73
+ if (Array.isArray(value)) return value.map((item) => markUntrustedField(item, source));
74
+ return value;
75
+ }