faberun 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "node": ">=22"
25
25
  },
26
26
  "scripts": {
27
- "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
27
+ "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')&&!p.includes('.runs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
29
  "check:campaign-brief-render": "node src/report/campaign-brief-html.mjs",
30
30
  "test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
@@ -25,7 +25,6 @@ typecheck`). Schema version is `3`.
25
25
  "sol": { "harness": "codex", "model": "gpt-5.6-sol", "reasoning": "xhigh", "vendor": "openai-sol" },
26
26
  "opus": { "harness": "claude", "model": "opus", "permissionMode": "acceptEdits" },
27
27
  "zcode-flash": { "harness": "zcode", "model": "glm-5.3-flash", "vendor": "zhipu-flash", "permissionMode": "edit" },
28
- "zcode-pro": { "harness": "zcode", "model": "glm-5.3", "vendor": "zhipu-pro", "permissionMode": "plan" },
29
28
  "agy-flash": { "harness": "agy", "model": "gemini-3.8-flash-low" }
30
29
  },
31
30
  "nodes": [
@@ -10,6 +10,7 @@ import { writeJsonAtomic } from "../run/store.mjs";
10
10
  import { projectIdForRunsDir, repositoryForRunsDir } from "../run/paths.mjs";
11
11
  import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
12
12
  import { promoteRun } from "../repo/integrate.mjs";
13
+ import { releaseRunWorktrees } from "../repo/worktree.mjs";
13
14
  import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
14
15
  import { readCampaign } from "./record.mjs";
15
16
  import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
@@ -130,7 +131,7 @@ export function resolveCampaign(runsDir, campaignId) {
130
131
  /**
131
132
  * @param {string} campaignPath
132
133
  * @param {{at?: string, eventId?: string}} options
133
- * @returns {{path: string, campaign: Campaign, ledgerFiles: string[], ledgerSkipped: {runId: string, source: string}[]}}
134
+ * @returns {{path: string, campaign: Campaign, ledgerFiles: string[], ledgerSkipped: {runId: string, source: string}[], worktrees: {removed: number, archived: string[]}}}
134
135
  */
135
136
  export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
136
137
  requireTimestamp(at, "at");
@@ -154,7 +155,16 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
154
155
  // Preserve after the closed record exists: the ledger is the recomputable
155
156
  // closed record, including requirements used by the north-star projector.
156
157
  const ledger = preserveCampaignLedger(campaignPath, campaignRepoRoot(campaignPath));
157
- return { path: campaignPath, campaign: closed, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped };
158
+ // A closed campaign resumes nothing, so the attempt worktrees its runs left
159
+ // go now, each archived under a ref first (releaseRunWorktrees).
160
+ const runsDir = resolve(campaignPath, "..", "..");
161
+ const worktrees = { removed: 0, archived: /** @type {string[]} */ ([]) };
162
+ for (const runId of campaign.linkedRunIds) {
163
+ const released = releaseRunWorktrees(campaignRepoRoot(campaignPath), join(runsDir, runId), runId);
164
+ worktrees.removed += released.removed;
165
+ worktrees.archived.push(...released.archived);
166
+ }
167
+ return { path: campaignPath, campaign: closed, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped, worktrees };
158
168
  }
159
169
 
160
170
  /**
@@ -51,6 +51,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
51
51
  /** @typedef {"down"|"up"|"informative"} Direction */
52
52
  /** @typedef {{value: number|null, direction: Direction, count: number, numerator?: number, denominator?: number, excludedRunIds?: string[], missingSources?: string[]}} Indicator */
53
53
  /** @typedef {Indicator & {unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}} CostIndicator */
54
+ /** @typedef {Indicator & {unknownCount: number}} JudgeCostShareIndicator */
54
55
  /** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
55
56
  /** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
56
57
  /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
@@ -79,6 +80,8 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
79
80
  * usageTokensByKind: GroupedIndicator,
80
81
  * usageTokensByKindByRuntime: GroupedIndicator,
81
82
  * usageCostUsd: CostIndicator,
83
+ * judgeFindingRate: Indicator,
84
+ * judgeCostShare: JudgeCostShareIndicator,
82
85
  * blockingJudgeFirstPassRate: GroupedIndicator,
83
86
  * notifyReceiptRate: Indicator,
84
87
  * silentStallRate: Indicator,
@@ -101,6 +104,10 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
101
104
  const excluded = excludedRunIdsFor(campaign, excludedRunIds);
102
105
  const eligibleNodes = nodes.filter((node) => !excluded.includes(node.runId));
103
106
  const eligibleEvents = eventList.filter((event) => typeof event.runId !== "string" || !excluded.includes(event.runId));
107
+ const eligibleUsageRecords = usageRecords.filter((record) => {
108
+ const object = jsonObjectOf(record);
109
+ return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
110
+ });
104
111
  const eligibleNotifications = notifications.filter((record) => {
105
112
  const object = jsonObjectOf(record);
106
113
  return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
@@ -110,6 +117,7 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
110
117
  const closedCheckpoints = closedCheckpointsOf(nodes);
111
118
  const span = eventSpanOf(eventList);
112
119
  const usage = usageTotalsOf(usageRecords);
120
+ const judgeYield = judgeYieldOf(eligibleEvents, eligibleUsageRecords);
113
121
  const gates = blockingJudgeFirstPassRateOf(eligibleNodes, eligibleEvents);
114
122
  const notify = notifyReceiptRateOf(eligibleNotifications);
115
123
  const stalls = silentStallRateOf(eligibleNodes, eligibleEvents);
@@ -129,6 +137,11 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
129
137
  unknownCountByReason: usage.unknownCountByReason,
130
138
  unknownFractionByReason: usage.unknownFractionByReason,
131
139
  },
140
+ judgeFindingRate: rate("up", judgeYield.findings, judgeYield.judged, excluded),
141
+ judgeCostShare: {
142
+ ...measured("down", usage.costCount, usage.costUsd === null ? null : usage.judgeCostUsd / usage.costUsd),
143
+ unknownCount: usage.unknownJudgeCount,
144
+ },
132
145
  blockingJudgeFirstPassRate: groupedRate("up", gates, excluded),
133
146
  notifyReceiptRate: rate("up", notify.satisfied, notify.count, excluded),
134
147
  silentStallRate: rate("down", stalls.stalled, stalls.activeHours, excluded, stalls.activeIntervals),
@@ -162,6 +175,8 @@ function applyMissingSources(metrics, missingSources) {
162
175
  usageTokensByKind: withMissing(metrics.usageTokensByKind, ["usage.jsonl"]),
163
176
  usageTokensByKindByRuntime: withMissing(metrics.usageTokensByKindByRuntime, ["usage.jsonl"]),
164
177
  usageCostUsd: withMissing(metrics.usageCostUsd, ["usage.jsonl"]),
178
+ judgeFindingRate: withMissing(metrics.judgeFindingRate, ["events.jsonl", "usage.jsonl"]),
179
+ judgeCostShare: withMissing(metrics.judgeCostShare, ["usage.jsonl"]),
165
180
  blockingJudgeFirstPassRate: withMissing(metrics.blockingJudgeFirstPassRate, ["nodes.json", "events.jsonl"]),
166
181
  notifyReceiptRate: withMissing(metrics.notifyReceiptRate, ["notify.jsonl"]),
167
182
  silentStallRate: withMissing(metrics.silentStallRate, ["nodes.json", "events.jsonl"]),
@@ -397,7 +412,7 @@ function eventSpanOf(events) {
397
412
  * invocation is counted separately rather than folded into a measured zero.
398
413
  *
399
414
  * @param {unknown[]} usageRecords
400
- * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, costCount: number, unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
415
+ * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, judgeCostUsd: number, costCount: number, unknownCount: number, unknownJudgeCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
401
416
  */
402
417
  function usageTotalsOf(usageRecords) {
403
418
  const tokensByKind = { inputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0 };
@@ -405,8 +420,10 @@ function usageTotalsOf(usageRecords) {
405
420
  const tokensByKindByRuntime = {};
406
421
  let tokenCount = 0;
407
422
  let costUsd = 0;
423
+ let judgeCostUsd = 0;
408
424
  let costCount = 0;
409
425
  let unknownCount = 0;
426
+ let unknownJudgeCount = 0;
410
427
  /** @type {Record<string, number>} */
411
428
  const unknownCountByReason = {};
412
429
  const kinds = /** @type {("inputTokens"|"cacheReadInputTokens"|"outputTokens")[]} */ (["inputTokens", "cacheReadInputTokens", "outputTokens"]);
@@ -425,9 +442,11 @@ function usageTotalsOf(usageRecords) {
425
442
  if (measuredAny) tokenCount += 1;
426
443
  if (typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance !== UNKNOWN_COST_PROVENANCE) {
427
444
  costUsd += record.costUsd;
445
+ if (record.role === "judge") judgeCostUsd += record.costUsd;
428
446
  costCount += 1;
429
447
  } else {
430
448
  unknownCount += 1;
449
+ if (record.role === "judge") unknownJudgeCount += 1;
431
450
  const reason = typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
432
451
  ? record.unknownReason
433
452
  : "legacy";
@@ -443,13 +462,51 @@ function usageTotalsOf(usageRecords) {
443
462
  tokensByKindByRuntime,
444
463
  tokenCount,
445
464
  costUsd: costCount === 0 ? null : costUsd,
465
+ judgeCostUsd,
446
466
  costCount,
447
467
  unknownCount,
468
+ unknownJudgeCount,
448
469
  unknownCountByReason,
449
470
  unknownFractionByReason,
450
471
  };
451
472
  }
452
473
 
474
+ /**
475
+ * Count logical nodes that reached a judge and those whose judge recorded at
476
+ * least one finding. A failed judge verdict is the durable ledger projection
477
+ * of a non-empty findings array; a complete-phase fail is included for
478
+ * advisory reviews that settle the node after reporting their finding.
479
+ *
480
+ * @param {JsonObject[]} events
481
+ * @param {unknown[]} usageRecords
482
+ * @returns {{judged: number, findings: number}}
483
+ */
484
+ function judgeYieldOf(events, usageRecords) {
485
+ const judged = new Set();
486
+ const findings = new Set();
487
+ for (const raw of usageRecords) {
488
+ const record = jsonObjectOf(raw);
489
+ if (record === null || record.role !== "judge" || typeof record.nodeId !== "string") continue;
490
+ judged.add(logicalNodeKey(record.runId, record.nodeId));
491
+ }
492
+ for (const event of events) {
493
+ if (typeof event.node !== "string") continue;
494
+ const key = logicalNodeKey(event.runId, event.node);
495
+ if (event.phase === "judge") judged.add(key);
496
+ const hasFindingArray = Array.isArray(event.findings) && event.findings.length > 0;
497
+ if ((event.phase === "judge" || event.phase === "complete") && (event.verdict === "fail" || hasFindingArray)) {
498
+ judged.add(key);
499
+ findings.add(key);
500
+ }
501
+ }
502
+ return { judged: judged.size, findings: findings.size };
503
+ }
504
+
505
+ /** @param {unknown} runId @param {string} nodeId @returns {string} */
506
+ function logicalNodeKey(runId, nodeId) {
507
+ return `${typeof runId === "string" ? runId : ""}:${nodeId}`;
508
+ }
509
+
453
510
  /**
454
511
  * Fraction of blocking-gated checkpoints whose first recorded verdict passed,
455
512
  * per lane (the judge runtime that produced it). A node under `advisory` or
@@ -320,6 +320,7 @@ function close(campaignId, values) {
320
320
  process.stdout.write(`[campaign] ledger skipped · ${skipped.runId}/${skipped.source}\n`);
321
321
  }
322
322
  reportRequirementClosure(closed.campaign.requirements ?? []);
323
+ if (closed.worktrees.removed > 0) process.stdout.write(`[campaign] worktrees released · ${closed.worktrees.removed} · archived under refs/faberun-archive/\n`);
323
324
  if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
324
325
  }
325
326
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { dirname, join, resolve } from "node:path";
9
+ import { parseArgs as parseFlags } from "node:util";
9
10
  import { loadPersistedContract, validateContract } from "../contract/index.mjs";
10
11
 
11
12
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -13,7 +14,7 @@ import { loadPersistedContract, validateContract } from "../contract/index.mjs";
13
14
  /** Flags are scoped to the operation that declares them; all others are rejected. */
14
15
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
15
16
  const OPERATION_OPTIONS = {
16
- validate: {},
17
+ validate: { "strict-traceability": { type: "boolean" } },
17
18
  };
18
19
 
19
20
  /**
@@ -23,9 +24,17 @@ const OPERATION_OPTIONS = {
23
24
  export function contractCli(args) {
24
25
  const operation = args[0];
25
26
  if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
26
- const target = args[1];
27
- if (!target || args.length > 2) return usage();
28
- if (operation === "validate") validateContractFile(resolve(target));
27
+ let parsed;
28
+ try {
29
+ parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
30
+ } catch (error) {
31
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
32
+ return usage();
33
+ }
34
+ const target = parsed.positionals[0];
35
+ if (!target || parsed.positionals.length > 1) return usage();
36
+ const values = /** @type {{"strict-traceability"?: boolean}} */ (parsed.values);
37
+ if (operation === "validate") validateContractFile(resolve(target), { strictTraceability: values["strict-traceability"] === true });
29
38
  }
30
39
 
31
40
  /**
@@ -38,19 +47,29 @@ export function contractCli(args) {
38
47
  * authoring validation, tree reads included.
39
48
  *
40
49
  * @param {string} path
50
+ * @param {{strictTraceability?: boolean}} [options]
41
51
  * @returns {ValidatedContract}
42
52
  */
43
- export function validateContractFile(path) {
53
+ export function validateContractFile(path, { strictTraceability = false } = {}) {
44
54
  const runJsonPath = join(dirname(path), "run.json");
45
55
  const contract = existsSync(runJsonPath)
46
56
  ? loadPersistedContract(path, readRunDigest(runJsonPath))
47
57
  : validateContract(JSON.parse(readFileSync(path, "utf8")), path);
48
58
  const count = contract.warnings.length;
49
- process.stdout.write(`valid${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
59
+ const blockingTraceability = strictTraceability
60
+ ? contract.warnings.filter((warning) => isTraceabilityFinding(warning))
61
+ : [];
62
+ process.stdout.write(`${blockingTraceability.length > 0 ? "invalid" : "valid"}${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
50
63
  for (const warning of contract.warnings) process.stdout.write(`[warn] ${warning}\n`);
64
+ if (blockingTraceability.length > 0) process.exitCode = 1;
51
65
  return contract;
52
66
  }
53
67
 
68
+ /** @param {string} warning @returns {boolean} */
69
+ function isTraceabilityFinding(warning) {
70
+ return warning.includes("judgment_without_reason") || warning.includes("judgment_beside_mechanical_proof");
71
+ }
72
+
54
73
  /**
55
74
  * @param {string} runJsonPath
56
75
  * @returns {string|undefined}
@@ -62,7 +81,7 @@ function readRunDigest(runJsonPath) {
62
81
 
63
82
  /** @returns {void} */
64
83
  function usage() {
65
- process.stderr.write("usage: faberun contract validate <contract.json>\n");
84
+ process.stderr.write("usage: faberun contract validate <contract.json> [--strict-traceability]\n");
66
85
  process.exitCode = 2;
67
86
  }
68
87
 
@@ -9,7 +9,7 @@ import { rejectUnknown, requireId, requireString } from "./assert.mjs";
9
9
 
10
10
  /** @typedef {{kind: "command"|"path"|"verification", ref: string}} DefinitionOfDoneProof */
11
11
 
12
- /** @typedef {{id: string, text: string, proof?: DefinitionOfDoneProof, judgment?: true}} DefinitionOfDoneItem */
12
+ /** @typedef {{id: string, text: string, proof?: DefinitionOfDoneProof, judgment?: true, reason?: string}} DefinitionOfDoneItem */
13
13
 
14
14
  /**
15
15
  * @param {unknown} value
@@ -28,12 +28,16 @@ export function validateDefinitionOfDone(value, label, options = {}) {
28
28
  throw new TypeError(`${itemLabel} must be an object with id, text, and proof or judgment: true`);
29
29
  }
30
30
  const record = /** @type {Record<string, unknown>} */ (item);
31
- rejectUnknown(record, new Set(["id", "text", "proof", "judgment"]), itemLabel);
31
+ rejectUnknown(record, new Set(["id", "text", "proof", "judgment", "reason"]), itemLabel);
32
32
  requireId(record.id, `${itemLabel}.id`);
33
33
  requireString(record.text, `${itemLabel}.text`);
34
34
  if (record.judgment !== undefined && record.judgment !== true) {
35
35
  throw new TypeError(`${itemLabel}.judgment must be true when present`);
36
36
  }
37
+ if (record.reason !== undefined) {
38
+ if (record.judgment !== true) throw new TypeError(`${itemLabel}.reason requires judgment: true`);
39
+ requireString(record.reason, `${itemLabel}.reason`);
40
+ }
37
41
  const proof = record.proof === undefined
38
42
  ? undefined
39
43
  : validateProof(record.proof, `${itemLabel}.proof`, options);
@@ -45,10 +49,34 @@ export function validateDefinitionOfDone(value, label, options = {}) {
45
49
  text: /** @type {string} */ (record.text),
46
50
  ...(proof === undefined ? {} : { proof }),
47
51
  ...(record.judgment === true ? { judgment: true } : {}),
52
+ ...(record.reason === undefined ? {} : { reason: /** @type {string} */ (record.reason) }),
48
53
  };
49
54
  });
50
55
  }
51
56
 
57
+ /**
58
+ * Report judgment items that do not explain what mechanical verification cannot
59
+ * observe. These remain advisory because `contract validate` has no strict
60
+ * traceability flag; a later strict contract command can promote these codes.
61
+ *
62
+ * @param {DefinitionOfDoneItem[]} items
63
+ * @param {number} index
64
+ * @returns {string[]}
65
+ */
66
+ export function judgmentReasonWarnings(items, index) {
67
+ const judgmentItems = items.filter((item) => item.judgment === true);
68
+ const warnings = items.flatMap((item, itemIndex) => item.judgment === true && item.reason === undefined
69
+ ? [`nodes[${index}] (definitionOfDone[${itemIndex}]): judgment_without_reason: judgment item must say what no command verifies`]
70
+ : []);
71
+ const hasMechanicalProof = items.some((item) => item.proof !== undefined);
72
+ if (judgmentItems.length > 0 && hasMechanicalProof && judgmentItems.every((item) => item.reason === undefined)) {
73
+ warnings.push(
74
+ `nodes[${index}]: judgment_beside_mechanical_proof: every judgment item shares this node with mechanical-proof items but declares no reason`,
75
+ );
76
+ }
77
+ return warnings;
78
+ }
79
+
52
80
  /**
53
81
  * @param {unknown} value
54
82
  * @param {string} label
@@ -144,4 +172,3 @@ export function unquotedFilterValueWarnings(items, index) {
144
172
 
145
173
  /** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
146
174
  const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
147
-
@@ -3,7 +3,7 @@ import { readFileSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
5
5
  import { RESERVED_ARTICLES } from "./articles.mjs";
6
- import { unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
6
+ import { judgmentReasonWarnings, unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
7
7
  import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
8
8
  import { VERIFICATION_LIMITS, requirementProofWarnings } from "./verification.mjs";
9
9
  import {
@@ -378,6 +378,7 @@ export function validateContract(raw, contractPath, options = {}) {
378
378
  ...nodes.flatMap((node, index) => [
379
379
  ...commandCoverageWarnings(node, index),
380
380
  ...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
381
+ ...judgmentReasonWarnings(node.definitionOfDone ?? [], index),
381
382
  ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
382
383
  ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
383
384
  ...(persisted ? [] : ignoreSourceWriteWarnings(node, index)),
@@ -33,6 +33,8 @@ import {
33
33
  upsertTierExhaustionCandidate,
34
34
  } from "./backoff.mjs";
35
35
  import { exhaustedUntilOf } from "./runtime-discovery.mjs";
36
+ import { getHarness, normalizeProviderAvailability } from "../harnesses/index.mjs";
37
+ import { availabilityKey, recordRefusal, refusalOfAvailability } from "../run/availability.mjs";
36
38
 
37
39
  import { acquire as acquireLock } from "../run/lock.mjs";
38
40
  import { parseDiscoveryResult } from "../contract/worker-result.mjs";
@@ -635,6 +637,10 @@ export function handleProviderExhaustion(contract, runDir, node, state, role, en
635
637
  now,
636
638
  });
637
639
  const exhaustedUntil = exhaustedUntilOf(envelope);
640
+ // Every other process on this machine learns the refusal now, not from its own failed call.
641
+ const runtime = contract.runtimes?.[current];
642
+ const refusal = runtime ? refusalOfAvailability(normalizeProviderAvailability(runtime, envelope)) : null;
643
+ if (runtime && refusal) recordRefusal(availabilityKey({ harness: runtime.harness, model: runtime.model, executable: getHarness(runtime.harness).executable(runtime) }), refusal);
638
644
  const plan = planRoute(contract, node, state, role, error, current, schedule, now, exhaustedUntil);
639
645
  const status = envelope.status === "failed" ? "failed" : "exhausted";
640
646
  if (plan.blocked) {
@@ -16,7 +16,7 @@ import { emptyUsage } from "../run/usage.mjs";
16
16
  import { errorMessage } from "../util.mjs";
17
17
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
18
18
  import { join, resolve } from "node:path";
19
- import { normalizeProviderResult, probeRuntime, providerCommand } from "../harnesses/index.mjs";
19
+ import { normalizeProviderAvailability, normalizeProviderResult, probeRuntime, providerCommand } from "../harnesses/index.mjs";
20
20
  import { killTarget, spawnInvocation } from "../host/platform.mjs";
21
21
  import { reachableRuntimes } from "../host/preflight.mjs";
22
22
  import { spawn } from "node:child_process";
@@ -106,6 +106,9 @@ export async function preflightRuntimes(entries, options = {}) {
106
106
  liveStatus: live.status,
107
107
  usage: live.usage,
108
108
  costUsd: live.costUsd,
109
+ // A failed ask carries its classified cause (quota, balance, model) and
110
+ // reset instant, so a refusal can be recorded as one instead of as an answer.
111
+ ...(live.status === "done" ? {} : { availability: normalizeProviderAvailability(/** @type {any} */ (runtimes[index]), live) }),
109
112
  detail: `${check.detail ?? "static probe failed"} · ${liveDetail}`,
110
113
  };
111
114
  }));
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, harnessCapabilities, probeRuntime } from "../harnesses/index.mjs";
15
15
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
16
- import { availabilityKey, readAvailability, recordAvailability } from "../run/availability.mjs";
16
+ import { availabilityKey, readAvailability, readRefusal, recordProbeVerdicts } from "../run/availability.mjs";
17
17
  import { blockingChecks, environmentPreflight, reachableRuntimes } from "../host/preflight.mjs";
18
18
  import { captureSourceIdentity } from "../repo/source-identity.mjs";
19
19
  import { boundedGitSync } from "../repo/worktree.mjs";
@@ -569,6 +569,46 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
569
569
  if (verdict) fresh.set(id, verdict);
570
570
  }
571
571
  }
572
+ // A refusal another process met on this machine (quota, balance, model) is
573
+ // not asked again before its reset: the launch is refused on it, spending
574
+ // nothing, and the operator is told when the provider is back.
575
+ if (!pendingFreshPreflight) {
576
+ const refused = [...routed.entries()].map(([id, { runtime }]) => {
577
+ const executable = getHarness(runtime.harness).executable(runtime);
578
+ const held = readRefusal(availabilityKey({ harness: runtime.harness, model: runtime.model, executable }));
579
+ return held ? {
580
+ id,
581
+ harness: runtime.harness,
582
+ executable,
583
+ model: runtime.model,
584
+ version: sourceIdentity?.harnessVersions?.[id] ?? null,
585
+ capabilities: harnessCapabilities(runtime),
586
+ requiredCapabilities: {},
587
+ requiredCapabilitySets: [],
588
+ ok: false,
589
+ live: true,
590
+ liveStatus: "exhausted",
591
+ availability: { available: false, exhaustedUntil: held.exhaustedUntil, reason: held.reason },
592
+ detail: `live exhausted · ${held.reason}: refusal recorded on this machine at ${held.observedAt}, back at ${held.exhaustedUntil}`,
593
+ } : null;
594
+ });
595
+ if (refused.some(Boolean)) {
596
+ return [...routed.entries()].map(([id, { runtime }], index) => refused[index] ?? {
597
+ id,
598
+ harness: runtime.harness,
599
+ executable: getHarness(runtime.harness).executable(runtime),
600
+ model: runtime.model,
601
+ version: sourceIdentity?.harnessVersions?.[id] ?? null,
602
+ capabilities: harnessCapabilities(runtime),
603
+ requiredCapabilities: {},
604
+ requiredCapabilitySets: [],
605
+ ok: true,
606
+ live: false,
607
+ liveStatus: "not-asked",
608
+ detail: "not asked: another routed runtime is refused on this machine",
609
+ });
610
+ }
611
+ }
572
612
  if (routed.size > 0 && fresh.size === routed.size) {
573
613
  return [...routed.entries()].map(([id, { runtime }]) => {
574
614
  const verdict = /** @type {RuntimeAvailability} */ (fresh.get(id));
@@ -594,10 +634,8 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
594
634
  // under the provider's own identity. Silence and a command that never
595
635
  // reached a provider are verdicts of nothing and are never recorded, so the
596
636
  // operator who fixes the host is never told the fix "already answered".
597
- recordAvailability(
598
- probes
599
- .filter((probe) => probe.live === true && probe.liveStatus !== "reused" && liveVerdictRecorded(probe))
600
- .map((probe) => availabilityKey(probe)),
637
+ recordProbeVerdicts(
638
+ probes.filter((probe) => probe.live === true && probe.liveStatus !== "reused" && liveVerdictRecorded(probe)),
601
639
  Date.now(),
602
640
  );
603
641
  return probes;
@@ -51,9 +51,12 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
51
51
  tier: 1,
52
52
  costRank: 1,
53
53
  },
54
+ // The flash tier, not the unsuffixed pro: measured 2026-09-24 in the judge
55
+ // canary, glm-5.3 recalled 0.79 against glm-5.3-flash's 0.85 at about nine
56
+ // times the price, which the owner confirmed matches the public benchmarks.
54
57
  "zcode-glm": {
55
58
  harness: "zcode",
56
- model: "glm-5.3",
59
+ model: "glm-5.3-flash",
57
60
  vendor: "zhipu",
58
61
  config: { "auth_token.env_key": "ZAI_API_KEY" },
59
62
  tier: 1,
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The usage windows a Codex account reports, read from the session file the
3
+ * CLI writes for every thread. It is separate from the adapter because it is
4
+ * not part of an invocation's result: `codex exec --json` streams no limits,
5
+ * while `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*-<thread>.jsonl` carries a
6
+ * `rate_limits` record on every turn (measured 2026-09-24: `primary` is the
7
+ * 300-minute window, `secondary` the 10080-minute week, each with
8
+ * `used_percent` and `resets_at` in epoch seconds).
9
+ */
10
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ /** @typedef {{window: string, usedPercent: number, resetsAt: string}} UsageWindow */
15
+
16
+ /** @param {Record<string, string|undefined>} [env] @returns {string} the account's Codex home */
17
+ export function codexHome(env = process.env) {
18
+ return env.CODEX_HOME || join(homedir(), ".codex");
19
+ }
20
+
21
+ /**
22
+ * The account's windows as the thread's session file last reported them, or
23
+ * an empty list when the file or the record cannot be found.
24
+ *
25
+ * @param {string} threadId
26
+ * @param {{env?: Record<string, string|undefined>, now?: number}} [options]
27
+ * @returns {UsageWindow[]}
28
+ */
29
+ export function codexUsageWindows(threadId, options = {}) {
30
+ const now = options.now ?? Date.now();
31
+ const sessions = join(codexHome(options.env), "sessions");
32
+ for (const day of [0, 1]) {
33
+ const date = new Date(now - day * 86_400_000);
34
+ const dir = join(sessions, String(date.getFullYear()), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0"));
35
+ if (!existsSync(dir)) continue;
36
+ const file = readdirSync(dir).find((name) => name.endsWith(`-${threadId}.jsonl`));
37
+ if (file) return lastWindows(readFileSync(join(dir, file), "utf8"));
38
+ }
39
+ return [];
40
+ }
41
+
42
+ /** @param {string} text @returns {UsageWindow[]} */
43
+ function lastWindows(text) {
44
+ const lines = text.split("\n");
45
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
46
+ if (!lines[index].includes("\"rate_limits\"")) continue;
47
+ let limits;
48
+ try {
49
+ limits = findRateLimits(JSON.parse(lines[index]));
50
+ } catch {
51
+ // A line cut by a writer still flushing is skipped; an earlier one answers.
52
+ continue;
53
+ }
54
+ if (!limits) continue;
55
+ return [["primary", limits.primary], ["secondary", limits.secondary]]
56
+ .filter(([, entry]) => entry && typeof entry.used_percent === "number" && typeof entry.resets_at === "number")
57
+ .map(([name, entry]) => ({
58
+ window: entry.window_minutes === 10080 ? "seven_day" : entry.window_minutes === 300 ? "five_hour" : String(name),
59
+ usedPercent: entry.used_percent,
60
+ resetsAt: new Date(entry.resets_at * 1000).toISOString(),
61
+ }));
62
+ }
63
+ return [];
64
+ }
65
+
66
+ /** @param {unknown} value @returns {any} the first `rate_limits` object anywhere in a record */
67
+ function findRateLimits(value) {
68
+ if (!value || typeof value !== "object") return null;
69
+ const record = /** @type {Record<string, unknown>} */ (value);
70
+ if (record.rate_limits && typeof record.rate_limits === "object") return record.rate_limits;
71
+ for (const child of Object.values(record)) {
72
+ const found = findRateLimits(child);
73
+ if (found) return found;
74
+ }
75
+ return null;
76
+ }
@@ -208,7 +208,7 @@ function authToken(runtime) {
208
208
  * each one, `doctor` checks binaries), so a permissions or disk failure has to
209
209
  * degrade into "not found" — never into an aborted run or a crashed report.
210
210
  *
211
- * @param {{env?: Record<string, string|undefined>, pathDirs?: string[], home?: string, bundle?: {electron: string, cli: string}}} [options]
211
+ * @param {{env?: Record<string, string|undefined>, pathDirs?: string[], home?: string, bundle?: {electron: string, cli: string}, host?: {arch: string, nodeMajor: number, node: string}}} [options]
212
212
  * @returns {void}
213
213
  */
214
214
  export function ensureZcodeAvailable(options = {}) {
@@ -218,7 +218,7 @@ export function ensureZcodeAvailable(options = {}) {
218
218
  if (resolvesOnPath(pathDirs, ZCODE_BIN_NAME)) return;
219
219
  const bundle = options.bundle ?? zcodeBundle(env);
220
220
  if (!bundle || !existsSync(bundle.electron) || !existsSync(bundle.cli)) return;
221
- const body = zcodeShim(bundle);
221
+ const body = zcodeShim(bundle, options.host);
222
222
  for (const dir of shimDirs(options.home ?? homedir())) {
223
223
  if (!pathDirs.includes(dir)) continue;
224
224
  if (settleShim(join(dir, ZCODE_BIN_NAME), body)) return;
@@ -252,9 +252,23 @@ function zcodeBundle(env) {
252
252
  * outlives it.
253
253
  *
254
254
  * @param {{electron: string, cli: string}} bundle
255
+ * @param {{arch: string, nodeMajor: number, node: string}} [host]
255
256
  * @returns {string}
256
257
  */
257
- function zcodeShim(bundle) {
258
+ function zcodeShim(bundle, host = { arch: process.arch, nodeMajor: Number(process.versions.node.split(".")[0]), node: process.execPath }) {
259
+ // Measured 2026-09-24 on darwin arm64 with node 26.8.1: the bundled CLI
260
+ // answers a 3.2 KB response intact under the system node, and every process
261
+ // it spawns under the app's Electron shows an icon in the macOS Dock. The
262
+ // Electron path stays for every other host, where the detached-ArrayBuffer
263
+ // failure (node 24 x64) is the only measurement there is.
264
+ if (host.arch === "arm64" && host.nodeMajor >= 26) {
265
+ return `#!/usr/bin/env bash
266
+ set -euo pipefail
267
+ # Written by the faberun zcode harness; the ZCode app owns the CLI path.
268
+ exec ${host.node} \\
269
+ ${bundle.cli} "$@"
270
+ `;
271
+ }
258
272
  return `#!/usr/bin/env bash
259
273
  set -euo pipefail
260
274
  # Written by the faberun zcode harness; the ZCode app owns both paths.
@@ -43,7 +43,8 @@ import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
43
43
  import { findExecutable } from "./platform.mjs";
44
44
  import { colorLevel, statusToken } from "../cli/brand.mjs";
45
45
  import { RUNS_DIR_NAME } from "../run/paths.mjs";
46
- import { availabilityKey, readAvailability, recordAvailability } from "../run/availability.mjs";
46
+ import { availabilityKey, readAvailability, recordProbeVerdicts } from "../run/availability.mjs";
47
+ import { readUsageWindows, usageAccountOf } from "../run/usage-windows.mjs";
47
48
 
48
49
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
49
50
  /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
@@ -222,6 +223,35 @@ export function checkRuntimeBinaries(runtimes, harnessVersions, cwd = ".") {
222
223
  return pass("runtime binaries", resolved.length ? `${resolved.join(" · ")}${aside}` : "no routed runtime");
223
224
  }
224
225
 
226
+ /**
227
+ * How much of each routed account's provider-reported window is spent. A
228
+ * window at 100% blocks the dispatch until it resets, because every call would
229
+ * be refused; one at or above 90% is an advisory warning with the reset
230
+ * instant, so the operator can ration before the wall. The 90% line is a first
231
+ * guess, not a measurement. No check is added when no routed runtime spends
232
+ * from an account that reports windows.
233
+ *
234
+ * @param {ReachableRuntimes} runtimes
235
+ * @param {NodeJS.ProcessEnv} env
236
+ * @param {number} [now]
237
+ * @returns {EnvCheck[]}
238
+ */
239
+ export function checkUsageWindows(runtimes, env, now = Date.now()) {
240
+ const accounts = new Set([...runtimes.values()].filter((entry) => entry.routed).map((entry) => usageAccountOf(entry.runtime, env)).filter((account) => account !== null));
241
+ /** @type {EnvCheck[]} */
242
+ const checks = [];
243
+ for (const account of accounts) {
244
+ const windows = readUsageWindows(/** @type {string} */ (account), now);
245
+ if (windows.length === 0) continue;
246
+ const worst = windows.reduce((a, b) => (b.usedPercent > a.usedPercent ? b : a));
247
+ const detail = `${account.split(":")[0]} ${windows.map((window) => `${window.window} ${window.usedPercent}% used, resets ${window.resetsAt}`).join(" · ")}`;
248
+ if (worst.usedPercent >= 100) checks.push(fail("usage window", `${detail}: every call would be refused until ${worst.resetsAt}`));
249
+ else if (worst.usedPercent >= 90) checks.push(fail("usage window", detail, true));
250
+ else checks.push(pass("usage window", detail));
251
+ }
252
+ return checks;
253
+ }
254
+
225
255
  /**
226
256
  * @param {{cwd: string, runtimes: ReachableRuntimes, harnessVersions?: Record<string, string|null>, env?: NodeJS.ProcessEnv}} options
227
257
  * @returns {EnvReport}
@@ -234,6 +264,7 @@ export function environmentPreflight(options) {
234
264
  checkGit(cwd),
235
265
  checkWorktree(cwd, env.FABERUN_REQUIRE_CLEAN_WORKTREE === "1"),
236
266
  checkRuntimeBinaries(options.runtimes, options.harnessVersions ?? {}, cwd),
267
+ ...checkUsageWindows(options.runtimes, env),
237
268
  ];
238
269
  return { schemaVersion: ENV_PREFLIGHT_SCHEMA_VERSION, ok: checks.every((check) => check.ok || check.advisory), checks };
239
270
  }
@@ -517,7 +548,7 @@ async function liveAvailabilityChecks(contractPath, runtimes) {
517
548
  const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
518
549
  const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
519
550
  const probes = await preflightContract(contractPath, { liveTimeoutSec: timeoutSec });
520
- recordAvailability(probes.filter((probe) => liveVerdict(probe).recorded).map((probe) => availabilityKey(probe)));
551
+ recordProbeVerdicts(probes.filter((probe) => liveVerdict(probe).recorded));
521
552
  return probes.map((probe) => {
522
553
  const verdict = liveVerdict(probe);
523
554
  return {
@@ -24,7 +24,7 @@ import { campaignCli } from "../cli/campaign.mjs";
24
24
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
25
25
  import { stableJson } from "../util.mjs";
26
26
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
27
- import { askPlanningRuntimes, refusePlanningSilence } from "./preflight.mjs";
27
+ import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
28
28
  import { parseSpec, validateSpec } from "./spec.mjs";
29
29
  import { collectRepoFacts } from "./repo-facts.mjs";
30
30
  import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validateFindings, validatePlanOutput } from "./template.mjs";
@@ -111,6 +111,7 @@ export async function runPlanningPipeline(options) {
111
111
  const campaignPath = campaignTree(cwd, campaignId);
112
112
  const campaign = readCampaign(campaignPath);
113
113
  if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
114
+ refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode);
114
115
  refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
115
116
 
116
117
  const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
@@ -9,7 +9,7 @@
9
9
  import { preflightRuntimes } from "../engine/live-preflight.mjs";
10
10
  import { liveSilenceCause } from "../engine/live-silence.mjs";
11
11
  import { harnessCapabilities } from "../harnesses/index.mjs";
12
- import { validateRuntime } from "../contract/runtime.mjs";
12
+ import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
13
13
 
14
14
  /**
15
15
  * The runtimes a planning run will spend, asked once before its first stage.
@@ -75,3 +75,44 @@ export function refusePlanningSilence(checks, cwd) {
75
75
  { code: "env_preflight_failed" },
76
76
  );
77
77
  }
78
+
79
+ /**
80
+ * Refuse, before anything is spent, a runtime choice no frozen contract could
81
+ * carry. Both halves were measured on 2026-09-24 in `choose-the-judges` R5,
82
+ * where each surfaced only at freeze, after four rounds: a worker whose
83
+ * permission mode cannot run commands cannot carry an implementation node's
84
+ * verification (US$ 7.64 and an hour, contested), and a judge that shares the
85
+ * worker's vendor, or the vendor of any runtime its fallback reaches, is
86
+ * refused by the vendor rule on every frozen node (`runtime_routing_unmet` in
87
+ * all four rounds). The pipeline's defaults name both roles, so both are
88
+ * decidable here.
89
+ *
90
+ * @param {Record<string, Record<string, unknown>>} runtimes
91
+ * @param {{worker?: string, judge?: string}} runtimeDefaults
92
+ * @param {"implementation"|"exploratory"} packageMode
93
+ * @returns {void}
94
+ */
95
+ export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode) {
96
+ const worker = runtimeDefaults.worker;
97
+ if (!worker || !runtimes[worker]) return;
98
+ if (packageMode === "implementation") {
99
+ try {
100
+ assertRuntimeExecutesCommands(/** @type {any} */ (runtimes), worker, 0, "every implementation node", "worker runtime");
101
+ } catch (error) {
102
+ throw new Error(`--runtime-defaults worker=${worker} cannot run the verification an implementation plan carries: ${error instanceof Error ? error.message : String(error)}`);
103
+ }
104
+ }
105
+ const judge = runtimeDefaults.judge;
106
+ if (!judge || !runtimes[judge]) return;
107
+ /** @type {Set<string>} */
108
+ const vendors = new Set();
109
+ const seen = new Set();
110
+ for (let id = /** @type {string|undefined} */ (worker); id && runtimes[id] && !seen.has(id); id = /** @type {string|undefined} */ (runtimes[id].fallback)) {
111
+ seen.add(id);
112
+ if (typeof runtimes[id].vendor === "string") vendors.add(/** @type {string} */ (runtimes[id].vendor));
113
+ }
114
+ const judgeVendor = runtimes[judge].vendor;
115
+ if (typeof judgeVendor === "string" && vendors.has(judgeVendor)) {
116
+ throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with worker ${worker} or its fallback, so no frozen node could route its judge; name a judge of another vendor`);
117
+ }
118
+ }
@@ -25,6 +25,13 @@ import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
25
25
  /** @typedef {{requirementId: string|null, kind: "command"|"path", ref: string, pass: boolean, detail: string}} RequirementProofResult */
26
26
 
27
27
  export const MEASURE_TIMEOUT_MS = 30_000;
28
+ /**
29
+ * A proof is a test run, not a fact probe. Measured 2026-09-24: R6 of
30
+ * `evals-with-a-budget` takes 98 s pinned to its file, and under the 30 s fact
31
+ * bound the runner died on SIGTERM with exit 1, read as a failing proof. Ten
32
+ * minutes has no measurement behind it beyond covering the slowest proof seen.
33
+ */
34
+ const PROOF_TIMEOUT_MS = 600_000;
28
35
  export const MEASURE_OUTPUT_CAP_BYTES = 4096;
29
36
 
30
37
  /** How much of a failing proof's own output a finding carries: enough to name the failure, never the whole log. */
@@ -82,9 +89,10 @@ export function runShellCapture(cwd, command, probes = {}) {
82
89
  * @param {string} cwd
83
90
  * @param {string} command
84
91
  * @param {MeasureProbes} [probes]
85
- * @returns {{output: string, exitCode: number|null, truncated: boolean}}
92
+ * @param {number} [timeoutMs]
93
+ * @returns {{output: string, exitCode: number|null, truncated: boolean, timedOut: boolean}}
86
94
  */
87
- function runShellFull(cwd, command, probes = {}) {
95
+ function runShellFull(cwd, command, probes = {}, timeoutMs = MEASURE_TIMEOUT_MS) {
88
96
  const run = probes.run ?? spawnSync;
89
97
  const env = { ...process.env };
90
98
  for (const key of MEASURE_SIDE_EFFECT_ENV_KEYS) delete env[key];
@@ -92,9 +100,10 @@ function runShellFull(cwd, command, probes = {}) {
92
100
  // child and prints no result at all (measured 2026-09-21, AGENTS.md), so a
93
101
  // proof run from inside a test runner would have nothing to show.
94
102
  delete env.NODE_TEST_CONTEXT;
95
- const result = run(command, { shell: true, cwd, timeout: MEASURE_TIMEOUT_MS, encoding: "utf8", env });
103
+ const result = run(command, { shell: true, cwd, timeout: timeoutMs, encoding: "utf8", env });
96
104
  const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`;
97
- return { output: combined, exitCode: result.status, truncated: Buffer.byteLength(combined, "utf8") > MEASURE_OUTPUT_CAP_BYTES };
105
+ const timedOut = /** @type {NodeJS.ErrnoException|undefined} */ (result.error)?.code === "ETIMEDOUT";
106
+ return { output: combined, exitCode: result.status, truncated: Buffer.byteLength(combined, "utf8") > MEASURE_OUTPUT_CAP_BYTES, timedOut };
98
107
  }
99
108
 
100
109
  /** `--test-name-pattern` in a shell command, quoted or bare, `=` or space. */
@@ -146,9 +155,11 @@ export function proveRequirements(cwd, requirements, probes = {}) {
146
155
  const proof = requirement.proof;
147
156
  if (!proof?.ref) continue;
148
157
  if (proof.kind === "command") {
149
- const { output, exitCode } = runShellFull(cwd, proof.ref, probes);
158
+ const { output, exitCode, timedOut } = runShellFull(cwd, proof.ref, probes, PROOF_TIMEOUT_MS);
150
159
  const matched = exitCode === 0 ? patternMatchedATest(proof.ref, output) : null;
151
- const detail = exitCode !== 0
160
+ const detail = timedOut
161
+ ? `timed out after ${PROOF_TIMEOUT_MS / 1000} s`
162
+ : exitCode !== 0
152
163
  ? `exit ${exitCode ?? "no exit code (killed or never started)"}${output.trim() ? `: ${output.trim().slice(0, PROOF_DETAIL_CAP)}` : ""}`
153
164
  : matched === false ? "exit 0, but its --test-name-pattern matched no test: the proof ran nothing" : "exit 0";
154
165
  results.push({ requirementId: requirement.id, kind: "command", ref: proof.ref, pass: exitCode === 0 && matched !== false, detail });
@@ -111,7 +111,7 @@ const REQUIRED_INPUTS = Object.freeze({
111
111
  // run had already succeeded. The id charset is requireId's
112
112
  // (contract/assert.mjs) verbatim, because an id that is present but invalid
113
113
  // fails that same validator just as late.
114
- const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
114
+ const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
115
115
  /**
116
116
  * The size guidance every draft and revise carries. measured 2026-09-20 over
117
117
  * stored runs: median 49 provider requests per worker turn; cost per turn
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { gitArguments } from "../host/platform.mjs";
3
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { RUNS_DIR_NAME, attemptWorktreePath, candidateWorktreePath } from "../run/paths.mjs";
5
+ import { RUNS_DIR_NAME, attemptWorktreePath, candidateWorktreePath, worktreeRoot } from "../run/paths.mjs";
6
6
 
7
7
  /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
8
8
 
@@ -481,3 +481,50 @@ export function attemptWorkspace(state) {
481
481
  const path = state?.worktree?.path;
482
482
  return path && state?.worktree?.status !== "removed" && existsSync(path) ? path : null;
483
483
  }
484
+
485
+ /**
486
+ * Release every attempt worktree a finished run left behind, keeping what it
487
+ * held. Measured 2026-09-24: attempts that were never accepted (a failed or
488
+ * blocked try, the first of two, a discovery node) kept their worktree and
489
+ * branch forever, and one machine held 41 registered and 42 orphaned
490
+ * worktrees (909 MB) from campaigns long closed. Each worktree's HEAD, and its
491
+ * uncommitted delta when it has one (`git stash create`, which touches no
492
+ * file), is kept under `refs/faberun-archive/<run>/<attempt>` before the
493
+ * worktree and its branch go, so removal loses nothing git can hold.
494
+ *
495
+ * @param {string} repo
496
+ * @param {string} runDir
497
+ * @param {string} runId
498
+ * @returns {{removed: number, archived: string[]}}
499
+ */
500
+ export function releaseRunWorktrees(repo, runDir, runId) {
501
+ const root = worktreeRoot(runDir, runId);
502
+ if (!existsSync(root)) return { removed: 0, archived: [] };
503
+ /** @type {string[]} */
504
+ const archived = [];
505
+ let removed = 0;
506
+ for (const entry of readdirSync(root)) {
507
+ const path = join(root, entry);
508
+ /** @param {string[]} args @returns {string} */
509
+ const inTree = (args) => {
510
+ try {
511
+ return git(path, args);
512
+ } catch {
513
+ // An orphaned directory git no longer knows answers nothing; it is removed below all the same.
514
+ return "";
515
+ }
516
+ };
517
+ const branch = inTree(["symbolic-ref", "--quiet", "--short", "HEAD"]);
518
+ const keep = inTree(["stash", "create"]) || inTree(["rev-parse", "HEAD"]);
519
+ if (keep) {
520
+ const ref = `refs/faberun-archive/${runId}/${entry}`;
521
+ runGit(["-C", repo, "update-ref", ref, keep]);
522
+ archived.push(ref);
523
+ }
524
+ removeWorktree(repo, path);
525
+ if (branch) deleteRef(repo, `refs/heads/${branch}`);
526
+ removed += 1;
527
+ }
528
+ rmSync(root, { recursive: true, force: true });
529
+ return { removed, archived };
530
+ }
@@ -25,6 +25,16 @@ import { writeJsonAtomic } from "./store.mjs";
25
25
 
26
26
  /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
27
27
 
28
+ /**
29
+ * Answers a provider gives before doing any work that no retry inside their
30
+ * window can change. Measured 2026-09-24: a probe answered
31
+ * `quota_exhausted` was stored as "answered", the next launches reused it
32
+ * without asking, and six processes spent into two exhausted accounts.
33
+ */
34
+ const REFUSAL_REASONS = new Set(["quota_exhausted", "insufficient_balance", "model_not_supported"]);
35
+ /** How long a refusal that names no reset instant is held. A first guess, not a measurement. */
36
+ const REFUSAL_HOLD_MS = 15 * 60 * 1000;
37
+
28
38
  /** The store shape this module reads and writes; a foreign shape reads as empty. */
29
39
  const STORE_SCHEMA_VERSION = 1;
30
40
 
@@ -96,6 +106,69 @@ export function recordAvailability(keys, now = Date.now()) {
96
106
  writeJsonAtomic(path, store);
97
107
  }
98
108
 
109
+ /**
110
+ * The refusal a probe or a provider envelope's classified availability
111
+ * carries, or null when it is anything else.
112
+ *
113
+ * @param {{available?: boolean, exhaustedUntil?: string|null, reason?: string}|null|undefined} availability
114
+ * @returns {{reason: string, exhaustedUntil: string|null}|null}
115
+ */
116
+ export function refusalOfAvailability(availability) {
117
+ if (!availability || availability.available !== false || !REFUSAL_REASONS.has(String(availability.reason))) return null;
118
+ return { reason: String(availability.reason), exhaustedUntil: availability.exhaustedUntil ?? null };
119
+ }
120
+
121
+ /**
122
+ * Record that a provider refused the work, so every process on this machine
123
+ * knows until the reset: the launch gate asks again instead of reusing an old
124
+ * answer, and a class that spends one call per case stops before its next.
125
+ *
126
+ * @param {string} key
127
+ * @param {{reason: string, exhaustedUntil: string|null}} refusal
128
+ * @param {number} [now]
129
+ * @returns {void}
130
+ */
131
+ export function recordRefusal(key, refusal, now = Date.now()) {
132
+ const store = loadStore();
133
+ store.verdicts[key] = {
134
+ available: false,
135
+ exhaustedUntil: refusal.exhaustedUntil ?? new Date(now + REFUSAL_HOLD_MS).toISOString(),
136
+ reason: refusal.reason,
137
+ observedAt: new Date(now).toISOString(),
138
+ window: PREFLIGHT_WINDOW,
139
+ };
140
+ const path = availabilityPath();
141
+ mkdirSync(dirname(path), { recursive: true });
142
+ writeJsonAtomic(path, store);
143
+ }
144
+
145
+ /**
146
+ * The refusal recorded for a provider that has not yet reset, or null.
147
+ *
148
+ * @param {string} key
149
+ * @param {number} [now]
150
+ * @returns {RuntimeAvailability|null}
151
+ */
152
+ export function readRefusal(key, now = Date.now()) {
153
+ const record = loadVerdicts()[key];
154
+ if (!record || record.available !== false || !record.exhaustedUntil) return null;
155
+ return Date.parse(record.exhaustedUntil) > now ? record : null;
156
+ }
157
+
158
+ /**
159
+ * Record what a set of live probes learned: an answer as an answer, a refusal
160
+ * as a refusal until its reset.
161
+ *
162
+ * @param {{harness: string, model: string, executable: string, availability?: {available: boolean, exhaustedUntil: string|null, reason: string}}[]} probes
163
+ * @param {number} [now]
164
+ * @returns {void}
165
+ */
166
+ export function recordProbeVerdicts(probes, now = Date.now()) {
167
+ const refused = probes.filter((probe) => refusalOfAvailability(probe.availability));
168
+ recordAvailability(probes.filter((probe) => !refused.includes(probe)).map((probe) => availabilityKey(probe)), now);
169
+ for (const probe of refused) recordRefusal(availabilityKey(probe), /** @type {{reason: string, exhaustedUntil: string|null}} */ (refusalOfAvailability(probe.availability)), now);
170
+ }
171
+
99
172
  /**
100
173
  * @returns {{schemaVersion: number, verdicts: Record<string, RuntimeAvailability>}}
101
174
  */
package/src/run/paths.mjs CHANGED
@@ -147,6 +147,11 @@ export function availabilityPath() {
147
147
  return join(faberunHome(), "availability.json");
148
148
  }
149
149
 
150
+ /** @returns {string} the machine-wide record of each account's provider-reported usage windows */
151
+ export function usageWindowsPath() {
152
+ return join(faberunHome(), "usage-windows.json");
153
+ }
154
+
150
155
  /**
151
156
  * The campaigns directory beneath a runs root: `<runs>/campaigns`.
152
157
  * Composes `campaign/layout.mjs`'s `campaignsDir`, which owns the shape given
@@ -173,7 +178,7 @@ export function campaignTree(cwd, campaignId) {
173
178
  }
174
179
 
175
180
  /** @param {string} runDir @param {string} runId @returns {string} */
176
- function worktreeRoot(runDir, runId) {
181
+ export function worktreeRoot(runDir, runId) {
177
182
  return join(dirname(runDir), "worktrees", runId);
178
183
  }
179
184
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The machine-wide record of how much of each account's provider-reported
3
+ * usage window is spent. It is separate from the availability store because
4
+ * it answers a different question: availability says whether a provider
5
+ * answered, this says how close the account is to refusing. Measured
6
+ * 2026-09-24: the Codex week went from 86% to 94% in one afternoon of judge
7
+ * readings, and nothing read it until the owner looked.
8
+ */
9
+ import { mkdirSync, readFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { usageWindowsPath } from "./paths.mjs";
12
+ import { writeJsonAtomic } from "./store.mjs";
13
+ import { codexHome, codexUsageWindows } from "../harnesses/codex/usage-window.mjs";
14
+
15
+ /** @typedef {import("../harnesses/codex/usage-window.mjs").UsageWindow} UsageWindow */
16
+ /** @typedef {{windows: UsageWindow[], observedAt: string}} AccountWindows */
17
+
18
+ /**
19
+ * @param {string} account the account key, e.g. `codex:<CODEX_HOME>`
20
+ * @param {UsageWindow[]} windows
21
+ * @param {number} [now]
22
+ * @returns {void}
23
+ */
24
+ export function recordUsageWindows(account, windows, now = Date.now()) {
25
+ if (windows.length === 0) return;
26
+ const store = loadStore();
27
+ store.accounts[account] = { windows, observedAt: new Date(now).toISOString() };
28
+ const path = usageWindowsPath();
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeJsonAtomic(path, store);
31
+ }
32
+
33
+ /**
34
+ * The account's windows that have not reset yet, or an empty list.
35
+ *
36
+ * @param {string} account
37
+ * @param {number} [now]
38
+ * @returns {UsageWindow[]}
39
+ */
40
+ export function readUsageWindows(account, now = Date.now()) {
41
+ const record = loadStore().accounts[account];
42
+ if (!record || !Array.isArray(record.windows)) return [];
43
+ return record.windows.filter((window) => typeof window.usedPercent === "number" && Date.parse(window.resetsAt) > now);
44
+ }
45
+
46
+ /** @returns {{schemaVersion: number, accounts: Record<string, AccountWindows>}} */
47
+ function loadStore() {
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(usageWindowsPath(), "utf8"));
50
+ if (parsed && parsed.schemaVersion === 1 && parsed.accounts && typeof parsed.accounts === "object") return parsed;
51
+ } catch {
52
+ // ENOENT on a machine that never recorded a window, or a store a truncated
53
+ // write left unreadable: either way nothing is known, which reads as no warning.
54
+ }
55
+ return { schemaVersion: 1, accounts: {} };
56
+ }
57
+
58
+ /**
59
+ * The account a runtime spends from, when its provider reports windows; null
60
+ * for every harness that reports none (only Codex is measured to).
61
+ *
62
+ * @param {{harness: string}} runtime
63
+ * @param {Record<string, string|undefined>} [env]
64
+ * @returns {string|null}
65
+ */
66
+ export function usageAccountOf(runtime, env = process.env) {
67
+ return runtime.harness === "codex" ? `codex:${codexHome(env)}` : null;
68
+ }
69
+
70
+ /**
71
+ * Record what a finished invocation's provider reported about its account.
72
+ *
73
+ * @param {{harness: string}} runtime
74
+ * @param {string|null|undefined} continuationId the provider's thread id
75
+ * @param {{env?: Record<string, string|undefined>, now?: number}} [options]
76
+ * @returns {void}
77
+ */
78
+ export function recordInvocationWindows(runtime, continuationId, options = {}) {
79
+ const account = usageAccountOf(runtime, options.env);
80
+ if (!account || !continuationId) return;
81
+ recordUsageWindows(account, codexUsageWindows(continuationId, options), options.now);
82
+ }
package/src/run/usage.mjs CHANGED
@@ -16,6 +16,7 @@ import { priceUsage } from "../engine/process.mjs";
16
16
  import { readBoundedTail, sessionLedger } from "../engine/transcript.mjs";
17
17
  import { writeNode } from "../engine/state.mjs";
18
18
  import { harnessCapabilities, normalizeProviderResult } from "../harnesses/index.mjs";
19
+ import { recordInvocationWindows } from "./usage-windows.mjs";
19
20
 
20
21
  // `priceUsage` is defined beside `invocationResult`, the second source point,
21
22
  // and re-exported here so the ledger's public surface is unchanged. This module
@@ -88,6 +89,12 @@ export function recordInvocationUsage(job, options = {}) {
88
89
  // Price only after the backfill has run: the counters this function persists
89
90
  // and returns are the ones the price is derived from, and the envelope becomes
90
91
  // the single priced object every later copy spreads from.
92
+ try {
93
+ recordInvocationWindows(job.runtime, envelope.continuationId);
94
+ } catch {
95
+ // The window record is telemetry about the account, never a reason to fail
96
+ // the invocation it rode on: a missing or unreadable session file means no reading.
97
+ }
91
98
  const priced = priceUsage(job.runtime, envelope.usage, envelope.costUsd);
92
99
  envelope = { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
93
100
  // The per-request ledger is read here, with the usage, for the same reason