faberun 0.26.3 → 0.28.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 (88) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +6 -1
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +68 -8
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +6 -2
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. package/src/util.mjs +18 -2
@@ -17,7 +17,7 @@ import { Buffer } from "node:buffer";
17
17
  import { gitArguments } from "../host/platform.mjs";
18
18
  import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
19
19
  import { basename, isAbsolute, relative, resolve } from "node:path";
20
- import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
20
+ import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
21
21
  import { createHash } from "node:crypto";
22
22
  import { errorCode } from "../util.mjs";
23
23
  import { execFileSync } from "node:child_process";
@@ -28,10 +28,14 @@ import { RUNS_DIR_NAME } from "../run/paths.mjs";
28
28
  /** A `node_modules` directory at any depth, matched as a whole path segment. */
29
29
  const NODE_MODULES_SEGMENT = /(?:^|\/)node_modules(?:\/|$)/u;
30
30
 
31
+ /** Ignore sources at most this large are kept verbatim so base rules can be replayed. */
32
+ const IGNORE_SOURCE_CONTENT_LIMIT = 64 * 1024;
33
+
31
34
  /**
32
- * One entry of a workspace snapshot.
35
+ * One entry of a workspace snapshot. `content` is present on text ignore
36
+ * sources small enough to replay the base ignore rules for a declared edit.
33
37
  *
34
- * @typedef {{path: string, kind: "file"|"symlink"|"missing", digest?: string, size?: number}} SnapshotEntry
38
+ * @typedef {{path: string, kind: "file"|"symlink"|"missing", digest?: string, size?: number, content?: string}} SnapshotEntry
35
39
  */
36
40
  /** @typedef {{entries: SnapshotEntry[], ignoreSources: SnapshotEntry[], truncated: boolean}} WorkspaceSnapshot */
37
41
  /** @typedef {{literal: string, paths: string[]}} WorkspaceScopeOrigin */
@@ -55,15 +59,29 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
55
59
  if (expectedIgnoreSources !== undefined && !sameSnapshotEntries(expectedIgnoreSources, ignoreSources)) {
56
60
  throw ignoreSourcesChanged(expectedIgnoreSources, ignoreSources);
57
61
  }
