faberun 0.8.0 → 0.9.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.8.0",
3
+ "version": "0.9.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": {
@@ -11,6 +11,7 @@ import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WAT
11
11
  import { boundedText, collapseLines } from "../util.mjs";
12
12
  import { campaignIdOf } from "./record.mjs";
13
13
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { dirname, isAbsolute, join } from "node:path";
15
16
  import { requireText, requireTimestamp } from "../contract/assert.mjs";
16
17
  import { writeJsonAtomic } from "../run/store.mjs";
@@ -34,6 +35,7 @@ const JOURNAL_TYPES = new Set([
34
35
  "question.resolved",
35
36
  "retrospective",
36
37
  "liveness",
38
+ "seat.allowance",
37
39
  ]);
38
40
  const SESSION_REQUIRED_TYPES = new Set([
39
41
  "session.attached",
@@ -63,7 +65,9 @@ const ENTRY_SHAPES = {
63
65
  "question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
64
66
  retrospective: ["at", "type", "eventId", "sessionId", "text"],
65
67
  liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
68
+ "seat.allowance": ["at", "type", "eventId", "sample", "harness", "remaining", "limit", "resetsAt", "delta", "window"],
66
69
  };
70
+ const SEAT_ALLOWANCE_SAMPLES = new Set(["start", "freeze"]);
67
71
  /**
68
72
  * Fields a liveness fact carried before the budget ceiling was removed. A
69
73
  * historical journal (like the live campaign's own) still has lines shaped
@@ -272,6 +276,7 @@ export function validateJournalEntry(entry) {
272
276
  // rejects an unexpected key; no deeper shape validation is needed for a type
273
277
  // nothing produces.
274
278
  if (type === "liveness") return;
279
+ if (type === "seat.allowance") return validateSeatAllowanceEntry(record);
275
280
  if (type === "session.attached") return validateSessionEntry(record);
276
281
  if (type === "run.registered") {
277
282
  requireText(record.runId, "entry.runId");
@@ -293,6 +298,49 @@ export function validateJournalEntry(entry) {
293
298
  }
294
299
  if (type === "outcome" && record.runId !== undefined) requireText(record.runId, "entry.runId");
295
300
  }
301
+ /**
302
+ * @param {JsonObject} entry
303
+ */
304
+ function validateSeatAllowanceEntry(entry) {
305
+ if (!SEAT_ALLOWANCE_SAMPLES.has(/** @type {string} */ (entry.sample))) {
306
+ throw new TypeError("seat.allowance sample must be start or freeze");
307
+ }
308
+ if (entry.harness !== null && typeof entry.harness !== "string") {
309
+ throw new TypeError("seat.allowance harness must be null or a string");
310
+ }
311
+ for (const field of ["remaining", "limit", "delta"]) {
312
+ if (entry[field] !== null && typeof entry[field] !== "number") {
313
+ throw new TypeError(`seat.allowance ${field} must be null or a number`);
314
+ }
315
+ }
316
+ if (entry.resetsAt !== null && typeof entry.resetsAt !== "string") {
317
+ throw new TypeError("seat.allowance resetsAt must be null or a string");
318
+ }
319
+ // Optional: a historical entry, and every call site not yet updated to
320
+ // sample it, carries no window at all.
321
+ if (entry.window !== undefined && entry.window !== null && typeof entry.window !== "string") {
322
+ throw new TypeError("seat.allowance window must be null or a string");
323
+ }
324
+ }
325
+ /**
326
+ * The one writer of `seat.allowance`: `campaign init` calls it for the
327
+ * `start` sample, `plan freeze` for the `freeze` sample. Kept as a single
328
+ * function, rather than two call sites building the literal themselves, so
329
+ * the field-ownership ratchet does not grow by one for every field this event
330
+ * carries.
331
+ *
332
+ * @param {string} campaignPath
333
+ * @param {{sample: "start"|"freeze", harness: string|null, remaining: number|null, limit: number|null, resetsAt: string|null, delta: number|null, window?: string|null}} allowance
334
+ * @returns {{entry: JournalEntry, deduplicated: boolean}}
335
+ */
336
+ export function appendSeatAllowanceEvent(campaignPath, allowance) {
337
+ return appendJournal(campaignPath, {
338
+ type: "seat.allowance",
339
+ eventId: randomUUID(),
340
+ at: new Date().toISOString(),
341
+ ...allowance,
342
+ });
343
+ }
296
344
  /**
297
345
  * @param {JsonObject} entry
298
346
  */
package/src/cli/brand.mjs CHANGED
@@ -197,6 +197,7 @@ export function renderUsage() {
197
197
  "contract validate <contract.json>",
198
198
  "spec validate <file> [--strict-traceability] [--json]",
199
199
  "spec scaffold <path> [--id <id>]",
200
+ "plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--detach] [--json]",
200
201
  "metrics <campaign-id> [--cwd <dir>] [--json]",
201
202
  "campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
202
203
  "seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
@@ -12,12 +12,14 @@ import {
12
12
  } from "../campaign/index.mjs";
13
13
  import { lockStale, pidAlive, processStartToken, readLock } from "../run/lock.mjs";
14
14
  import { syncAgentSignal } from "../repo/signal.mjs";
15
- import { acknowledgeJournalEvent, appendJournal, readJournal, watchJournal } from "../campaign/journal.mjs";
15
+ import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
16
16
  import { driveCampaignChain } from "../campaign/chain.mjs";
17
17
  import { unparkCampaign } from "../campaign/unpark.mjs";
18
18
  import { readCampaign } from "../campaign/record.mjs";
19
19
  import { notifyQueueFor } from "../engine/notify-queue.mjs";
20
20
  import { appendInbox, readInbox, wakeCapabilityNotice } from "../notify/index.mjs";
21
+ import { allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
22
+ import { detectOperatorHarness } from "../seat/harnesses.mjs";
21
23
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
22
24
  import { errorCode, readJsonTolerant } from "../util.mjs";
23
25
 
@@ -326,12 +328,23 @@ function watchLockStale(occupant) {
326
328
  * @param {string} campaignId
327
329
  * @param {CliValues} values
328
330
  */
329
- function init(campaignId, values) {
331
+ async function init(campaignId, values) {
330
332
  const cwd = resolve(values.cwd ?? ".");
331
333
  const runsDir = join(cwd, ".runs");
332
334
  const goal = textValue(values.goal, "--goal");
333
335
  const contracts = contractManifest(values.contract);
334
336
  const created = initializeCampaign(runsDir, { campaignId, goal, contracts, landBranch: values.landBranch });
337
+ // The operator's own seat: whichever harness this CLI is running inside
338
+ // (env-marker detection, see seat/harnesses.mjs), the only harness whose
339
+ // allowance is meaningful at a point before any node runtime exists.
340
+ const harness = detectOperatorHarness();
341
+ const allowance = await sampleAllowance({ harness });
342
+ appendSeatAllowanceEvent(created.path, {
343
+ sample: "start",
344
+ harness,
345
+ delta: null,
346
+ ...allowanceEventFields(allowance),
347
+ });
335
348
  renderHandoff(created.path, runsDir);
336
349
  process.stdout.write(`[campaign] ${campaignId} initialized · ${created.path} · landBranch ${created.campaign.landBranch} · ${created.campaign.contracts.length} contract(s)\n`);
337
350
  if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `plan` argv: run the planning pipeline as successive ordinary runs (draft,
3
+ * review, revise up to a round budget) and freeze the result, or park it
4
+ * contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
5
+ * sequencing and every decision the pipeline makes.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
10
+ import { classifyRunProgress } from "../campaign/chain.mjs";
11
+ import { runProgress } from "../engine/supervise.mjs";
12
+ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
13
+ import { validateRuntime } from "../contract/runtime.mjs";
14
+ import { delay } from "../util.mjs";
15
+ import { runPlanningPipeline } from "../plan/pipeline.mjs";
16
+
17
+ /** How often a foreground `plan` polls a launched stage's run directory. */
18
+ const DEFAULT_POLL_MS = 1_000;
19
+
20
+ /**
21
+ * `--runtime-defaults worker=<id>,judge=<id>`, either key optional, comma
22
+ * separated. Absent entirely, the pipeline falls through to plain
23
+ * availability discovery for every node.
24
+ *
25
+ * @param {string|undefined} value
26
+ * @returns {{worker?: string, judge?: string}}
27
+ */
28
+ export function parseRuntimeDefaults(value) {
29
+ /** @type {{worker?: string, judge?: string}} */
30
+ const result = {};
31
+ if (value === undefined) return result;
32
+ for (const pair of value.split(",")) {
33
+ const eq = pair.indexOf("=");
34
+ if (eq < 0) throw new Error(`--runtime-defaults entries must be worker=<id> or judge=<id>: ${pair}`);
35
+ const role = pair.slice(0, eq).trim();
36
+ const id = pair.slice(eq + 1).trim();
37
+ if (role !== "worker" && role !== "judge") throw new Error(`--runtime-defaults role must be worker or judge: ${role}`);
38
+ if (!id) throw new Error(`--runtime-defaults ${role} needs a runtime id`);
39
+ result[role] = id;
40
+ }
41
+ return result;
42
+ }
43
+
44
+ /**
45
+ * @param {unknown} value
46
+ * @returns {number}
47
+ */
48
+ function reviewRoundsOf(value) {
49
+ if (value === undefined) return 2;
50
+ const rounds = Number(value);
51
+ if (!Number.isInteger(rounds) || rounds < 1) throw new Error(`--review-rounds must be a positive integer: ${String(value)}`);
52
+ return rounds;
53
+ }
54
+
55
+ /**
56
+ * A `--runtimes <path>` catalogue: a JSON object in the same shape a
57
+ * contract's own `runtimes` field takes, validated entry-by-entry with the
58
+ * same validator `validateContract` uses, so a malformed catalogue is
59
+ * rejected before any planning stage launches rather than surfacing as an
60
+ * opaque failure deep inside the pipeline.
61
+ *
62
+ * @param {string} path
63
+ * @returns {Record<string, import("../contract/index.mjs").ValidatedRuntime>}
64
+ */
65
+ export function loadRuntimesCatalogue(path) {
66
+ const resolved = resolve(path);
67
+ /** @type {unknown} */
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(readFileSync(resolved, "utf8"));
71
+ } catch (error) {
72
+ throw new Error(`--runtimes ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
73
+ }
74
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`--runtimes ${path} must be a JSON object`);
75
+ /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */
76
+ const runtimes = {};
77
+ for (const [id, runtime] of Object.entries(raw)) runtimes[id] = validateRuntime(id, runtime);
78
+ return runtimes;
79
+ }
80
+
81
+ /**
82
+ * @param {string} target
83
+ * @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, detach?: boolean, json?: boolean}} values
84
+ * @returns {Promise<void>}
85
+ */
86
+ export async function planCli(target, values) {
87
+ const specPath = resolve(target);
88
+ if (typeof values.campaign !== "string" || !values.campaign) throw new Error("plan requires --campaign <id>");
89
+ const campaignId = values.campaign;
90
+ const phase = typeof values.phase === "string" && values.phase ? values.phase : "default";
91
+ const reviewRounds = reviewRoundsOf(values["review-rounds"]);
92
+ const approveBelow = /** @type {"standard"|"high"|"none"|undefined} */ (values["approve-below"]);
93
+ const runtimeDefaults = parseRuntimeDefaults(values["runtime-defaults"]);
94
+ const runtimes = typeof values.runtimes === "string" && values.runtimes
95
+ ? loadRuntimesCatalogue(values.runtimes)
96
+ : DISCOVERY_RUNTIME_DEFINITIONS;
97
+
98
+ if (values.detach === true) {
99
+ const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
100
+ if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
101
+ if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
102
+ if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
103
+ const child = detachArgv(argv);
104
+ if (child.pid === undefined) throw new Error("detached plan has no pid");
105
+ process.stdout.write(`[plan] detached · pid ${child.pid} · ${specPath}\n`);
106
+ return;
107
+ }
108
+
109
+ const result = await runPlanningPipeline({
110
+ specPath,
111
+ campaignId,
112
+ phase,
113
+ reviewRounds,
114
+ approveBelow,
115
+ runtimeDefaults,
116
+ runtimes,
117
+ launch: async (contractPath, contract) => {
118
+ const child = detachSelf("run", contractPath);
119
+ if (child.pid === undefined) throw new Error("detached planning run has no pid");
120
+ await waitForBootstrap(join(contract.cwd, ".runs", contract.id), child.pid, child);
121
+ },
122
+ wait: async (runDir) => {
123
+ for (;;) {
124
+ const progress = runProgress(runDir);
125
+ const classification = classifyRunProgress(progress);
126
+ if (classification !== "unfinished" && classification !== "waiting") return progress;
127
+ await delay(DEFAULT_POLL_MS);
128
+ }
129
+ },
130
+ });
131
+
132
+ if (values.json === true) {
133
+ process.stdout.write(`${JSON.stringify(result)}\n`);
134
+ return;
135
+ }
136
+ if (result.status === "contested") {
137
+ process.stdout.write(`[plan] ${campaignId} phase ${phase} contested after ${result.round} round(s) · ${result.findings.length} finding(s) · ${result.planPath}\n`);
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
142
+ }
package/src/cli.mjs CHANGED
@@ -32,6 +32,7 @@ import { skillsCli } from "./cli/skills.mjs";
32
32
  import { updateCommand } from "./cli/update.mjs";
33
33
  import { contractCli, validateContractFile } from "./cli/contract.mjs";
34
34
  import { specCli } from "./cli/spec.mjs";
35
+ import { planCli } from "./cli/plan.mjs";
35
36
  import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
36
37
  import { runContract } from "./engine/scheduler.mjs";
37
38
  import { resumeRun } from "./engine/resume.mjs";
@@ -106,6 +107,16 @@ export const COMMAND_OPTIONS = {
106
107
  setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
107
108
  init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
108
109
  metrics: METRICS_OPTIONS,
110
+ plan: {
111
+ campaign: { type: "string" },
112
+ phase: { type: "string" },
113
+ "review-rounds": { type: "string" },
114
+ "approve-below": { type: "string" },
115
+ "runtime-defaults": { type: "string" },
116
+ runtimes: { type: "string" },
117
+ detach: { type: "boolean" },
118
+ json: { type: "boolean" },
119
+ },
109
120
  };
110
121
 
111
122
  /**
@@ -421,6 +432,10 @@ async function main(argv) {
421
432
  }
422
433
  if (command === "metrics") { process.stdout.write(renderCampaignMetrics(target, values)); return; }
423
434
  if (command === "findings") { process.stdout.write(renderFindings(resolve(target))); return; }
435
+ if (command === "plan") {
436
+ await planCli(target, /** @type {Parameters<typeof planCli>[1]} */ (values));
437
+ return;
438
+ }
424
439
  if (command === "validate") { validateContractFile(resolve(target)); return; }
425
440
  usage();
426
441
  }
@@ -432,7 +432,7 @@ function renderDiscoveryPrompt(packet, nodeId) {
432
432
  ...bulletOrNone(packet.nonGoals),
433
433
  "",
434
434
  "## Required output",
435
- 'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository.',
435
+ 'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository. Put structured findings meant to inform that packet in `output` (a JSON object, at most 65536 bytes); prose belongs in `summary`.',
436
436
  "",
437
437
  "## Verification",
438
438
  VERIFICATION_PARAGRAPH,
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { validateTaskPacket } from "./task-packet.mjs";
3
- import { requireText } from "./assert.mjs";
3
+ import { assertObject, requireText } from "./assert.mjs";
4
4
 
5
5
  const RESULT_LIMITS = Object.freeze({
6
6
  bytes: 32 * 1024,
@@ -9,6 +9,10 @@ const RESULT_LIMITS = Object.freeze({
9
9
  itemBytes: 2 * 1024,
10
10
  artifactBytes: 16 * 1024,
11
11
  missingContextItems: 16,
12
+ // A discovery worker's structured findings are the deliverable, not
13
+ // incidental prose, so `output` gets its own ceiling outside the 32 KiB
14
+ // envelope instead of competing with summary/verification for it.
15
+ outputBytes: 64 * 1024,
12
16
  });
13
17
 
14
18
  /**
@@ -26,9 +30,11 @@ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
26
30
  /**
27
31
  * The worker-result protocol: exactly one JSON object returned as the only
28
32
  * content of the final worker message. `blocked_context` requires at least one
29
- * missingContext entry; `done` requires none.
33
+ * missingContext entry; `done` requires none. `output` is optional and carries
34
+ * a discovery node's structured findings; an execution result must not
35
+ * declare it (enforced where the node's mode is known, not here).
30
36
  *
31
- * @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[]}} WorkerResult
37
+ * @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[], output?: Record<string, unknown>}} WorkerResult
32
38
  */
33
39
 
34
40
  /**
@@ -37,8 +43,9 @@ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
37
43
  */
38
44
  export function parseWorkerResult(value) {
39
45
  if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
40
- if (Buffer.byteLength(value, "utf8") > RESULT_LIMITS.bytes) {
41
- throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
46
+ const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
47
+ if (Buffer.byteLength(value, "utf8") > maxRawBytes) {
48
+ throw new TypeError(`worker result exceeds ${maxRawBytes} bytes`);
42
49
  }
43
50
  let parsed;
44
51
  try {
@@ -86,17 +93,42 @@ export function validateWorkerResult(value) {
86
93
  if (record.status === "done" && missingContext.length > 0) {
87
94
  throw new TypeError("worker result.missingContext must be empty for done");
88
95
  }
89
- const normalized = {
96
+ // `output` is accepted on any result here: the schema does not know which
97
+ // node mode produced it. Refusing it for execution results happens exactly
98
+ // once, at the ingestion point that does know the mode (resolveWorkerResult).
99
+ let output;
100
+ if (Object.hasOwn(record, "output") && record.output !== undefined) {
101
+ assertObject(record.output, "worker result.output");
102
+ if (Buffer.byteLength(JSON.stringify(record.output), "utf8") > RESULT_LIMITS.outputBytes) {
103
+ throw new TypeError(`worker result.output exceeds ${RESULT_LIMITS.outputBytes} bytes`);
104
+ }
105
+ output = /** @type {Record<string, unknown>} */ (record.output);
106
+ }
107
+ const envelope = {
90
108
  status: /** @type {WorkerResultStatus} */ (record.status),
91
109
  summary: /** @type {string} */ (record.summary),
92
110
  verification: [.../** @type {string[]} */ (record.verification)],
93
111
  artifacts: [.../** @type {string[]} */ (record.artifacts)],
94
112
  missingContext: [...missingContext],
95
113
  };
96
- if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > RESULT_LIMITS.bytes) {
114
+ // `output` is bounded on its own above and kept outside this envelope cap:
115
+ // it is a discovery node's deliverable, not incidental prose competing with
116
+ // summary/verification for the same 32 KiB budget.
117
+ if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > RESULT_LIMITS.bytes) {
97
118
  throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
98
119
  }
99
- return normalized;
120
+ return output === undefined ? envelope : { ...envelope, output };
121
+ }
122
+
123
+ /**
124
+ * A discovery node's structured findings, or null when the result carries
125
+ * none. The one accessor for `output` so callers never read the raw field.
126
+ *
127
+ * @param {WorkerResult} result
128
+ * @returns {Record<string, unknown>|null}
129
+ */
130
+ export function discoveryOutput(result) {
131
+ return result.output ?? null;
100
132
  }
101
133
 
102
134
  /**
@@ -54,6 +54,7 @@ import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsa
54
54
  import { attemptWorkspace } from "../repo/worktree.mjs";
55
55
  import { executeControllerVerification } from "./verify.mjs";
56
56
  import {
57
+ ExecutionOutputNotAllowedError,
57
58
  materializeAttemptResult,
58
59
  readWorkerResultFile,
59
60
  resolveWorkerResult,
@@ -528,6 +529,16 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
528
529
  try {
529
530
  workerResult = resolveWorkerResult(runDir, job.node, envelope.result);
530
531
  } catch (error) {
532
+ // A definitive protocol violation, not a malformed result: an
533
+ // execution node has no legitimate way to earn the repair path here.
534
+ if (error instanceof ExecutionOutputNotAllowedError) {
535
+ clearTierExhaustion(state);
536
+ transition(runDir, state, "failed", {
537
+ phase: "worker",
538
+ error: { code: "execution_output_not_allowed", message: errorMessage(error) },
539
+ }, lock);
540
+ continue;
541
+ }
531
542
  if (job.resultMaterialization) {
532
543
  clearTierExhaustion(state);
533
544
  transition(runDir, state, "failed", {
@@ -19,10 +19,18 @@ import { extractJson } from "../harnesses/protocol.mjs";
19
19
  import { invocationResult } from "./process.mjs";
20
20
  import { join } from "node:path";
21
21
  import { parseJudge } from "./prompts.mjs";
22
- import { parseWorkerResult } from "../contract/worker-result.mjs";
22
+ import { discoveryOutput, parseWorkerResult } from "../contract/worker-result.mjs";
23
23
  import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
24
24
  import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
25
25
 
26
+ /**
27
+ * Thrown by `resolveWorkerResult` when an execution result declares `output`.
28
+ * A distinct type, not a plain TypeError: this is a definitive protocol
29
+ * violation the engine can fail terminally on sight, not a malformed result
30
+ * worth the generic invalid-result repair attempt.
31
+ */
32
+ export class ExecutionOutputNotAllowedError extends TypeError {}
33
+
26
34
  /** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
27
35
  /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
28
36
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -115,6 +123,10 @@ export function isResultMaterializationInvocation(invocation) {
115
123
  }
116
124
  }
117
125
  /**
126
+ * The single ingestion point that knows the node's mode, so it is the one
127
+ * place `output` is refused for an execution result: the schema itself
128
+ * accepts the field on any result, mode-blind.
129
+ *
118
130
  * @param {string} runDir
119
131
  * @param {ValidatedNode} node
120
132
  * @param {unknown} providerResult
@@ -122,9 +134,11 @@ export function isResultMaterializationInvocation(invocation) {
122
134
  */
123
135
  export function resolveWorkerResult(runDir, node, providerResult) {
124
136
  const fromFile = readWorkerResultFile(runDir, node.id);
125
- if (fromFile) return fromFile;
126
- const result = parseWorkerResult(String(extractJson(providerResult) ?? providerResult ?? ""));
127
- persistWorkerResultFile(runDir, node.id, result);
137
+ const result = fromFile ?? parseWorkerResult(String(extractJson(providerResult) ?? providerResult ?? ""));
138
+ if (node.taskPacket.mode === "execution" && discoveryOutput(result) !== null) {
139
+ throw new ExecutionOutputNotAllowedError("worker result.output is only allowed for a discovery node, not execution");
140
+ }
141
+ if (!fromFile) persistWorkerResultFile(runDir, node.id, result);
128
142
  return result;
129
143
  }
130
144
  /**
@@ -5,6 +5,8 @@ import { finite } from "../util.mjs";
5
5
  * `zcode.mjs`, `replay.mjs` and `exec-jsonl.mjs` itself all depend on it.
6
6
  */
7
7
 
8
+ /** @typedef {{remaining: number|null, limit: number|null, resetsAt: string|null, window: string|null}} ClaudeAllowance */
9
+
8
10
  /**
9
11
  * Parse newline-delimited JSON without accepting provider prose.
10
12
  *
@@ -75,33 +77,110 @@ function withStartupReason(message, options = {}) {
75
77
  return reason ? `${message}: ${boundedMessage(reason, 512)}` : message;
76
78
  }
77
79
 
80
+ /**
81
+ * Claude's account-wide rate-limit signal, reported on its own `rate_limit_event`
82
+ * stream line, never on the terminal `result` event. Measured 2026-09-17
83
+ * against `claude -p 'Reply with exactly OK and use no tools.' --output-format
84
+ * stream-json --verbose`, cost `total_cost_usd: 0.27848` for that one probe
85
+ * (opus-tier default model, most of it `cache_creation_input_tokens` for this
86
+ * project's session context -- a probe against a smaller/cheaper model would
87
+ * cost far less, but the seat sampled is whichever model the operator's own
88
+ * session already has configured, so this is the honest per-probe figure for
89
+ * that seat). No `rate_limits` or `rate_limit_status` field appears anywhere
90
+ * in the stream. The measured line, verbatim:
91
+ * `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning",
92
+ * "resetsAt":1789837200,"rateLimitType":"seven_day","utilization":0.77,
93
+ * "isUsingOverage":false,"surpassedThreshold":0.75,"unifiedWindows":
94
+ * {"five_hour":{"utilization":0.1,"resetsAt":1789663200},"seven_day":
95
+ * {"utilization":0.77,"resetsAt":1789837200}}}}`. `utilization` is a 0..1
96
+ * fraction of the window named by `rateLimitType`, confirmed (not merely
97
+ * assumed) by `surpassedThreshold: 0.75` sitting just below the measured
98
+ * `0.77` at `status: "allowed_warning"` -- a 0-100 percentage scale would put
99
+ * 0.77 nowhere near a 75-scaled threshold. This is a different field, on a
100
+ * different scale, from the `used_percentage` (0-100) the statusline
101
+ * integration already reads off `rate_limits.five_hour` (see
102
+ * `integrations/claude-code/statusline.sh`); the two must not be confused.
103
+ * `remaining` here is the fraction left (`1 - utilization`) and `limit` is
104
+ * the fixed ceiling `1` -- a coarse proxy, declared as such. A `utilization`
105
+ * outside `[0, 1]` is treated as no signal (null) rather than silently
106
+ * clamped, so a future scale change on the stream fails visibly instead of
107
+ * pinning every sample to 0.
108
+ *
109
+ * `unifiedWindows` carries every window's own utilization side by side
110
+ * (`five_hour: 0.1` next to `seven_day: 0.77` in the measured line above): the
111
+ * top-level `utilization` is only ever the figure for the window `rateLimitType`
112
+ * names, so a sample must read the named window out of `unifiedWindows` (falling
113
+ * back to the top-level field only when `unifiedWindows` carries no entry for
114
+ * it) and record which window that was. Two samples of different windows are
115
+ * not comparable -- a `five_hour` utilization minus a `seven_day` one is not a
116
+ * delta -- so the window travels with the sample for `allowanceDelta` to pin.
117
+ *
118
+ * @param {Record<string, unknown>[]} events
119
+ * @returns {ClaudeAllowance}
120
+ */
121
+ function extractClaudeAllowance(events) {
122
+ const event = events.findLast((candidate) => candidate.type === "rate_limit_event");
123
+ const info = event?.rate_limit_info;
124
+ const record = info && typeof info === "object" && !Array.isArray(info) ? /** @type {Record<string, unknown>} */ (info) : null;
125
+ const window = record && typeof record.rateLimitType === "string" ? record.rateLimitType : null;
126
+ const unifiedWindows = record?.unifiedWindows && typeof record.unifiedWindows === "object" && !Array.isArray(record.unifiedWindows)
127
+ ? /** @type {Record<string, unknown>} */ (record.unifiedWindows)
128
+ : null;
129
+ const namedWindowEntry = window && unifiedWindows?.[window] && typeof unifiedWindows[window] === "object" && !Array.isArray(unifiedWindows[window])
130
+ ? /** @type {Record<string, unknown>} */ (unifiedWindows[window])
131
+ : null;
132
+ const rawUtilization = finite(namedWindowEntry ? namedWindowEntry.utilization : record?.utilization);
133
+ const utilization = rawUtilization !== null && rawUtilization >= 0 && rawUtilization <= 1 ? rawUtilization : null;
134
+ const resetsAtSeconds = finite(namedWindowEntry ? namedWindowEntry.resetsAt : record?.resetsAt);
135
+ return {
136
+ remaining: utilization === null ? null : 1 - utilization,
137
+ limit: utilization === null ? null : 1,
138
+ resetsAt: resetsAtSeconds === null ? null : new Date(resetsAtSeconds * 1000).toISOString(),
139
+ window,
140
+ };
141
+ }
142
+
143
+ /** The claude allowance shape with every member null: no signal was read. */
144
+ const NULL_CLAUDE_ALLOWANCE = { remaining: null, limit: null, resetsAt: null, window: null };
145
+
78
146
  /**
79
147
  * @param {string} stdout
80
148
  * @param {number|null} exitCode
81
149
  * @param {string|null} signal
82
150
  * @param {import("./index.mjs").NormalizeOptions} [options]
83
- * @returns {import("./index.mjs").ProviderEnvelope}
151
+ * @returns {import("./index.mjs").ProviderEnvelope & {allowance: ClaudeAllowance}}
84
152
  */
85
153
  export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
86
- if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
154
+ // The signal check must stay the first statement, byte-identical to before
155
+ // the allowance signal existed: `parseJsonLines` throws on any unparseable
156
+ // line past the first, and a killed process routinely leaves a truncated
157
+ // *later* line (the bounded-tail tolerance only covers the first). Parsing
158
+ // stdout before checking `signal` would turn a clean cancellation into a
159
+ // thrown error, which callers (`src/run/usage.mjs`, `src/engine/process.mjs`)
160
+ // catch into `invalid_output` or `null`, losing the envelope entirely.
161
+ if (signal) return { ...failed("canceled", `provider ended after ${signal}`, "canceled"), allowance: NULL_CLAUDE_ALLOWANCE };
87
162
  const events = parseJsonLines(stdout, "claude");
163
+ const allowance = extractClaudeAllowance(events);
88
164
  const resultEvent = events.findLast((event) => event.type === "result");
89
- if (!resultEvent) return failed("incomplete_stream", withStartupReason("Claude emitted no result event", options));
165
+ if (!resultEvent) return { ...failed("incomplete_stream", withStartupReason("Claude emitted no result event", options)), allowance };
90
166
  const result = typeof resultEvent.result === "string" ? resultEvent.result : null;
91
167
  // A provider-reported quota stop is exhaustion: the declared failover edge
92
168
  // must fire instead of settling the node as an ordinary provider failure.
93
169
  const quotaText = claudeQuotaText(resultEvent, events);
94
170
  if (quotaText) {
95
- return failed(
96
- "quota_exhausted",
97
- boundedMessage(quotaText, 512),
98
- "exhausted",
99
- typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
100
- canonicalUsage(resultEvent.usage),
101
- );
171
+ return {
172
+ ...failed(
173
+ "quota_exhausted",
174
+ boundedMessage(quotaText, 512),
175
+ "exhausted",
176
+ typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
177
+ canonicalUsage(resultEvent.usage),
178
+ ),
179
+ allowance,
180
+ };
102
181
  }
103
182
  if (resultEvent.is_error || exitCode !== 0) {
104
- return failed("provider_error", result ?? `Claude exited with code ${exitCode}`);
183
+ return { ...failed("provider_error", result ?? `Claude exited with code ${exitCode}`), allowance };
105
184
  }
106
185
  return {
107
186
  status: result?.trim() ? "done" : "no-op",
@@ -110,6 +189,7 @@ export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
110
189
  usage: canonicalUsage(resultEvent.usage),
111
190
  costUsd: finite(resultEvent.total_cost_usd),
112
191
  error: null,
192
+ allowance,
113
193
  };
114
194
  }
115
195