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,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
|
+
}
|