faberun 0.10.0 → 0.12.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 (60) hide show
  1. package/README.md +6 -0
  2. package/integrations/claude-code/statusline.sh +31 -2
  3. package/package.json +2 -2
  4. package/skills/faberun/references/spec-format.md +9 -0
  5. package/src/campaign/chain.mjs +2 -1
  6. package/src/campaign/index.mjs +102 -1
  7. package/src/campaign/metrics.mjs +2 -2
  8. package/src/cli/brand.mjs +2 -0
  9. package/src/cli/campaign-contract.mjs +55 -0
  10. package/src/cli/campaign.mjs +48 -13
  11. package/src/cli/init.mjs +8 -2
  12. package/src/cli/launch.mjs +2 -1
  13. package/src/cli/plan.mjs +3 -2
  14. package/src/cli/project.mjs +150 -0
  15. package/src/cli/skills.mjs +2 -3
  16. package/src/cli/update.mjs +7 -10
  17. package/src/cli.mjs +30 -4
  18. package/src/contract/index.mjs +44 -0
  19. package/src/contract/task-packet.mjs +12 -3
  20. package/src/engine/cancel.mjs +13 -0
  21. package/src/engine/dispatch.mjs +20 -2
  22. package/src/engine/lifecycle.mjs +38 -61
  23. package/src/engine/live-preflight.mjs +2 -1
  24. package/src/engine/notify-queue.mjs +9 -1
  25. package/src/engine/pricing-seed.json +543 -0
  26. package/src/engine/pricing-seed.mjs +54 -0
  27. package/src/engine/process.mjs +8 -1
  28. package/src/engine/result-file.mjs +2 -1
  29. package/src/engine/resume.mjs +6 -2
  30. package/src/engine/run-identity.mjs +9 -2
  31. package/src/engine/scheduler.mjs +40 -6
  32. package/src/engine/settle-judge.mjs +141 -0
  33. package/src/engine/settle.mjs +1 -1
  34. package/src/harnesses/protocol.mjs +46 -10
  35. package/src/harnesses/replay/bin.mjs +10 -1
  36. package/src/host/home.mjs +10 -3
  37. package/src/host/platform.mjs +111 -0
  38. package/src/host/preflight.mjs +43 -21
  39. package/src/host/projects.mjs +155 -0
  40. package/src/notify/index.mjs +88 -10
  41. package/src/plan/pipeline.mjs +32 -9
  42. package/src/plan/repo-facts.mjs +80 -2
  43. package/src/plan/spec.mjs +2 -1
  44. package/src/repo/declared-paths.mjs +7 -1
  45. package/src/repo/integrate.mjs +1 -1
  46. package/src/repo/scope-closure.mjs +2 -1
  47. package/src/repo/signal.mjs +24 -7
  48. package/src/repo/source-identity.mjs +7 -0
  49. package/src/repo/workspace.mjs +12 -2
  50. package/src/repo/worktree.mjs +89 -22
  51. package/src/report/next.mjs +66 -6
  52. package/src/report/progress.mjs +724 -0
  53. package/src/report/render.mjs +3 -3
  54. package/src/run/migrate.mjs +278 -0
  55. package/src/run/paths.mjs +189 -0
  56. package/src/seat/index.mjs +2 -1
  57. package/src/web/app.css +170 -0
  58. package/src/web/app.mjs +690 -0
  59. package/src/web/index.html +38 -283
  60. package/src/web/server.mjs +197 -182
@@ -18,7 +18,8 @@ import { acquire as acquireLock } from "../run/lock.mjs";
18
18
  import { applyInvalidWorkerResult, assertRunMutable, handleProviderExhaustion } from "./lifecycle.mjs";
19
19
  import { applyJudgeResult } from "./review.mjs";
20
20
  import { assertSourceUnchanged, captureRunIdentity, recordedBaseRef, statesFingerprint } from "./run-identity.mjs";
21
- import { attemptWorkspace, attemptWorktreePath, gitHead, removeWorktree, runRefName } from "../repo/worktree.mjs";
21
+ import { attemptWorkspace, gitHead, removeWorktree, runRefName } from "../repo/worktree.mjs";
22
+ import { attemptWorktreePath } from "../run/paths.mjs";
22
23
  import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
23
24
  import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
24
25
  import { closePersistedInvocation, recoverOrphan, recoveryFromOverride } from "./recover.mjs";
@@ -302,7 +303,10 @@ export async function resumeRun(runDirPath, options = {}) {
302
303
  await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
303
304
  continue;
304
305
  }