62
+ return { entries: captureWorkspaceEntries(root, relevantWorkspacePaths(root)), ignoreSources, truncated: false };
63
+ }
64
+ /**
65
+ * Hash the observable workspace paths into snapshot entries. This is separate
66
+ * from `captureWorkspaceSnapshot` so a declared ignore-source edit can be
67
+ * enumerated with the base ignore rules and still hash the current file
68
+ * content, which is what makes the edit count as a declared write.
69
+ *
70
+ * @param {string} root
71
+ * @param {string[]} paths
72
+ * @returns {SnapshotEntry[]}
73
+ */
74
+ function captureWorkspaceEntries(root, paths) {
58
75
  /** @type {SnapshotEntry[]} */
59
76
  const entries = [];
60
- const add = (/** @type {SnapshotEntry} */ entry) => {
77
+ /** @param {SnapshotEntry} entry */
78
+ const add = (entry) => {
61
79
  if (entries.length >= VERIFICATION_LIMITS.snapshotEntries) {
62
80
  throw fail("snapshot_too_large", `workspace snapshot exceeds ${VERIFICATION_LIMITS.snapshotEntries} entries`);
63
81
  }
64
82
  entries.push(entry);
65
83
  };
66
- for (const rel of relevantWorkspacePaths(root)) {
84
+ for (const rel of paths) {
67
85
  if (Buffer.byteLength(rel, "utf8") > VERIFICATION_LIMITS.snapshotPathBytes) {
68
86
  throw fail("snapshot_path_too_long", `workspace path exceeds ${VERIFICATION_LIMITS.snapshotPathBytes} bytes: ${rel.slice(0, 128)}`);
69
87
  }
@@ -99,7 +117,7 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
99
117
  throw fail("snapshot_unsupported_entry", `unsupported workspace entry: ${rel}`);
100
118
  }
101
119
  }
102
- return { entries, ignoreSources, truncated: false };
120
+ return entries;
103
121
  }
104
122
  /**
105
123
  * @param {WorkspaceSnapshot|undefined} before
@@ -121,10 +139,22 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
121
139
  files: expandScopePaths(root, normalizeScopePaths(scope.files ?? [], "files")),
122
140
  roots: expandScopePaths(root, normalizeScopePaths(scope.roots ?? [], "roots")),
123
141
  });
124
- const after = captureWorkspaceSnapshot(cwd, before.ignoreSources);
125
- if (!sameSnapshotEntries(before.ignoreSources, after.ignoreSources)) {
126
- throw ignoreSourcesChanged(before.ignoreSources, after.ignoreSources);
142
+ const currentIgnoreSources = captureIgnoreSources(root);
143
+ const changedIgnoreSources = changedSnapshotPaths(before.ignoreSources, currentIgnoreSources);
144
+ // A changed ignore source inside the declared scope is the node's own work:
145
+ // it is judged under the base rules instead of failing. Anything outside the
146
+ // scope still fails closed before scope matching.
147
+ if (changedIgnoreSources.some((path) => !isDeclaredScopePath(allowed, path))) {
148
+ throw ignoreSourcesChanged(before.ignoreSources, currentIgnoreSources);
127
149
  }
150
+ /** @type {WorkspaceSnapshot} */
151
+ const after = {
152
+ entries: changedIgnoreSources.length
153
+ ? captureWorkspaceEntriesUnderBaseRules(root, before.ignoreSources, changedIgnoreSources)
154
+ : captureWorkspaceEntries(root, relevantWorkspacePaths(root)),
155
+ ignoreSources: currentIgnoreSources,
156
+ truncated: false,
157
+ };
128
158
  const prior = new Map(before.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
129
159
  const current = new Map(after.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
130
160
  const changed = new Set();
@@ -142,6 +172,143 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
142
172
  !directoryRoots.some((scopeRoot) => path === scopeRoot || path.startsWith(`${scopeRoot}/`)));
143
173
  return { after, changedPaths, unexpectedPaths };
144
174
  }
175
+ /**
176
+ * Whether a workspace-relative path falls inside the captured scope boundary,
177
+ * using the same file-root versus directory-root rule as the entry comparison.
178
+ *
179
+ * @param {WorkspaceScopeBoundary} allowed
180
+ * @param {string} path
181
+ * @returns {boolean}
182
+ */
183
+ function isDeclaredScopePath(allowed, path) {
184
+ const fileRoots = new Set(allowed.fileRoots ?? []);
185
+ if (allowed.files.includes(path) || fileRoots.has(path)) return true;
186
+ return allowed.roots.some((scopeRoot) => !fileRoots.has(scopeRoot) && (path === scopeRoot || path.startsWith(`${scopeRoot}/`)));
187
+ }
188
+ /**
189
+ * The ignore sources whose fingerprint moved between two snapshots.
190
+ *
191
+ * @param {SnapshotEntry[]} before
192
+ * @param {SnapshotEntry[]} after
193
+ * @returns {string[]}
194
+ */
195
+ function changedSnapshotPaths(before, after) {
196
+ const prior = new Map(before.map((entry) => [entry.path, snapshotEntryKey(entry)]));
197
+ const current = new Map(after.map((entry) => [entry.path, snapshotEntryKey(entry)]));
198
+ return [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
199
+ }
200
+ /**
201
+ * The identity of a snapshot entry for comparison. `content` is deliberately
202
+ * excluded: it is only carried to replay base ignore rules, so a persisted
203
+ * snapshot captured before that field existed still compares equal.
204
+ *
205
+ * @param {SnapshotEntry} entry
206
+ * @returns {string}
207
+ */
208
+ function snapshotEntryKey(entry) {
209
+ return JSON.stringify({ path: entry.path, kind: entry.kind, digest: entry.digest, size: entry.size });
210
+ }
211
+ /**
212
+ * The after entries for a declared ignore-source edit. Paths are enumerated
213
+ * with the ignore rules captured before the attempt, so a worker cannot edit a
214
+ * declared ignore file to hide a change the base rules would have shown. The
215
+ * changed sources are put back afterwards and hashed from their current
216
+ * content, so the edit itself still counts as a declared write.
217
+ *
218
+ * @param {string} root
219
+ * @param {SnapshotEntry[]} beforeIgnoreSources
220
+ * @param {string[]} changedIgnoreSources
221
+ * @returns {SnapshotEntry[]}
222
+ */
223
+ function captureWorkspaceEntriesUnderBaseRules(root, beforeIgnoreSources, changedIgnoreSources) {
224
+ const beforeByPath = new Map(beforeIgnoreSources.map((entry) => [entry.path, entry]));
225
+ /** @type {{path: string, saved: ReplayableFileState}[]} */
226
+ const restorations = [];
227
+ /** @type {string[]} */
228
+ let paths = [];
229
+ try {
230
+ for (const path of changedIgnoreSources) {
231
+ const beforeEntry = beforeByPath.get(path);
232
+ if (beforeEntry !== undefined && typeof beforeEntry.content !== "string") continue;
233
+ const child = ignoreSourceAbsolutePath(root, path);
234
+ const saved = readReplayableFileState(child);
235
+ if (saved === undefined) continue;
236
+ restorations.push({ path: child, saved });
237
+ if (beforeEntry === undefined) removeReplayableFile(child);
238
+ else if (typeof beforeEntry.content === "string") writeReplayableFile(child, beforeEntry.content);
239
+ }
240
+ paths = relevantWorkspacePaths(root);
241
+ } finally {
242
+ for (const { path, saved } of restorations.reverse()) restoreReplayableFileState(path, saved);
243
+ }
244
+ const observable = new Set(paths);
245
+ // A source the worker created is new under the base rules, so the walk above
246
+ // cannot list it; the declared edit still has to appear as a write.
247
+ for (const path of changedIgnoreSources) {
248
+ if (!path.startsWith(".git/")) observable.add(path);
249
+ }
250
+ return captureWorkspaceEntries(root, [...observable].sort());
251
+ }
252
+ /** @typedef {{missing: true}|{missing: false, content: string, mode: number}} ReplayableFileState */
253
+ /**
254
+ * @param {string} path
255
+ * @returns {ReplayableFileState|undefined} undefined when replay would be unsafe
256
+ */
257
+ function readReplayableFileState(path) {
258
+ let metadata;
259
+ try {
260
+ metadata = lstatSync(path);
261
+ } catch (error) {
262
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return { missing: true };
263
+ return undefined;
264
+ }
265
+ if (!metadata.isFile()) return undefined;
266
+ try {
267
+ return { missing: false, content: readFileSync(path, "utf8"), mode: metadata.mode };
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ /** @param {string} path @param {string} content @returns {void} */
273
+ function writeReplayableFile(path, content) {
274
+ writeFileSync(path, content);
275
+ }
276
+ /** @param {string} path @returns {void} */
277
+ function removeReplayableFile(path) {
278
+ rmSync(path, { force: true });
279
+ }
280
+ /** @param {string} path @param {ReplayableFileState} saved @returns {void} */
281
+ function restoreReplayableFileState(path, saved) {
282
+ if (saved.missing) {
283
+ rmSync(path, { force: true });
284
+ return;
285
+ }
286
+ writeFileSync(path, saved.content, { mode: saved.mode });
287
+ }
288
+ /**
289
+ * The on-disk file behind an ignore source. `.git/config` and
290
+ * `.git/info/exclude` may live outside the workspace in a linked worktree, so
291
+ * ask Git for their real path.
292
+ *
293
+ * @param {string} root
294
+ * @param {string} path
295
+ * @returns {string}
296
+ */
297
+ function ignoreSourceAbsolutePath(root, path) {
298
+ if (path === ".git/config" || path === ".git/info/exclude") {
299
+ const name = path === ".git/config" ? "config" : "info/exclude";
300
+ try {
301
+ const value = execFileSync("git", gitArguments(["-C", root, "rev-parse", "--git-path", name]), {
302
+ encoding: "utf8",
303
+ stdio: ["ignore", "pipe", "ignore"],
304
+ }).trim();
305
+ if (value) return isAbsolute(value) ? value : resolve(root, value);
306
+ } catch {
307
+ // Fall through to the literal workspace path.
308
+ }
309
+ }
310
+ return resolve(root, path);
311
+ }
145
312
  /**
146
313
  * Resolve a declared worker scope before an untrusted worker starts. The
147
314
  * returned paths are the literal declarations plus the contained target paths
@@ -295,9 +462,7 @@ export function isIgnoreSource(path) {
295
462
  * @returns {Error}
296
463
  */
297
464
  function ignoreSourcesChanged(before, after) {
298
- const prior = new Map(before.map((entry) => [entry.path, JSON.stringify(entry)]));
299
- const current = new Map(after.map((entry) => [entry.path, JSON.stringify(entry)]));
300
- const changed = [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
465
+ const changed = changedSnapshotPaths(before, after);
301
466
  return fail("snapshot_ignore_changed", `workspace ignore sources changed during worker execution: ${changed.join(", ")}`);
302
467
  }
303
468
 
@@ -437,14 +602,24 @@ function captureIgnoreSources(root) {
437
602
  const bytes = Buffer.byteLength(relevant, "utf8");
438
603
  return [{ path, kind: "file", digest: `config:${createHash("sha256").update(relevant).digest("hex")}:${bytes}`, size: bytes }];
439
604
  }
440
- return [{ path, kind: "file", digest: fileIdentity(child, metadata).digest, size: metadata.size }];
605
+ const identity = fileIdentity(child, metadata);
606
+ /** @type {SnapshotEntry} */
607
+ const entry = { path, kind: "file", digest: identity.digest, size: metadata.size };
608
+ if (metadata.size <= IGNORE_SOURCE_CONTENT_LIMIT) {
609
+ try {
610
+ entry.content = readFileSync(child, "utf8");
611
+ } catch {
612
+ // Content is only needed to replay base rules for a declared edit.
613
+ }
614
+ }
615
+ return [entry];
441
616
  };
442
617
  return [...paths].sort().flatMap(capturePath);
443
618
  }
444
619
  /** @param {SnapshotEntry[]} before @param {SnapshotEntry[]} after @returns {boolean} */
445
620
  function sameSnapshotEntries(before, after) {
446
621
  if (before.length !== after.length) return false;
447
- return before.every((entry, index) => JSON.stringify(entry) === JSON.stringify(after[index]));
622
+ return before.every((entry, index) => snapshotEntryKey(entry) === snapshotEntryKey(after[index]));
448
623
  }
449
624
  /** @param {string} root @param {string[]} paths @returns {string[]} */
450
625
  function expandScopePaths(root, paths) {
@@ -90,6 +90,10 @@ function renderIdentity(model) {
90
90
  `- Frozen plan: ${link(identity.planPath)}`,
91
91
  `- Plan digest: \`${identity.planDigest}\``,
92
92
  `- Contract digest: \`${identity.contractDigest}\``,
93
+ // R36: the effective mode the launch recorded on the run snapshot, read
94
+ // back here rather than re-derived from the contract; absent reads as the
95
+ // cross-vendor default.
96
+ `- Judge independence: \`${identity.judgeIndependence ?? "cross-vendor"}\``,
93
97
  `- Journal cursor: \`${identity.journalCursor}\``,
94
98
  `- Usage sample cutoff: \`${identity.usageSampleCutoff ?? "not recorded"}\``,
95
99
  ].join("\n");
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { renderStatusJson } from "./render.mjs";
26
26
  import { buildCampaignProgress, remainingEstimateMs } from "./progress.mjs";
27
+ import { attemptCosts, retriesOutspendFirst } from "../run/usage.mjs";
27
28
  import { chooseLanguage, labelsFor } from "./locale.mjs";
28
29
  import { readNodeSnapshot } from "../run/node-store.mjs";
29
30
  import { campaignDir } from "../campaign/layout.mjs";
@@ -39,9 +40,11 @@ import { basename, dirname, join } from "node:path";
39
40
  /** @typedef {import("./render.mjs").RoleUsage} RoleUsage */
40
41
  /** @typedef {import("../notify/index.mjs").NotifyEvent} NotifyEvent */
41
42
  /** @typedef {import("./locale.mjs").Language} Language */
42
- /** @typedef {{campaignId: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, nodes: StatusPayloadNode[]}} Payload */
43
+ /** @typedef {{attempt: number, costUsd: number|null}} AttemptCost */
44
+ /** @typedef {StatusPayloadNode & {attemptCosts: AttemptCost[], retriesOutspendFirst: boolean}} MessageNode */
45
+ /** @typedef {{campaignId: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, nodes: MessageNode[]}} Payload */
43
46
  /** @typedef {{id: string, goal: string|null, percentDone: number, nodesTotal: number, phasesDone: number, phasesTotal: number, costTotalUsd: number|null, elapsedMs: number|null, nextCommand: string|null}} CampaignSummary */
44
- /** @typedef {{runDir: string, runId: string, payload: Payload, objectives: Map<string, string|null>, snapshots: Map<string, Record<string, unknown>>, campaign: CampaignSummary|null, label: (key: string) => string, subject: StatusPayloadNode}} View */
47
+ /** @typedef {{runDir: string, runId: string, payload: Payload, objectives: Map<string, string|null>, snapshots: Map<string, Record<string, unknown>>, campaign: CampaignSummary|null, label: (key: string) => string, subject: MessageNode}} View */
45
48
 
46
49
  /**
47
50
  * The message's byte ceiling. Measured 2026-09-21 on the live campaign's
@@ -105,6 +108,15 @@ export function renderRunProgress(runDir, event) {
105
108
  const runsDir = dirname(runDir);
106
109
  const objectives = readObjectives(runDir);
107
110
  const snapshots = new Map(payload.nodes.map((node) => [node.id, readSnapshotSafe(runDir, node.id)]));
111
+ // R31: the status payload's nodes carry their per-attempt cost, the same
112
+ // `usage.mjs` reader the campaign roll-up uses, so the message's text line
113
+ // can mark a node whose retries out-spent the first attempt without
114
+ // re-deriving the comparison here.
115
+ for (const node of payload.nodes) {
116
+ const snapshot = /** @type {import("../contract/index.mjs").NodeSnapshot} */ (/** @type {unknown} */ (snapshots.get(node.id) ?? {}));
117
+ node.attemptCosts = attemptCosts(snapshot);
118
+ node.retriesOutspendFirst = retriesOutspendFirst(snapshot);
119
+ }
108
120
  const campaign = campaignSummary(runsDir, payload.campaignId);
109
121
  const label = labelsFor(chooseLanguage(process.env, [campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
110
122
  const subject = subjectNode(payload.nodes, event.nodeId ?? null);
@@ -130,7 +142,7 @@ function nodeTerminal(view) {
130
142
  const ending = QUIET_STATES.has(subject.status) && !SUCCESS.has(subject.status)
131
143
  ? label(subject.status === "running" ? "running" : "pending")
132
144
  : `${label(OUTCOME_LABEL[subject.status] ?? "doneIn")} ${shortSpan(subject)}`;
133
- const headline = `${OUTCOME[subject.status] ?? "❔"} ${subject.id} · ${ending} · ${formatUsd(subject.costUsd)}${model}${error}`;
145
+ const headline = `${OUTCOME[subject.status] ?? "❔"} ${subject.id} · ${ending} · ${formatUsd(subject.costUsd)}${model}${error}${retryOutspendMark(subject)}`;
134
146
  const pad = padder(label, ["asked", "did", "proof"]);
135
147
  return [
136
148
  headline,
@@ -199,7 +211,7 @@ function attention(view) {
199
211
  const { subject, label, snapshots, objectives, runDir } = view;
200
212
  const snapshot = snapshots.get(subject.id) ?? {};
201
213
  const reason = subject.errorCode ?? subject.status;
202
- const headline = `👀 ${subject.id} ${label("needsYou")} · ${reason} · ${label("attempt")} ${subject.attempt} · ${shortSpan(subject)} · ${formatUsd(subject.costUsd)}`;
214
+ const headline = `👀 ${subject.id} ${label("needsYou")} · ${reason} · ${label("attempt")} ${subject.attempt} · ${shortSpan(subject)} · ${formatUsd(subject.costUsd)}${retryOutspendMark(subject)}`;
203
215
  const pad = padder(label, ["why", "asked", "do"]);
204
216
  return [
205
217
  headline,
@@ -461,9 +473,10 @@ function workerSummary(snapshot) {
461
473
  * most recently settled node (a `run.terminal` event names the run, not a
462
474
  * node), else the run's first node.
463
475
  *
464
- * @param {StatusPayloadNode[]} nodes
476
+ * @template {{id: string, status: import("../contract/index.mjs").NodeStatus, updatedAt?: string|null}} T
477
+ * @param {T[]} nodes
465
478
  * @param {string|null} nodeId
466
- * @returns {StatusPayloadNode}
479
+ * @returns {T}
467
480
  */
468
481
  function subjectNode(nodes, nodeId) {
469
482
  const named = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
@@ -577,6 +590,24 @@ function formatUsd(value) {
577
590
  return `$${value.toFixed(2)}`;
578
591
  }
579
592
 
593
+ /**
594
+ * R31's mark for a node's text line: when the attempts after the first
595
+ * together out-spend the first, the line says so and quotes both sides, so a
596
+ * reader sees the size of the overrun rather than only that one exists. Empty
597
+ * when the comparison cannot be made -- a single attempt, or an attempt on
598
+ * either side that is not fully priced -- because an unknown comparison is
599
+ * not a warning.
600
+ *
601
+ * @param {MessageNode} node
602
+ * @returns {string}
603
+ */
604
+ function retryOutspendMark(node) {
605
+ if (!node.retriesOutspendFirst) return "";
606
+ const [first, ...retries] = node.attemptCosts;
607
+ const retryTotal = retries.reduce((total, entry) => total + /** @type {number} */ (entry.costUsd), 0);
608
+ return ` · ⚠️ retries out-spend the first attempt (${formatUsd(retryTotal)} vs ${formatUsd(/** @type {number} */ (first.costUsd))})`;
609
+ }
610
+
580
611
  /** Whole dollars once a campaign passes a hundred: `$132`, not `$132.21`, in a footer. @param {number|null} value @returns {string} */
581
612
  function formatUsdCompact(value) {
582
613
  if (typeof value === "number" && Number.isFinite(value) && value >= 100) return `$${Math.round(value)}`;
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { formatDuration, readRunUsage, renderStatusJson, roleUsage } from "./render.mjs";
34
34
  import { nodeSnapshotPath, readNodeSnapshot } from "../run/node-store.mjs";
35
+ import { attemptCosts, retriesOutspendFirst } from "../run/usage.mjs";
35
36
  import { finite } from "../util.mjs";
36
37
  import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
37
38
  import { campaignDir } from "../campaign/layout.mjs";
@@ -128,7 +129,7 @@ function campaignCumulativeCostUsd(runsDir, campaignId) {
128
129
  }
129
130
 
130
131
  /**
131
- * @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
132
+ * @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, attemptCosts: import("../run/usage.mjs").AttemptCost[], retriesOutspendFirst: boolean, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
132
133
  * `status` carries every `NodeStatus` plus two the manifest alone can
133
134
  * explain: `not_started` (the contract has no run yet) and `unreadable` (a
134
135
  * run exists but this node's own snapshot does not).
@@ -138,6 +139,10 @@ function campaignCumulativeCostUsd(runsDir, campaignId) {
138
139
  * three points), so a single `runtime` field reads as though the judge did
139
140
  * the worker's job. `judgeRuntime` is `null` for a node with no gate, or one
140
141
  * whose gate never ran -- never the worker's own runtime repeated.
142
+ * `attemptCosts` is the per-attempt cost R31 adds to the payload, derived
143
+ * from the snapshot's own invocations by the ledger's shared reader;
144
+ * `retriesOutspendFirst` is the flag that marks the notification's text line
145
+ * when those retries together cost more than the first attempt.
141
146
  */
142
147
  /**
143
148
  * `counts.done` and `counts.settled` are two different numbers: `done` is
@@ -411,7 +416,7 @@ function judgeRuntimeLabel(invocations) {
411
416
  function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerRuntimeById) {
412
417
  const id = String(node.id);
413
418
  const dependsOn = Array.isArray(node.dependsOn) ? node.dependsOn.map(String) : [];
414
- const empty = { id, dependsOn, attempt: 0, workerRuntime: null, judgeRuntime: null, elapsedSpan: null, costUsd: null, workerLogPath: null, judgeLogPaths: /** @type {string[]} */ ([]), attemptBranch: null, sealCommit: null };
419
+ const empty = { id, dependsOn, attempt: 0, workerRuntime: null, judgeRuntime: null, elapsedSpan: null, costUsd: null, attemptCosts: /** @type {import("../run/usage.mjs").AttemptCost[]} */ ([]), retriesOutspendFirst: false, workerLogPath: null, judgeLogPaths: /** @type {string[]} */ ([]), attemptBranch: null, sealCommit: null };
415
420
  if (!hasRun) return { ...empty, status: "not_started", runDir: null, verificationRecordPath: null };
416
421
 
417
422
  let snapshot;
@@ -429,6 +434,10 @@ function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerR
429
434
  .map((invocation) => invocation.stdoutPath)
430
435
  .filter((path) => typeof path === "string");
431
436
  const worktree = snapshot.worktree && typeof snapshot.worktree === "object" ? /** @type {Record<string, unknown>} */ (snapshot.worktree) : null;
437
+ // The snapshot reader returns the raw persisted object; the ledger's
438
+ // per-attempt reader wants the validated shape, and only its `invocations`
439
+ // are read here.
440
+ const attemptState = /** @type {import("../contract/index.mjs").NodeSnapshot} */ (/** @type {unknown} */ (snapshot));
432
441
 
433
442
  return {
434
443
  id,
@@ -439,6 +448,8 @@ function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerR
439
448
  judgeRuntime: judgeRuntimeLabel(invocations),
440
449
  elapsedSpan: spanOf({ startedAt: typeof snapshot.startedAt === "string" ? snapshot.startedAt : null, updatedAt: typeof snapshot.updatedAt === "string" ? snapshot.updatedAt : null }),
441
450
  costUsd: typeof snapshot.costUsd === "number" ? snapshot.costUsd : null,
451
+ attemptCosts: attemptCosts(attemptState),
452
+ retriesOutspendFirst: retriesOutspendFirst(attemptState),
442
453
  runDir,
443
454
  workerLogPath: typeof worker?.stdoutPath === "string" ? worker.stdoutPath : null,
444
455
  judgeLogPaths,
package/src/run/paths.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  * run, because answering the home side of a not-yet-migrated repository would
18
18
  * split its state across two layouts.
19
19
  */
20
- import { existsSync } from "node:fs";
20
+ import { existsSync, rmSync } from "node:fs";
21
21
  import { dirname, join, resolve, sep } from "node:path";
22
22
  import { campaignDir, campaignsDir } from "../campaign/layout.mjs";
23
23
  import { faberunHome } from "../host/home.mjs";
@@ -134,6 +134,19 @@ export function runDirectory(cwd, runId) {
134
134
  return join(runsRoot(cwd), runId);
135
135
  }
136
136
 
137
+ /**
138
+ * Remove one run's directory and everything under it. The caller owns the
139
+ * decision that the directory may go; keeping the erasure here means no other
140
+ * module re-derives `<runs>/<id>` to delete it and the layout stays in one
141
+ * place.
142
+ *
143
+ * @param {string} runDir
144
+ * @returns {void}
145
+ */
146
+ export function removeRunDirectory(runDir) {
147
+ rmSync(runDir, { recursive: true, force: true });
148
+ }
149
+
137
150
  /**
138
151
  * The live-preflight verdict store, at the top of the home. Whether a
139
152
  * provider answers is a fact about this machine and this operator -- the same
package/src/run/usage.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { appendJsonl, writeJsonAtomic } from "./store.mjs";
10
10
  import { basename, join } from "node:path";
11
- import { errorMessage, stableJson } from "../util.mjs";
11
+ import { errorMessage, finite, stableJson } from "../util.mjs";
12
12
  import { existsSync, readdirSync, readFileSync } from "node:fs";
13
13
  import { liveUsage } from "../harnesses/session-metrics.mjs";
14
14
 
@@ -129,6 +129,71 @@ export function invocationCost(state) {
129
129
  .filter((cost) => typeof cost === "number" && Number.isFinite(cost)));
130
130
  return costs.length ? costs.reduce((total, cost) => total + cost, 0) : undefined;
131
131
  }
132
+ /** @typedef {{attempt: number, costUsd: number|null}} AttemptCost */
133
+ /**
134
+ * The cost of each attempt a node made, derived from the invocation ledger the
135
+ * snapshot already stores and nothing else: every invocation carrying the same
136
+ * `attempt` is one attempt, and its cost is the sum of the prices those
137
+ * invocations recorded. An attempt with any unpriced invocation reports `null`
138
+ * rather than a partial sum -- the same refusal `roleUsage` makes for a role,
139
+ * because dollars that are partly missing are not a total. An invocation that
140
+ * predates the `attempt` field is read as the first attempt rather than
141
+ * dropped.
142
+ *
143
+ * Attempts come back in the order they ran; a snapshot with no invocations
144
+ * yields an empty list, never a fabricated zero-attempt.
145
+ *
146
+ * @param {NodeSnapshot} state
147
+ * @returns {AttemptCost[]}
148
+ */
149
+ export function attemptCosts(state) {
150
+ /** @type {Map<number, {costUsd: number, unpriced: number}>} */
151
+ const byAttempt = new Map();
152
+ for (const invocation of state.invocations ?? []) {
153
+ const attempt = typeof invocation.attempt === "number" && Number.isFinite(invocation.attempt) ? invocation.attempt : 1;
154
+ const bucket = byAttempt.get(attempt) ?? { costUsd: 0, unpriced: 0 };
155
+ const cost = finite(invocation.costUsd);
156
+ if (cost === null) bucket.unpriced += 1;
157
+ else bucket.costUsd += cost;
158
+ byAttempt.set(attempt, bucket);
159
+ }
160
+ return [...byAttempt.entries()]
161
+ .sort((left, right) => left[0] - right[0])
162
+ .map(([attempt, bucket]) => ({ attempt, costUsd: bucket.unpriced > 0 ? null : bucket.costUsd }));
163
+ }
164
+ /**
165
+ * The two sides of R31's comparison: the first attempt's cost and the sum of
166
+ * every attempt after it. `null` when they cannot be compared -- fewer than
167
+ * two attempts, or any attempt on either side is not fully priced -- so a
168
+ * caller never reads an invented number.
169
+ *
170
+ * @param {NodeSnapshot} state
171
+ * @returns {{firstCostUsd: number, retryCostUsd: number}|null}
172
+ */
173
+ export function retryOutspend(state) {
174
+ const costs = attemptCosts(state);
175
+ if (costs.length < 2) return null;
176
+ const [first, ...retries] = costs;
177
+ if (first.costUsd === null || retries.some((entry) => entry.costUsd === null)) return null;
178
+ return {
179
+ firstCostUsd: first.costUsd,
180
+ retryCostUsd: retries.reduce((total, entry) => total + /** @type {number} */ (entry.costUsd), 0),
181
+ };
182
+ }
183
+ /**
184
+ * Whether the retries together out-spend the first attempt. The one predicate
185
+ * every surface shares -- the campaign payload and the notification's text
186
+ * line -- so a node that cost more to redo than to do is named the same way
187
+ * everywhere, against the first attempt's own recorded cost rather than a
188
+ * guessed threshold.
189
+ *
190
+ * @param {NodeSnapshot} state
191
+ * @returns {boolean}
192
+ */
193
+ export function retriesOutspendFirst(state) {
194
+ const outspend = retryOutspend(state);
195
+ return outspend !== null && outspend.retryCostUsd > outspend.firstCostUsd;
196
+ }
132
197
  /**
133
198
  * Invocation ids already present in the run's usage.jsonl. The append path
134
199
  * uses this to stay idempotent across resume and replay.
package/src/util.mjs CHANGED
@@ -239,8 +239,13 @@ export function finite(value) {
239
239
  * outside them separates.
240
240
  *
241
241
  * Deliberately not a shell: no expansion, no substitution, no operators, and
242
- * no backslash escape -- on Windows a backslash is a path separator and
243
- * `C:\Users\x` must survive this intact. The question both callers ask is
242
+ * no backslash escape outside double quotes -- on Windows a backslash is a
243
+ * path separator and `C:\Users\x` must survive this intact. Inside double
244
+ * quotes a backslash before `"` or `\` is the escape POSIX gives it, and any
245
+ * other backslash stays literal, so a quoted Windows path is still intact.
246
+ * Measured 2026-09-27: a `node -e "<script with \"fs\">"` proof, the shape
247
+ * `JSON.stringify` writes, split at the escaped quote and read as a script
248
+ * that did not parse. The question both callers ask is
244
249
  * "which words does this command name", and a quoted path holding a space is
245
250
  * one word: splitting it on whitespace produced two fragments that matched no
246
251
  * file, so a proof naming a real path read as naming none.
@@ -255,6 +260,7 @@ export function shellWords(text) {
255
260
  let current = null;
256
261
  /** @type {string|null} */
257
262
  let quote = null;
263
+ let escaped = false;
258
264
  for (const character of text) {
259
265
  if (quote === null && /\s/u.test(character)) {
260
266
  if (current !== null) words.push(current);
@@ -266,12 +272,22 @@ export function shellWords(text) {
266
272
  current ??= "";
267
273
  continue;
268
274
  }
275
+ if (escaped) {
276
+ current = (current ?? "") + (character === '"' || character === "\\" ? character : `\\${character}`);
277
+ escaped = false;
278
+ continue;
279
+ }
280
+ if (quote === '"' && character === "\\") {
281
+ escaped = true;
282
+ continue;
283
+ }
269
284
  if (quote === character) {
270
285
  quote = null;
271
286
  continue;
272
287
  }
273
288
  current = (current ?? "") + character;
274
289
  }
290
+ if (escaped) current = `${current ?? ""}\\`;
275
291
  if (current !== null) words.push(current);
276
292
  return words;
277
293
  }