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