305
- if (node.taskPacket.mode === "discovery" && parsedWorkerResult.status === "done") {
306
+ // Same discriminator as lifecycle.mjs: the artifact is owed only by
307
+ // the repo-reading discovery packet (empty readFiles), never by one
308
+ // closed to its listed read files, which delivers through `output`.
309
+ if (node.taskPacket.mode === "discovery" && node.taskPacket.readFiles.length === 0 && parsedWorkerResult.status === "done") {
306
310
  try {
307
311
  parseDiscoveryResult(parsedWorkerResult, attemptWorkspace(state) ?? contract.cwd);
308
312
  } catch (error) {
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
23
23
  import { stableJson } from "../util.mjs";
24
24
  import { validateRunMetadata } from "../contract/snapshot.mjs";
25
25
  import { contractDigest } from "../contract/index.mjs";
26
+ import { RUNS_DIR_NAME, runDirectory } from "../run/paths.mjs";
26
27
 
27
28
  /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
28
29
  /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
@@ -88,7 +89,7 @@ export function createRunMetadata(lock, sourceIdentity, resume = {}, integration
88
89
  */
89
90
  function runDirFor(sourceIdentity) {
90
91
  if (!sourceIdentity.cwd || !sourceIdentity.contractId) return null;
91
- return join(sourceIdentity.cwd, ".runs", sourceIdentity.contractId);
92
+ return runDirectory(sourceIdentity.cwd, sourceIdentity.contractId);
92
93
  }
93
94
 
94
95
  /**
@@ -319,7 +320,13 @@ function walkControllerFiles(root) {
319
320
  /** @type {string[]} */
320
321
  const found = [];
321
322
  for (const entry of readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
322
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".runs") continue;
323
+ // The controller identity hashes the executable snapshot, not the tree it
324
+ // is running in: `node_modules` is a dependency install, `.git` is
325
+ // repository metadata, and `RUNS_DIR_NAME` is the runner's own scratch
326
+ // state written while a run is in flight. None of the three is a change
327
+ // to the controller's source, so the sha must never move because the
328
+ // runner wrote to its own state -- only because tracked source did.
329
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === RUNS_DIR_NAME) continue;
323
330
  const path = join(root, entry.name);
324
331
  if (entry.isDirectory()) found.push(...walkControllerFiles(path));
325
332
  else if (entry.isFile()) found.push(path);
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import { syncAgentSignal } from "../repo/signal.mjs";
4
4
  import { JUDGE_SCHEMA, PARKED, SETTLED, retryPrompt } from "./prompts.mjs";
@@ -18,8 +18,10 @@ import {
18
18
  } from "../run/store.mjs";
19
19
  import {
20
20
  acquire as acquireLock,
21
+ lockStale,
21
22
  LockLostError,
22
23
  processStartToken,
24
+ readLock,
23
25
  } from "../run/lock.mjs";
24
26
  import { registerRun, resolveCampaign } from "../campaign/index.mjs";
25
27
  import { createRunRef, runRefName } from "../repo/worktree.mjs";
@@ -46,6 +48,7 @@ import { startJudge, startWorker } from "./dispatch.mjs";
46
48
  import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
47
49
  import { blockDependents, runtimeAssignments } from "./assignment.mjs";
48
50
  import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from "./supervise.mjs";
51
+ import { runDirectory, runsRoot } from "../run/paths.mjs";
49
52
 
50
53
  /** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
51
54
 
@@ -179,6 +182,25 @@ function renderFingerprint(states) {
179
182
  return `${statesFingerprint(states)}#${progress}`;
180
183
  }
181
184
 
185
+ /**
186
+ * Move a cancelled run's directory out of the way of its own id, once, so
187
+ * the launch that needs the id back can create it fresh. Never called for
188
+ * any other reason a run directory might already exist -- this is the one
189
+ * occupant a launch is allowed to displace on its own.
190
+ *
191
+ * @param {string} runDir
192
+ * @returns {void}
193
+ */
194
+ function archiveCanceledRunDir(runDir) {
195
+ let archivedPath = `${runDir}.canceled-${Date.now()}`;
196
+ let suffix = 0;
197
+ while (existsSync(archivedPath)) {
198
+ suffix += 1;
199
+ archivedPath = `${runDir}.canceled-${Date.now()}-${suffix}`;
200
+ }
201
+ renameSync(runDir, archivedPath);
202
+ }
203
+
182
204
  /**
183
205
  * @param {string} contractPath
184
206
  * @param {{detachedBootstrap?: boolean, baseRef?: string}} [options]
@@ -192,9 +214,21 @@ function renderFingerprint(states) {
192
214
  export async function runContract(contractPath, options = {}) {
193
215
  const absoluteContractPath = resolve(contractPath);
194
216
  const contract = validateContractForLaunch(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, { baseRef: options.baseRef });
195
- const runDir = join(contract.cwd, ".runs", contract.id);
196
- if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
197
- mkdirSync(join(contract.cwd, ".runs"), { recursive: true });
217
+ const runDir = runDirectory(contract.cwd, contract.id);
218
+ if (existsSync(runDir)) {
219
+ // A cancelled run is the one prior occupant of this id a fresh launch may
220
+ // move aside on its own: `cancel` already released the git names (the run
221
+ // ref, every node's attempt branch) this launch needs back, and the
222
+ // cancel-request marker plus a stale controller lock is what proves no
223
+ // process can still be writing into it. Anything else at this path --
224
+ // still running, or settled without ever being cancelled -- keeps
225
+ // refusing exactly as before; the operator's evidence is never silently
226
+ // claimed.
227
+ const wasCanceled = existsSync(join(runDir, "cancel.request.json")) && lockStale(readLock(runDir));
228
+ if (!wasCanceled) throw new Error(`run already exists: ${runDir}`);
229
+ archiveCanceledRunDir(runDir);
230
+ }
231
+ mkdirSync(runsRoot(contract.cwd), { recursive: true });
198
232
  try {
199
233
  mkdirSync(runDir);
200
234
  } catch (error) {
@@ -208,7 +242,7 @@ export async function runContract(contractPath, options = {}) {
208
242
  const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
209
243
  const integrationRef = createRunRef(contract.cwd, contract.id, sourceIdentity.gitHead);
210
244
  lock.assert();
211
- const runsDir = join(contract.cwd, ".runs");
245
+ const runsDir = runsRoot(contract.cwd);
212
246
  const campaign = resolveCampaign(runsDir, contract.campaignId);
213
247
  mkdirSync(join(runDir, "nodes"), { recursive: true });
214
248
  mkdirSync(join(runDir, "logs"), { recursive: true });
@@ -281,7 +315,7 @@ export async function runContract(contractPath, options = {}) {
281
315
  export async function driveRun(contract, runDir, states, campaign, lock, sourceIdentity, resume = {}, options = {}) {
282
316
  lock.assert();
283
317
  assertEnvironmentReady(contract, runDir, sourceIdentity);
284
- const runsDir = join(contract.cwd, ".runs");
318
+ const runsDir = runsRoot(contract.cwd);
285
319
  const bootstrapNonce = bootstrapNonceForProcess();
286
320
  // Only the CLI entry can answer this: a nonce inherited by evals/run.mjs or
287
321
  // by a test must not make the controller wait for an acknowledgement nobody
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The judge branch of `finalizeClosedJobs`, and the workspace comparison its
3
+ * fail-closed write check depends on. Split out of `lifecycle.mjs` for the
4
+ * same reason `engine/settle.mjs` was: `lifecycle.mjs` and
5
+ * `test/engine/judge.test.mjs` both sit on the 800-line ceiling
6
+ * `test/repo/source-shape.test.mjs` enforces.
7
+ *
8
+ * `settleJudgeRound` runs only after `lifecycle.mjs` has already called
9
+ * `judgeWorkspaceWriteViolation` and found nothing: the check has to run
10
+ * before any branch here (a failed provider, a bounded re-dispatch, or a
11
+ * verdict) can adopt or launder a judge's own write, so it cannot live inside
12
+ * this function without reintroducing the escape it closes.
13
+ *
14
+ * `clearTierExhaustion` and `handleProviderExhaustion` are `lifecycle.mjs`'s
15
+ * own -- importing them back from there would recreate the exact
16
+ * `lifecycle.mjs` <-> `review.mjs` cycle `test/repo/source-shape.test.mjs`
17
+ * once caught and `AGENTS.md` records as fixed, so the caller passes them in
18
+ * instead.
19
+ */
20
+ import { judgeReaskOutstanding } from "./judge-gate.mjs";
21
+ import { judgeVerdictEvidence } from "../contract/review-modes.mjs";
22
+ import {
23
+ JUDGE_MAX_FAILURES,
24
+ applyJudgeProtocolFailure,
25
+ applyJudgeResult,
26
+ applyJudgeRound,
27
+ settleUnavailableJudge,
28
+ } from "./review.mjs";
29
+ import { networkTransition } from "./backoff.mjs";
30
+ import { startJudge } from "./dispatch.mjs";
31
+ import { writeNode } from "./state.mjs";
32
+ import { compareWorkspaceSnapshot } from "../repo/workspace.mjs";
33
+ import { errorCode, errorMessage, excerpt } from "../util.mjs";
34
+
35
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
36
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
37
+ /** @typedef {import("./process.mjs").Job} Job */
38
+ /** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
39
+ /** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
40
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
41
+ /** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
42
+
43
+ /**
44
+ * Whether a closed judge job wrote into its own workspace, or a workspace
45
+ * comparison error that means the same thing: either is a violation, never
46
+ * silence. `compareWorkspaceSnapshot` throws `snapshot_ignore_changed` when
47
+ * the judge edits an ignore source, `snapshot_symlink_escape` when it creates
48
+ * a symlink out of the tree, and `snapshot_too_large` when it adds enough
49
+ * entries -- all ordinary judge writes that must fail closed exactly like the
50
+ * worker path (`engine/scope.mjs`'s `checkWorkerScope`), never vanish into a
51
+ * "no writes" result. `job.scopeBaseline` is null only when the capture at
52
+ * dispatch time itself failed -- `startJudge` degrades to unchecked rather
53
+ * than refuse to run the judge at all -- and that is the one case with
54
+ * nothing to compare, not a violation.
55
+ *
56
+ * @param {Job} job
57
+ * @returns {{message: string}|null}
58
+ */
59
+ export function judgeWorkspaceWriteViolation(job) {
60
+ const baseline = job.scopeBaseline;
61
+ if (!baseline) return null;
62
+ try {
63
+ const { unexpectedPaths } = compareWorkspaceSnapshot(/** @type {import("../repo/workspace.mjs").WorkspaceSnapshot} */ (baseline), job.cwd);
64
+ if (!unexpectedPaths.length) return null;
65
+ return { message: excerpt(`judge wrote into its own workspace (${unexpectedPaths.length}): ${unexpectedPaths.slice(0, 8).join(", ")}`) ?? "judge wrote into its own workspace" };
66
+ } catch (error) {
67
+ return { message: excerpt(`judge workspace comparison failed (${errorCode(error) ?? "scope_snapshot_invalid"}): ${errorMessage(error)}`) ?? "judge workspace comparison failed" };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Settle a closed judge invocation whose write check already passed: a
73
+ * provider that failed outright gets one bounded re-dispatch, then review-mode
74
+ * settlement; anything else that is not exactly one usable verdict (none at
75
+ * all, several of them, an unparseable one, a stream cut off before its
76
+ * terminal envelope, or a phase killed on its wall clock) takes the same
77
+ * bounded re-ask; a clean verdict is applied.
78
+ *
79
+ * @param {ValidatedContract} contract
80
+ * @param {Job} job
81
+ * @param {NodeSnapshot} state
82
+ * @param {string} runDir
83
+ * @param {Map<string, Job>} running
84
+ * @param {LockHandle} lock
85
+ * @param {Map<string, NodeSnapshot>} states
86
+ * @param {string} campaignPath
87
+ * @param {PricedEnvelope} envelope
88
+ * @param {{clearTierExhaustion: (state: NodeSnapshot) => void, handleProviderExhaustion: typeof import("./lifecycle.mjs").handleProviderExhaustion}} hooks
89
+ * `lifecycle.mjs`'s own tier-exhaustion clear and provider-exhaustion router, passed in rather than imported back.
90
+ * @returns {Promise<void>}
91
+ */
92
+ export async function settleJudgeRound(contract, job, state, runDir, running, lock, states, campaignPath, envelope, { clearTierExhaustion, handleProviderExhaustion }) {
93
+ const node = job.node;
94
+ // A judge provider that failed outright (its turn died, its tool host was
95
+ // gone) is a provider failure, never a verdict: the gate cannot adopt a
96
+ // result the judge could not ground in inspection. Re-dispatch the judge
97
+ // once on the same routing, then settle by review mode so a judge failure
98
+ // is surfaced, never silently settled. A stream that never reached its
99
+ // terminal envelope is a protocol defect instead and takes the bounded
100
+ // re-ask below.
101
+ if (envelope.status === "failed" && envelope.error?.code !== "incomplete_stream") {
102
+ // A judge that lost its socket is not an unavailable judge. It buys the
103
+ // same bounded network waits a worker does, on the runtime it already
104
+ // warmed, and spends none of the one re-dispatch counted below.
105
+ const network = networkTransition(contract, node, state, "judge", envelope, job.exitCode);
106
+ if (network && handleProviderExhaustion(contract, runDir, node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) return;
107
+ clearTierExhaustion(state);
108
+ // The provider died on the bounded re-ask itself, so the one permitted
109
+ // re-ask is spent: settle by review mode here rather than dispatch a
110
+ // third judge invocation behind a fresh failure count.
111
+ if (judgeReaskOutstanding(state)) {
112
+ await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
113
+ return;
114
+ }
115
+ state.judgeFailures = (state.judgeFailures ?? 0) + 1;
116
+ if (state.judgeFailures < JUDGE_MAX_FAILURES) {
117
+ writeNode(runDir, state, lock);
118
+ await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaignPath),
119
+ contract, node, state, runDir, running, lock, states, campaignPath, state.result);
120
+ return;
121
+ }
122
+ await settleUnavailableJudge(contract, node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
123
+ return;
124
+ }
125
+ // Whatever else this invocation produced, it is not exactly one usable
126
+ // verdict: no verdict at all, several of them in separate agent messages,
127
+ // an unparseable one, a stream cut off before its terminal envelope, or a
128
+ // phase killed on its wall clock. One bounded re-ask, then the review mode
129
+ // decides — advisory completes, blocking enters attention with the work
130
+ // preserved so a retry in place can re-judge it.
131
+ const evidence = judgeVerdictEvidence(envelope);
132
+ if (!evidence.ok) {
133
+ const network = networkTransition(contract, node, state, "judge", envelope, job.exitCode);
134
+ if (network && handleProviderExhaustion(contract, runDir, node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) return;
135
+ clearTierExhaustion(state);
136
+ await applyJudgeProtocolFailure(contract, node, state, runDir, running, lock, states, campaignPath, evidence.reason);
137
+ return;
138
+ }
139
+ clearTierExhaustion(state);
140
+ await applyJudgeResult(contract, node, state, evidence.result, runDir, lock, running, states, campaignPath);
141
+ }
@@ -22,10 +22,10 @@ import { alreadyNotified, notifyQueueFor } from "./notify-queue.mjs";
22
22
 
23
23
  import {
24
24
  attemptWorkspace,
25
- attemptWorktreePath,
26
25
  removeWorktree,
27
26
  sealAttempt,
28
27
  } from "../repo/worktree.mjs";
28
+ import { attemptWorktreePath } from "../run/paths.mjs";
29
29
  import { basename } from "node:path";
30
30
  import { boundedUtf8, errorCode, errorMessage } from "../util.mjs";
31
31
  import { campaignIdOf } from "../campaign/record.mjs";
@@ -498,6 +498,50 @@ export function isVerdictCandidate(text) {
498
498
  }
499
499
  }
500
500
 
501
+ /**
502
+ * A judge's final message can carry its whole verdict on one line with no
503
+ * newline before the JSON: `Confirmed. Now write the verdict JSON.{"verdict":
504
+ * "fail", ...}`, measured 2026-09-17 at 5662 characters. A line-boundary-only
505
+ * suffix scan never finds it, extractJson returns null, isVerdictCandidate
506
+ * returns false, and a correct, evidenced verdict is discarded as
507
+ * `judge_unavailable`. The candidate starts tried here are every `{` and `[`
508
+ * offset, not just line starts, so a JSON object glued directly to prose is
509
+ * still reachable.
510
+ */
511
+ const SUFFIX_CANDIDATE_LIMIT = 200;
512
+
513
+ /**
514
+ * The last parseable suffix of `trimmed`, trying every `{`/`[` offset from the
515
+ * end backwards. Bounded to the last `SUFFIX_CANDIDATE_LIMIT` offsets: without
516
+ * a cap this is one `JSON.parse` per bracket character, quadratic on a
517
+ * pathological message built mostly of brackets. 200 is comfortably above the
518
+ * bracket count of any real verdict object (the measured 5662-character
519
+ * message resolves on one of its first few tries, since its outermost `{` is
520
+ * also its first) and keeps every call to at most 200 parses regardless of
521
+ * message length.
522
+ *
523
+ * @param {string} trimmed
524
+ * @returns {string|null}
525
+ */
526
+ function suffixJsonCandidate(trimmed) {
527
+ const offsets = [];
528
+ for (let index = 0; index < trimmed.length; index += 1) {
529
+ const char = trimmed[index];
530
+ if (char === "{" || char === "[") offsets.push(index);
531
+ }
532
+ const floor = Math.max(0, offsets.length - SUFFIX_CANDIDATE_LIMIT);
533
+ for (let index = offsets.length - 1; index >= floor; index -= 1) {
534
+ const candidate = trimmed.slice(offsets[index]);
535
+ try {
536
+ JSON.parse(candidate);
537
+ return candidate;
538
+ } catch {
539
+ // This suffix is not JSON; keep trying earlier bracket offsets.
540
+ }
541
+ }
542
+ return null;
543
+ }
544
+
501
545
  /**
502
546
  * Extract a JSON value from a provider response that may carry prose, taking
503
547
  * the last parseable suffix or fenced JSON block.
@@ -514,16 +558,8 @@ export function extractJson(value) {
514
558
  } catch {
515
559
  // Not JSON as a whole: fall through to the suffix and fenced-block scans below.
516
560
  }
517
- const lines = trimmed.split(/\r?\n/u);
518
- for (let index = lines.length - 1; index > 0; index -= 1) {
519
- const candidate = lines.slice(index).join("\n").trim();
520
- try {
521
- JSON.parse(candidate);
522
- return candidate;
523
- } catch {
524
- // This suffix is not JSON; keep trying earlier line boundaries.
525
- }
526
- }
561
+ const suffix = suffixJsonCandidate(trimmed);
562
+ if (suffix !== null) return suffix;
527
563
  const blocks = [...value.matchAll(/```json\s*([\s\S]*?)```/giu)];
528
564
  for (const block of blocks.reverse()) {
529
565
  const candidate = block[1].trim();
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { RUNS_DIR_NAME } from "../../run/paths.mjs";
4
5
 
5
6
  const ENVELOPE_STATUSES = new Set(["done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled"]);
6
7
  const LINE_KEYS = new Set(["envelope", "files", "delayMs", "exitCode", "stdoutRaw"]);
@@ -13,7 +14,15 @@ const ENVELOPE_KEYS = new Set([...ENVELOPE_REQUIRED_KEYS, ...ENVELOPE_OPTIONAL_K
13
14
  const USAGE_KEYS = new Set(["inputTokens", "outputTokens", "cacheReadInputTokens"]);
14
15
  const FILE_KEYS = new Set(["path", "content"]);
15
16
  const ERROR_KEYS = new Set(["code", "message", "resetAt"]);
16
- const METADATA_ROOTS = new Set([".git", ".runs", "node_modules", ".claude", ".codex"]);
17
+ // A recorded file write is refused, not merely ignored, if it targets any of
18
+ // these roots: `.git` is repository metadata, `RUNS_DIR_NAME` is the runner's
19
+ // own scratch tree, `node_modules` is a dependency install, and `.claude` and
20
+ // `.codex` are agent runtimes' own scratch state. A replayed recording that
21
+ // named one of these would be indistinguishable from a live worker escaping
22
+ // its workspace into the runner's own bookkeeping, so replay comparison
23
+ // treats all five the same way live execution does: never a legitimate
24
+ // worker write.
25
+ const METADATA_ROOTS = new Set([".git", RUNS_DIR_NAME, "node_modules", ".claude", ".codex"]);
17
26
  const PREFLIGHT_TOKEN = "FABERUN_PREFLIGHT_OK";
18
27
 
19
28
  const PREFLIGHT_ENVELOPE = Object.freeze({
package/src/host/home.mjs CHANGED
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * One module owns every path the installer, the self-updater and the banner
5
5
  * must agree on: `versions/<v>/`, the `current` symlink, `config.json`,
6
- * `update-check.json` and `tmp/`. install.sh is owned by another node; if it
7
- * and the updater each spelled the layout out, they would drift until
8
- * `current` pointed at a directory one of them did not mean.
6
+ * `update-check.json`, `tmp/` and `projects/`. install.sh is owned by another
7
+ * node; if it and the updater each spelled the layout out, they would drift
8
+ * until `current` pointed at a directory one of them did not mean.
9
+ * `projects/` holds the project registry; `host/projects.mjs` owns what goes
10
+ * inside it, since this module owns where things sit, not what they mean.
9
11
  */
10
12
  import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
11
13
  import { homedir } from "node:os";
@@ -50,6 +52,11 @@ export function tmpDir(home) {
50
52
  return join(home, "tmp");
51
53
  }
52
54
 
55
+ /** @param {string} home @returns {string} */
56
+ export function projectsDir(home) {
57
+ return join(home, "projects");
58
+ }
59
+
53
60
  /**
54
61
  * The version directory an entry path was launched from, or null when the path
55
62
  * does not name a location inside `versions/`.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The three primitives whose implementation differs on Windows, in one module
3
+ * because the callers that need them — the updater repointing `current`, the
4
+ * skill registration linking a directory, the doctor and the banner asking
5
+ * whether a binary is there — must all get the same answer.
6
+ *
7
+ * A directory link is a junction on Windows. A directory *symlink* there needs
8
+ * Developer Mode or an elevated shell, so `symlinkSync` on a stock machine
9
+ * fails with EPERM; a junction needs no privilege, resolves through
10
+ * `realpathSync` — which is how `installedVersionDir` identifies the running
11
+ * version — and reports `isSymbolicLink()` true, so every reader already
12
+ * written against the POSIX layout keeps working.
13
+ *
14
+ * `tar` is the one in System32 when it is there (bsdtar, shipped with Windows
15
+ * since 10 1803). Git for Windows puts a GNU tar on PATH that reads the `C:` in
16
+ * an absolute path as a remote host and fails with `Cannot connect to C:`, and
17
+ * whichever of the two comes first on PATH is not something this tool gets to
18
+ * choose. install.ps1 resolves it the same way for the same reason.
19
+ *
20
+ * A command name is a name plus `PATHEXT` on Windows. `node` on PATH is
21
+ * `node.exe` and there is no extensionless file beside it, so a lookup that
22
+ * only joins the bare name reports the running interpreter as missing.
23
+ */
24
+ import { existsSync, renameSync, rmSync, symlinkSync } from "node:fs";
25
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
26
+
27
+ /**
28
+ * Point `linkPath` at `target`, replacing whatever link is already there. A
29
+ * relative `target` is relative to the link's own directory, which is the form
30
+ * the install layout is written in (`current -> versions/<v>`).
31
+ *
32
+ * On POSIX the swap is atomic: a sibling temporary link then a rename, so a
33
+ * concurrent reader sees either the old target or the new one, never a missing
34
+ * link. On Windows it cannot be — renaming a directory over an existing
35
+ * junction fails with EPERM — so the link is removed and remade, and a reader
36
+ * in the moment between the two finds nothing there.
37
+ *
38
+ * @param {string} linkPath
39
+ * @param {string} target
40
+ * @returns {void}
41
+ */
42
+ export function linkDirectory(linkPath, target) {
43
+ if (process.platform === "win32") {
44
+ // A junction stores an absolute target; a relative one would be recorded
45
+ // against the process cwd rather than the link's directory.
46
+ const absolute = isAbsolute(target) ? target : resolve(dirname(linkPath), target);
47
+ rmSync(linkPath, { recursive: true, force: true });
48
+ symlinkSync(absolute, linkPath, "junction");
49
+ return;
50
+ }
51
+ const temporary = `${linkPath}.tmp`;
52
+ rmSync(temporary, { force: true });
53
+ symlinkSync(target, temporary);
54
+ renameSync(temporary, linkPath);
55
+ }
56
+
57
+ /**
58
+ * The `tar` to spawn on this machine.
59
+ *
60
+ * @returns {string}
61
+ */
62
+ export function tarExecutable() {
63
+ if (process.platform !== "win32") return "tar";
64
+ const system = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
65
+ return existsSync(system) ? system : "tar";
66
+ }
67
+
68
+ /**
69
+ * The first directory on PATH that holds `name`, or null. Exported because the
70
+ * doctor, the banner and the skill registration all count the same binaries,
71
+ * rather than each keeping a copy of the lookup that can disagree.
72
+ *
73
+ * @param {string} name
74
+ * @returns {string|null}
75
+ */
76
+ export function findExecutable(name) {
77
+ const candidates = executableCandidates(name);
78
+ if (name.includes("/") || name.includes("\\")) {
79
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
80
+ }
81
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
82
+ if (!dir) continue;
83
+ for (const candidate of candidates) {
84
+ const path = join(dir, candidate);
85
+ if (existsSync(path)) return path;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * The file names a command name can have on this platform: itself on POSIX,
93
+ * itself plus every `PATHEXT` spelling on Windows.
94
+ *
95
+ * PATHEXT comes first and the bare name last, because that is the order
96
+ * Windows itself runs them in: `node` on PATH is `node.exe`, a CLI npm
97
+ * installed is `<name>.cmd`, and the extensionless file npm leaves beside it is
98
+ * a shell script no Windows process can execute. Measured 2026-09-18 on Windows
99
+ * 11: without the extensions `doctor` reported `binary node · not found on
100
+ * PATH` from a process that node itself was running.
101
+ *
102
+ * @param {string} name
103
+ * @returns {string[]}
104
+ */
105
+ function executableCandidates(name) {
106
+ if (process.platform !== "win32") return [name];
107
+ // Lowercased so the reported path reads like the file on disk: PATHEXT is
108
+ // upper case by convention, the file system is not case sensitive.
109
+ const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((extension) => extension.trim().toLowerCase()).filter(Boolean);
110
+ return [...extensions.map((extension) => `${name}${extension}`), name];
111
+ }
@@ -16,9 +16,10 @@
16
16
  */
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { existsSync, readFileSync, statfsSync } from "node:fs";
19
- import { delimiter, join, resolve } from "node:path";
19
+ import { join, resolve } from "node:path";
20
20
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, probeRuntime } from "../harnesses/index.mjs";
21
21
  import { addRuntimeRequirement, failoverTargets, runtimeSnapshot } from "../engine/failover.mjs";
22
+ import { pricingSeedAge } from "../engine/pricing-seed.mjs";
22
23
  import { validateContract } from "../contract/index.mjs";
23
24
  import { sharedVerificationCommands } from "../contract/final-verification.mjs";
24
25
  import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runtime-discovery.mjs";
@@ -26,7 +27,9 @@ import { errorMessage } from "../util.mjs";
26
27
  import { boundedGitSync } from "../repo/worktree.mjs";
27
28
  import { routeRuntime } from "../contract/runtime.mjs";
28
29
  import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
30
+ import { findExecutable } from "./platform.mjs";
29
31
  import { colorLevel, statusToken } from "../cli/brand.mjs";
32
+ import { RUNS_DIR_NAME } from "../run/paths.mjs";
30
33
 
31
34
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
32
35
  /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
@@ -254,6 +257,22 @@ export function declaredVerificationCommands(contract) {
254
257
  return [...commands.values()];
255
258
  }
256
259
 
260
+ /**
261
+ * Environment names removed before spawning a timing candidate. The probe
262
+ * only measures how long a command takes; a subtraction of a named few, not
263
+ * an allowlist, so PATH, HOME and every ordinary variable the command needs
264
+ * to run at all still pass through unchanged.
265
+ */
266
+ const SIDE_EFFECT_ENV_KEYS = [
267
+ NOTIFY_BIN_ENV, // a measurement must not notify a human
268
+ "FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
269
+ "FABERUN_CLAUDE_BIN", // same, for the claude harness
270
+ "FABERUN_AGY_BIN", // same, for the agy harness
271
+ "FABERUN_DSH_BIN", // same, for the dsh harness
272
+ "FABERUN_ZCODE_BIN", // same, for the zcode harness
273
+ "FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
274
+ ];
275
+
257
276
  /**
258
277
  * Run every declared verification command once and report what it actually
259
278
  * costs against the timeout the contract gives it.
@@ -275,6 +294,8 @@ export function declaredVerificationCommands(contract) {
275
294
  export function timeVerificationCommands(contract, probes = {}) {
276
295
  const now = probes.now ?? (() => Date.now());
277
296
  const run = probes.run ?? spawnSync;
297
+ const env = { ...process.env };
298
+ for (const key of SIDE_EFFECT_ENV_KEYS) delete env[key];
278
299
  return declaredVerificationCommands(contract).map((command) => {
279
300
  const label = command.argv.join(" ");
280
301
  const name = `verification timing · ${label}`;
@@ -288,6 +309,7 @@ export function timeVerificationCommands(contract, probes = {}) {
288
309
  timeout: ceilingSec * 1_000,
289
310
  stdio: "ignore",
290
311
  encoding: "utf8",
312
+ env,
291
313
  });
292
314
  const seconds = (now() - startedAt) / 1_000;
293
315
  const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
@@ -391,6 +413,17 @@ export async function doctorCommand(contractPath, values) {
391
413
  checks.push({ name: `binary ${binary}`, ok: found !== null, detail: found ?? "not found on PATH" });
392
414
  }
393
415
  checks.push({ name: "runner schema", ok: true, detail: `protocol ${PROTOCOL_SCHEMA_VERSION} · runner ${CONTRACT_VERSION}` });
416
+ // A stale seed still prices better than no seed, so this check informs and
417
+ // never fails: a fact beside the runner-schema line, not a gate like the
418
+ // advisory checks below it, which exist because they can genuinely fail.
419
+ const seedAge = pricingSeedAge();
420
+ checks.push({
421
+ name: "pricing seed",
422
+ ok: true,
423
+ detail: seedAge.stale
424
+ ? `models.dev rates vendored ${seedAge.fetchedAt} · ${seedAge.ageDays} days old; re-vendor src/engine/pricing-seed.json`
425
+ : `models.dev rates vendored ${seedAge.fetchedAt}`,
426
+ });
394
427
  /** @type {Set<string>} */
395
428
  let usedHarnesses = new Set();
396
429
  /** @type {Set<string>} */
@@ -486,12 +519,20 @@ function isGitWorkTree(repoDir) {
486
519
  }
487
520
 
488
521
  /**
522
+ * Whether the runs directory name is ignored in `repoDir`.
523
+ *
524
+ * Not dead cleanup after run state moved under the faberun home: an attempt
525
+ * worktree is still a git working tree, and R3 keeps the worker's result
526
+ * sidecar inside it, under `.runs/`. The ignore line is what keeps that
527
+ * sidecar out of the worktree's scope diff, so this check -- and the
528
+ * `.gitignore` line `cli/init.mjs` writes -- survive the move.
529
+ *
489
530
  * @param {string} repoDir
490
531
  * @returns {boolean}
491
532
  */
492
533
  function isRunsIgnored(repoDir) {
493
534
  try {
494
- const result = boundedGitSync(["-C", repoDir, "check-ignore", "-q", ".runs"], { stdio: ["ignore", "ignore", "ignore"] });
535
+ const result = boundedGitSync(["-C", repoDir, "check-ignore", "-q", RUNS_DIR_NAME], { stdio: ["ignore", "ignore", "ignore"] });
495
536
  if (result.status === 0) return true;
496
537
  } catch {
497
538
  // A git that cannot run leaves the check-ignore answer unknown; fall through to reading .gitignore directly.
@@ -503,22 +544,3 @@ function isRunsIgnored(repoDir) {
503
544
  return false;
504
545
  }
505
546
  }
506
-
507
- /**
508
- * The first directory on PATH that holds `name`, or null. Exported because the
509
- * banner counts the harness binaries with the same lookup the doctor uses,
510
- * rather than a second copy that can disagree.
511
- *
512
- * @param {string} name
513
- * @returns {string|null}
514
- */
515
- export function findExecutable(name) {
516
- if (name.includes("/") || name.includes("\\")) return existsSync(name) ? name : null;
517
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
518
- if (!dir) continue;
519
- const candidate = join(dir, name);
520
- if (existsSync(candidate)) return candidate;
521
- }
522
- return null;
523
- }
524
-