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,386 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
const ENVELOPE_STATUSES = new Set(["done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled"]);
|
|
6
|
+
const LINE_KEYS = new Set(["envelope", "files", "delayMs", "exitCode", "stdoutRaw"]);
|
|
7
|
+
// exhaustedUntil, like error.resetAt below, is optional on a real provider
|
|
8
|
+
// envelope — most statuses never carry either — so it sits outside the
|
|
9
|
+
// required set even though it is a recognized field.
|
|
10
|
+
const ENVELOPE_REQUIRED_KEYS = new Set(["status", "result", "continuationId", "usage", "costUsd", "error"]);
|
|
11
|
+
const ENVELOPE_OPTIONAL_KEYS = new Set(["exhaustedUntil"]);
|
|
12
|
+
const ENVELOPE_KEYS = new Set([...ENVELOPE_REQUIRED_KEYS, ...ENVELOPE_OPTIONAL_KEYS]);
|
|
13
|
+
const USAGE_KEYS = new Set(["inputTokens", "outputTokens", "cacheReadInputTokens"]);
|
|
14
|
+
const FILE_KEYS = new Set(["path", "content"]);
|
|
15
|
+
const ERROR_KEYS = new Set(["code", "message", "resetAt"]);
|
|
16
|
+
const METADATA_ROOTS = new Set([".git", ".runs", "node_modules", ".claude", ".codex"]);
|
|
17
|
+
const PREFLIGHT_TOKEN = "FABERUN_PREFLIGHT_OK";
|
|
18
|
+
|
|
19
|
+
const PREFLIGHT_ENVELOPE = Object.freeze({
|
|
20
|
+
status: "done",
|
|
21
|
+
result: PREFLIGHT_TOKEN,
|
|
22
|
+
continuationId: null,
|
|
23
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
24
|
+
costUsd: null,
|
|
25
|
+
error: null,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** @param {string[]} args @param {string} name @returns {string|null} */
|
|
29
|
+
function argValue(args, name) {
|
|
30
|
+
const index = args.indexOf(name);
|
|
31
|
+
return index >= 0 && index + 1 < args.length ? args[index + 1] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {string} message @returns {never} */
|
|
35
|
+
function fail(message) {
|
|
36
|
+
process.stderr.write(`replay: ${message}\n`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {number} lineIndex @param {string} message @returns {never} */
|
|
41
|
+
function failSchema(lineIndex, message) {
|
|
42
|
+
fail(`recording line ${lineIndex + 1} violates schema: ${message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} code
|
|
47
|
+
* @param {string} message
|
|
48
|
+
* @returns {{status: "failed", result: null, continuationId: null, usage: {inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0}, costUsd: null, error: {code: string, message: string}}}
|
|
49
|
+
*/
|
|
50
|
+
function failedEnvelope(code, message) {
|
|
51
|
+
return {
|
|
52
|
+
status: "failed",
|
|
53
|
+
result: null,
|
|
54
|
+
continuationId: null,
|
|
55
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
56
|
+
costUsd: null,
|
|
57
|
+
error: { code, message },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {Promise<string>} */
|
|
62
|
+
function readStdin() {
|
|
63
|
+
return new Promise((settle) => {
|
|
64
|
+
let input = "";
|
|
65
|
+
process.stdin.setEncoding("utf8");
|
|
66
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
67
|
+
process.stdin.on("end", () => settle(input));
|
|
68
|
+
process.stdin.on("error", () => settle(input));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {string} recording @returns {string[]} */
|
|
73
|
+
function readRecordingLines(recording) {
|
|
74
|
+
if (!existsSync(recording)) return [];
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = readFileSync(recording, "utf8");
|
|
78
|
+
} catch (error) {
|
|
79
|
+
fail(`cannot read recording ${recording}: ${String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
return text.split(/\r?\n/u).filter((line) => line.trim().length > 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @param {string} cursorPath @returns {number} */
|
|
85
|
+
function readCursor(cursorPath) {
|
|
86
|
+
if (!existsSync(cursorPath)) return 0;
|
|
87
|
+
const raw = readFileSync(cursorPath, "utf8").trim();
|
|
88
|
+
if (!/^(?:0|[1-9]\d*)$/u.test(raw)) {
|
|
89
|
+
fail(`cursor file ${cursorPath} must contain a non-negative integer`);
|
|
90
|
+
}
|
|
91
|
+
return Number(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {string} cursorPath @param {number} value @returns {void} */
|
|
95
|
+
function writeCursor(cursorPath, value) {
|
|
96
|
+
const temporary = `${cursorPath}.${process.pid}.${Date.now()}.tmp`;
|
|
97
|
+
writeFileSync(temporary, `${value}\n`);
|
|
98
|
+
renameSync(temporary, cursorPath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} value
|
|
103
|
+
* @param {number} lineIndex
|
|
104
|
+
* @returns {{envelope: Record<string, unknown>, files: {path: string, content: string}[], delayMs: number|undefined, exitCode: number|undefined, stdoutRaw: string|undefined}}
|
|
105
|
+
*/
|
|
106
|
+
function parseRecord(value, lineIndex) {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) failSchema(lineIndex, "must be an object");
|
|
108
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
109
|
+
for (const key of Object.keys(record)) {
|
|
110
|
+
if (!LINE_KEYS.has(key)) failSchema(lineIndex, `has unknown field ${key}`);
|
|
111
|
+
}
|
|
112
|
+
if (!Object.hasOwn(record, "envelope")) failSchema(lineIndex, "is missing envelope");
|
|
113
|
+
const envelope = parseEnvelope(record.envelope, lineIndex);
|
|
114
|
+
/** @type {{path: string, content: string}[]} */
|
|
115
|
+
const files = [];
|
|
116
|
+
if (record.files !== undefined) {
|
|
117
|
+
if (!Array.isArray(record.files)) failSchema(lineIndex, "files must be an array");
|
|
118
|
+
files.push(...record.files.map((file, fileIndex) => parseFile(file, lineIndex, fileIndex)));
|
|
119
|
+
}
|
|
120
|
+
let delayMs;
|
|
121
|
+
if (record.delayMs !== undefined) {
|
|
122
|
+
if (typeof record.delayMs !== "number" || !Number.isFinite(record.delayMs) || record.delayMs < 0) {
|
|
123
|
+
failSchema(lineIndex, "delayMs must be a non-negative number");
|
|
124
|
+
}
|
|
125
|
+
delayMs = record.delayMs;
|
|
126
|
+
}
|
|
127
|
+
let exitCode;
|
|
128
|
+
if (record.exitCode !== undefined) {
|
|
129
|
+
if (typeof record.exitCode !== "number" || !Number.isInteger(record.exitCode) || record.exitCode < 0 || record.exitCode > 255) {
|
|
130
|
+
failSchema(lineIndex, "exitCode must be an integer between 0 and 255");
|
|
131
|
+
}
|
|
132
|
+
exitCode = record.exitCode;
|
|
133
|
+
}
|
|
134
|
+
let stdoutRaw;
|
|
135
|
+
if (record.stdoutRaw !== undefined) {
|
|
136
|
+
if (typeof record.stdoutRaw !== "string") failSchema(lineIndex, "stdoutRaw must be a string");
|
|
137
|
+
stdoutRaw = record.stdoutRaw;
|
|
138
|
+
}
|
|
139
|
+
return { envelope, files, delayMs, exitCode, stdoutRaw };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {unknown} value
|
|
144
|
+
* @param {number} lineIndex
|
|
145
|
+
* @returns {Record<string, unknown>}
|
|
146
|
+
*/
|
|
147
|
+
function parseEnvelope(value, lineIndex) {
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) failSchema(lineIndex, "envelope must be an object");
|
|
149
|
+
const envelope = /** @type {Record<string, unknown>} */ (value);
|
|
150
|
+
for (const key of Object.keys(envelope)) {
|
|
151
|
+
if (!ENVELOPE_KEYS.has(key)) failSchema(lineIndex, `envelope has unknown field ${key}`);
|
|
152
|
+
}
|
|
153
|
+
for (const key of ENVELOPE_REQUIRED_KEYS) {
|
|
154
|
+
if (!Object.hasOwn(envelope, key)) failSchema(lineIndex, `envelope is missing ${key}`);
|
|
155
|
+
}
|
|
156
|
+
if (Object.hasOwn(envelope, "exhaustedUntil")) {
|
|
157
|
+
const exhaustedUntil = envelope.exhaustedUntil;
|
|
158
|
+
if (exhaustedUntil !== null && typeof exhaustedUntil !== "string") {
|
|
159
|
+
failSchema(lineIndex, "envelope.exhaustedUntil must be a string or null");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const status = envelope.status;
|
|
163
|
+
if (typeof status !== "string" || !ENVELOPE_STATUSES.has(status)) {
|
|
164
|
+
failSchema(lineIndex, `envelope.status must be one of ${[...ENVELOPE_STATUSES].join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
const result = envelope.result;
|
|
167
|
+
if (result !== null && typeof result !== "string") failSchema(lineIndex, "envelope.result must be a string or null");
|
|
168
|
+
const continuationId = envelope.continuationId;
|
|
169
|
+
if (continuationId !== null && typeof continuationId !== "string") {
|
|
170
|
+
failSchema(lineIndex, "envelope.continuationId must be a string or null");
|
|
171
|
+
}
|
|
172
|
+
const usage = envelope.usage;
|
|
173
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage)) failSchema(lineIndex, "envelope.usage must be an object");
|
|
174
|
+
for (const key of Object.keys(usage)) {
|
|
175
|
+
if (!USAGE_KEYS.has(key)) failSchema(lineIndex, `envelope.usage has unknown field ${key}`);
|
|
176
|
+
}
|
|
177
|
+
for (const key of USAGE_KEYS) {
|
|
178
|
+
if (!Object.hasOwn(usage, key)) failSchema(lineIndex, `envelope.usage is missing ${key}`);
|
|
179
|
+
const raw = /** @type {Record<string, unknown>} */ (usage)[key];
|
|
180
|
+
if (raw !== null && (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0)) {
|
|
181
|
+
failSchema(lineIndex, `envelope.usage.${key} must be a non-negative integer or null`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const costUsd = envelope.costUsd;
|
|
185
|
+
if (costUsd !== null && (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0)) {
|
|
186
|
+
failSchema(lineIndex, "envelope.costUsd must be a non-negative number or null");
|
|
187
|
+
}
|
|
188
|
+
parseError(envelope.error, lineIndex);
|
|
189
|
+
return envelope;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {unknown} value
|
|
194
|
+
* @param {number} lineIndex
|
|
195
|
+
* @param {number} fileIndex
|
|
196
|
+
* @returns {{path: string, content: string}}
|
|
197
|
+
*/
|
|
198
|
+
function parseFile(value, lineIndex, fileIndex) {
|
|
199
|
+
const label = `files[${fileIndex}]`;
|
|
200
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) failSchema(lineIndex, `${label} must be an object`);
|
|
201
|
+
const file = /** @type {Record<string, unknown>} */ (value);
|
|
202
|
+
for (const key of Object.keys(file)) {
|
|
203
|
+
if (!FILE_KEYS.has(key)) failSchema(lineIndex, `${label} has unknown field ${key}`);
|
|
204
|
+
}
|
|
205
|
+
const path = file.path;
|
|
206
|
+
if (typeof path !== "string" || path.length === 0) failSchema(lineIndex, `${label}.path must be a non-empty string`);
|
|
207
|
+
const content = file.content;
|
|
208
|
+
if (typeof content !== "string") failSchema(lineIndex, `${label}.content must be a string`);
|
|
209
|
+
return { path, content };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {unknown} value
|
|
214
|
+
* @param {number} lineIndex
|
|
215
|
+
* @returns {void}
|
|
216
|
+
*/
|
|
217
|
+
function parseError(value, lineIndex) {
|
|
218
|
+
if (value === null) return;
|
|
219
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) failSchema(lineIndex, "envelope.error must be an object or null");
|
|
220
|
+
const error = /** @type {Record<string, unknown>} */ (value);
|
|
221
|
+
for (const key of Object.keys(error)) {
|
|
222
|
+
if (!ERROR_KEYS.has(key)) failSchema(lineIndex, `envelope.error has unknown field ${key}`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof error.code !== "string" || error.code.length === 0 || typeof error.message !== "string") {
|
|
225
|
+
failSchema(lineIndex, "envelope.error must carry a non-empty code and a string message");
|
|
226
|
+
}
|
|
227
|
+
if (Object.hasOwn(error, "resetAt")) {
|
|
228
|
+
const resetAt = error.resetAt;
|
|
229
|
+
if (resetAt !== null && typeof resetAt !== "string") {
|
|
230
|
+
failSchema(lineIndex, "envelope.error.resetAt must be a string or null");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fail closed when a recorded file target could escape the real workspace:
|
|
237
|
+
* relative paths only, no `..` segments, no metadata roots, and no symlinked
|
|
238
|
+
* ancestor whose realpath leaves the workspace.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} path
|
|
241
|
+
* @param {string} workspace
|
|
242
|
+
* @returns {string|null}
|
|
243
|
+
*/
|
|
244
|
+
function containmentProblem(path, workspace) {
|
|
245
|
+
if (isAbsolute(path)) return `replay file path must be relative: ${path}`;
|
|
246
|
+
const segments = path.split(/[\\/]+/u).filter((segment) => segment.length > 0 && segment !== ".");
|
|
247
|
+
if (segments.some((segment) => segment === "..")) {
|
|
248
|
+
return `replay file path must not traverse outside the workspace: ${path}`;
|
|
249
|
+
}
|
|
250
|
+
if (segments.length === 0 || METADATA_ROOTS.has(segments[0])) {
|
|
251
|
+
return `replay file path must not target a metadata root: ${path}`;
|
|
252
|
+
}
|
|
253
|
+
const target = join(process.cwd(), path);
|
|
254
|
+
let ancestor = target;
|
|
255
|
+
while (!existsSync(ancestor)) {
|
|
256
|
+
const parent = dirname(ancestor);
|
|
257
|
+
if (parent === ancestor) break;
|
|
258
|
+
ancestor = parent;
|
|
259
|
+
}
|
|
260
|
+
let ancestorReal = null;
|
|
261
|
+
try {
|
|
262
|
+
ancestorReal = realpathSync(ancestor);
|
|
263
|
+
} catch {
|
|
264
|
+
ancestorReal = null;
|
|
265
|
+
}
|
|
266
|
+
if (ancestorReal === null || !(ancestorReal === workspace || ancestorReal.startsWith(`${workspace}${sep}`))) {
|
|
267
|
+
return `replay file path escapes the workspace through a symlink: ${path}`;
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const args = process.argv.slice(2);
|
|
273
|
+
if (args.includes("--version")) {
|
|
274
|
+
const probeArg = argValue(args, "--replay-probe");
|
|
275
|
+
if (probeArg !== null) {
|
|
276
|
+
/** @type {unknown} */
|
|
277
|
+
let probe;
|
|
278
|
+
try {
|
|
279
|
+
probe = JSON.parse(probeArg);
|
|
280
|
+
} catch {
|
|
281
|
+
fail("--replay-probe must be valid JSON");
|
|
282
|
+
}
|
|
283
|
+
if (!probe || typeof probe !== "object" || Array.isArray(probe)) {
|
|
284
|
+
fail("--replay-probe must be a JSON object");
|
|
285
|
+
}
|
|
286
|
+
const record = /** @type {Record<string, unknown>} */ (probe);
|
|
287
|
+
if (typeof record.stderr === "string" && record.stderr) process.stderr.write(`${record.stderr}\n`);
|
|
288
|
+
const exitCode = typeof record.exitCode === "number" && Number.isInteger(record.exitCode) && record.exitCode >= 0 && record.exitCode <= 255
|
|
289
|
+
? record.exitCode
|
|
290
|
+
: 1;
|
|
291
|
+
process.exit(exitCode);
|
|
292
|
+
}
|
|
293
|
+
process.stdout.write("replay 1.0.0\n");
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const recordingArg = argValue(args, "--recording");
|
|
298
|
+
if (recordingArg === null) {
|
|
299
|
+
fail("--recording <recording.jsonl> is required");
|
|
300
|
+
}
|
|
301
|
+
const recording = resolve(recordingArg);
|
|
302
|
+
const prompt = await readStdin();
|
|
303
|
+
|
|
304
|
+
if (prompt.includes(PREFLIGHT_TOKEN)) {
|
|
305
|
+
process.stdout.write(`${JSON.stringify(PREFLIGHT_ENVELOPE)}\n`);
|
|
306
|
+
process.exit(0);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const lines = readRecordingLines(recording);
|
|
310
|
+
const cursorPath = `${recording}.cursor`;
|
|
311
|
+
const cursor = readCursor(cursorPath);
|
|
312
|
+
if (cursor >= lines.length) {
|
|
313
|
+
const suffix = lines.length === 1 ? "" : "s";
|
|
314
|
+
process.stdout.write(`${JSON.stringify(failedEnvelope("replay_exhausted", `replay recording exhausted at cursor ${cursor} (${lines.length} line${suffix})`))}\n`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rawLine = lines[cursor];
|
|
319
|
+
/** @type {unknown} */
|
|
320
|
+
let parsed;
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(rawLine);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
fail(`recording line ${cursor + 1} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
325
|
+
}
|
|
326
|
+
const record = parseRecord(parsed, cursor);
|
|
327
|
+
const workspace = realpathSync(process.cwd());
|
|
328
|
+
|
|
329
|
+
for (const file of record.files) {
|
|
330
|
+
const problem = containmentProblem(file.path, workspace);
|
|
331
|
+
if (problem !== null) {
|
|
332
|
+
process.stdout.write(`${JSON.stringify(failedEnvelope("replay_path_escape", problem))}\n`);
|
|
333
|
+
process.exit(2);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const file of record.files) {
|
|
337
|
+
const target = join(process.cwd(), file.path);
|
|
338
|
+
try {
|
|
339
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
340
|
+
writeFileSync(target, file.content);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
fail(`cannot write ${file.path}: ${String(error)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
writeCursor(cursorPath, cursor + 1);
|
|
347
|
+
appendFileSync(
|
|
348
|
+
`${recording}.invocations.jsonl`,
|
|
349
|
+
`${JSON.stringify({ at: new Date().toISOString(), index: cursor, promptBytes: Buffer.byteLength(prompt, "utf8"), args })}\n`,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve an `error.resetAt` written as `+<ms>` into an absolute instant, at
|
|
354
|
+
* the moment the envelope is emitted rather than when the recording was
|
|
355
|
+
* written.
|
|
356
|
+
*
|
|
357
|
+
* A recording that hardcodes `Date.now() + 3000` is racing the spawn: the
|
|
358
|
+
* controller only reads `resetAt` when this envelope comes back, and under a
|
|
359
|
+
* loaded suite the spawn can outlast the window, which flips
|
|
360
|
+
* `classifyTransition` from the reset branch to failover. That is how
|
|
361
|
+
* "a quota exhaustion carrying a scheduled reset..." passed alone and failed
|
|
362
|
+
* in the full run. Emitting the instant here makes the window start where the
|
|
363
|
+
* test means it to.
|
|
364
|
+
*
|
|
365
|
+
* @param {Record<string, unknown>} envelope
|
|
366
|
+
* @returns {Record<string, unknown>}
|
|
367
|
+
*/
|
|
368
|
+
function resolveRelativeReset(envelope) {
|
|
369
|
+
const error = envelope.error;
|
|
370
|
+
if (!error || typeof error !== "object") return envelope;
|
|
371
|
+
const record = /** @type {Record<string, unknown>} */ (error);
|
|
372
|
+
const resetAt = record.resetAt;
|
|
373
|
+
if (typeof resetAt !== "string" || !/^\+\d+$/u.test(resetAt)) return envelope;
|
|
374
|
+
const at = new Date(Date.now() + Number(resetAt.slice(1))).toISOString();
|
|
375
|
+
return { ...envelope, error: { ...record, resetAt: at } };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (record.delayMs !== undefined && record.delayMs > 0) {
|
|
379
|
+
await new Promise((settle) => setTimeout(settle, record.delayMs));
|
|
380
|
+
}
|
|
381
|
+
if (record.stdoutRaw !== undefined) {
|
|
382
|
+
process.stdout.write(record.stdoutRaw);
|
|
383
|
+
} else {
|
|
384
|
+
process.stdout.write(`${JSON.stringify(resolveRelativeReset(record.envelope))}\n`);
|
|
385
|
+
}
|
|
386
|
+
process.exitCode = record.exitCode ?? 0;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { parseVersion } from "../protocol.mjs";
|
|
4
|
+
|
|
5
|
+
/** Envelope statuses a recording may carry. */
|
|
6
|
+
const REPLAY_STATUSES = Object.freeze(new Set([
|
|
7
|
+
"done",
|
|
8
|
+
"no-op",
|
|
9
|
+
"blocked",
|
|
10
|
+
"failed",
|
|
11
|
+
"exhausted",
|
|
12
|
+
"stalled",
|
|
13
|
+
"canceled",
|
|
14
|
+
]));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deterministic provider stand-in. The recording holds already-normalized
|
|
18
|
+
* envelopes consumed strictly in order through a `<recording>.cursor` sidecar,
|
|
19
|
+
* so the controller exercises everything after provider normalization with
|
|
20
|
+
* zero model invocations.
|
|
21
|
+
*
|
|
22
|
+
* @type {import("../index.mjs").HarnessAdapter}
|
|
23
|
+
*/
|
|
24
|
+
export const replayHarness = {
|
|
25
|
+
capabilities: {
|
|
26
|
+
structuredOutput: true,
|
|
27
|
+
promptTransport: "stdin",
|
|
28
|
+
sandbox: false,
|
|
29
|
+
permissions: false,
|
|
30
|
+
continuation: true,
|
|
31
|
+
tokenBudget: true,
|
|
32
|
+
costBudget: true,
|
|
33
|
+
usage: true,
|
|
34
|
+
cost: true,
|
|
35
|
+
// A recording cannot prove mechanical tool-policy enforcement.
|
|
36
|
+
toolPolicy: false,
|
|
37
|
+
// replay/bin.mjs writes its one envelope line after the recorded delay,
|
|
38
|
+
// never incrementally.
|
|
39
|
+
streamsOutput: false,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// A recording exposes no permission mode.
|
|
43
|
+
permissionExecution: null,
|
|
44
|
+
|
|
45
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
|
|
46
|
+
executable(runtime) {
|
|
47
|
+
return process.env.FABERUN_REPLAY_BIN
|
|
48
|
+
?? runtime.executable
|
|
49
|
+
?? fileURLToPath(new URL("./bin.mjs", import.meta.url));
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A recording only stands in for a prompt invocation; the live version
|
|
54
|
+
* probe (`probeRuntime`) never touches it, so a deterministic case that
|
|
55
|
+
* needs to prove a probe's own balance/quota/missing-CLI classification
|
|
56
|
+
* declares `config["replay.probe"]` instead — carried to replay/bin.mjs as
|
|
57
|
+
* a `--replay-probe` argument, the same way a recorded envelope carries
|
|
58
|
+
* `error.resetAt` to prove a reset instant.
|
|
59
|
+
*
|
|
60
|
+
* @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
versionArgs(runtime) {
|
|
63
|
+
const probe = runtime.config?.["replay.probe"];
|
|
64
|
+
if (probe === undefined) return runtime.versionArgs ?? ["--version"];
|
|
65
|
+
if (!probe || typeof probe !== "object" || Array.isArray(probe)) {
|
|
66
|
+
throw new TypeError('replay runtime config["replay.probe"] must be an object');
|
|
67
|
+
}
|
|
68
|
+
return ["--version", "--replay-probe", JSON.stringify(probe)];
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
parseVersion,
|
|
72
|
+
|
|
73
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @param {string} prompt @param {import("../index.mjs").CommandOptions} options @returns {import("../index.mjs").HarnessCommand} */
|
|
74
|
+
command(runtime, prompt, options) {
|
|
75
|
+
const recording = runtime.config?.["replay.recording"];
|
|
76
|
+
if (typeof recording !== "string" || recording.length === 0) {
|
|
77
|
+
throw new TypeError('replay runtime requires config["replay.recording"]');
|
|
78
|
+
}
|
|
79
|
+
const args = ["--recording", resolve(recording)];
|
|
80
|
+
if (options.continuationId) args.push("--continuation", options.continuationId);
|
|
81
|
+
if (options.schema || options.schemaPath) args.push("--schema");
|
|
82
|
+
return {
|
|
83
|
+
executable: this.executable(runtime),
|
|
84
|
+
args,
|
|
85
|
+
promptTransport: "stdin",
|
|
86
|
+
input: prompt,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
normalize: normalizeReplayResult,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const harness = replayHarness;
|
|
94
|
+
export default replayHarness;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse the last non-empty stdout line as an already-normalized provider
|
|
98
|
+
* envelope. Canonical fields are kept — including the optional
|
|
99
|
+
* `error.resetAt` and `exhaustedUntil`, in exactly the shape
|
|
100
|
+
* `ProviderEnvelope` declares for a real harness — and unknown fields are
|
|
101
|
+
* dropped; anything else (prose, an empty stream, a non-zero exit with no
|
|
102
|
+
* envelope) normalizes to a `failed` envelope with error code
|
|
103
|
+
* `invalid_output`. Never throws.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} stdout
|
|
106
|
+
* @param {number|null} exitCode
|
|
107
|
+
* @param {string|null} signal
|
|
108
|
+
* @returns {import("../index.mjs").ProviderEnvelope}
|
|
109
|
+
*/
|
|
110
|
+
function normalizeReplayResult(stdout, exitCode, signal) {
|
|
111
|
+
const envelope = parseLastEnvelope(stdout);
|
|
112
|
+
if (envelope) return envelope;
|
|
113
|
+
const reason = signal
|
|
114
|
+
? `provider ended after ${signal}`
|
|
115
|
+
: exitCode
|
|
116
|
+
? `replay exited with code ${exitCode}`
|
|
117
|
+
: lastNonEmpty(stdout) === null
|
|
118
|
+
? "replay emitted no stdout"
|
|
119
|
+
: "replay stdout is not a valid envelope";
|
|
120
|
+
return {
|
|
121
|
+
status: "failed",
|
|
122
|
+
result: null,
|
|
123
|
+
continuationId: null,
|
|
124
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
125
|
+
costUsd: null,
|
|
126
|
+
error: { code: "invalid_output", message: boundedText(reason, 512) },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} stdout
|
|
132
|
+
* @returns {import("../index.mjs").ProviderEnvelope|null}
|
|
133
|
+
*/
|
|
134
|
+
function parseLastEnvelope(stdout) {
|
|
135
|
+
const line = lastNonEmpty(stdout);
|
|
136
|
+
if (line === null) return null;
|
|
137
|
+
/** @type {unknown} */
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(line);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return canonicalEnvelope(parsed);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} stdout
|
|
149
|
+
* @returns {string|null}
|
|
150
|
+
*/
|
|
151
|
+
function lastNonEmpty(stdout) {
|
|
152
|
+
return String(stdout ?? "")
|
|
153
|
+
.split(/\r?\n/u)
|
|
154
|
+
.map((line) => line.trim())
|
|
155
|
+
.filter((line) => line.length > 0)
|
|
156
|
+
.at(-1) ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate one parsed stdout value as a canonical envelope and return it with
|
|
161
|
+
* exactly the canonical fields. Returns null (never throws) when the value is
|
|
162
|
+
* not a well-formed envelope.
|
|
163
|
+
*
|
|
164
|
+
* @param {unknown} value
|
|
165
|
+
* @returns {import("../index.mjs").ProviderEnvelope|null}
|
|
166
|
+
*/
|
|
167
|
+
function canonicalEnvelope(value) {
|
|
168
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
169
|
+
const envelope = /** @type {Record<string, unknown>} */ (value);
|
|
170
|
+
const status = envelope.status;
|
|
171
|
+
if (typeof status !== "string" || !REPLAY_STATUSES.has(status)) return null;
|
|
172
|
+
const result = envelope.result;
|
|
173
|
+
if (result !== null && typeof result !== "string") return null;
|
|
174
|
+
const continuationId = envelope.continuationId;
|
|
175
|
+
if (continuationId !== null && typeof continuationId !== "string") return null;
|
|
176
|
+
const usage = canonicalUsage(envelope.usage);
|
|
177
|
+
if (!usage) return null;
|
|
178
|
+
const costUsd = envelope.costUsd;
|
|
179
|
+
if (costUsd !== null && (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0)) return null;
|
|
180
|
+
const error = canonicalError(envelope.error);
|
|
181
|
+
if (error === undefined) return null;
|
|
182
|
+
const exhaustedUntil = envelope.exhaustedUntil;
|
|
183
|
+
if (exhaustedUntil !== undefined && exhaustedUntil !== null && typeof exhaustedUntil !== "string") return null;
|
|
184
|
+
return {
|
|
185
|
+
status: /** @type {"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} */ (status),
|
|
186
|
+
result,
|
|
187
|
+
continuationId,
|
|
188
|
+
usage,
|
|
189
|
+
costUsd,
|
|
190
|
+
error,
|
|
191
|
+
...(exhaustedUntil !== undefined ? { exhaustedUntil } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {unknown} value
|
|
197
|
+
* @returns {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|null}
|
|
198
|
+
*/
|
|
199
|
+
function canonicalUsage(value) {
|
|
200
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
201
|
+
const usage = /** @type {Record<string, unknown>} */ (value);
|
|
202
|
+
/** @type {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} */
|
|
203
|
+
const record = { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
|
|
204
|
+
for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(record))) {
|
|
205
|
+
const raw = usage[key];
|
|
206
|
+
if (raw === undefined) continue;
|
|
207
|
+
if (raw !== null && (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0)) return null;
|
|
208
|
+
record[key] = raw;
|
|
209
|
+
}
|
|
210
|
+
return record;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {unknown} value
|
|
215
|
+
* @returns {{code: string, message: string, resetAt?: string|null}|null|undefined}
|
|
216
|
+
*/
|
|
217
|
+
function canonicalError(value) {
|
|
218
|
+
if (value === null) return null;
|
|
219
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
220
|
+
const error = /** @type {Record<string, unknown>} */ (value);
|
|
221
|
+
if (typeof error.code !== "string" || error.code.length === 0 || typeof error.message !== "string") return undefined;
|
|
222
|
+
const resetAt = error.resetAt;
|
|
223
|
+
if (resetAt !== undefined && resetAt !== null && typeof resetAt !== "string") return undefined;
|
|
224
|
+
return { code: error.code, message: error.message, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {string} value
|
|
229
|
+
* @param {number} maxBytes
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
function boundedText(value, maxBytes) {
|
|
233
|
+
const bytes = Buffer.from(value, "utf8");
|
|
234
|
+
if (bytes.length <= maxBytes) return bytes.toString("utf8");
|
|
235
|
+
let end = maxBytes;
|
|
236
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
237
|
+
return `${bytes.subarray(0, end).toString("utf8")}…`;
|
|
238
|
+
}
|