faberun 0.8.0 → 0.10.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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Planning contract templates: the one-node `mode: "discovery"` contracts a
3
+ * plan pipeline runs outside the control session — draft, review, revise (a
4
+ * draft carrying the reviewer's findings), spec authoring, and spec review.
5
+ * Separate from freeze.mjs (which turns an already-built plan into a
6
+ * validated contract on disk) because this module never touches the
7
+ * filesystem or a model: it only assembles the JSON object `validateContract`
8
+ * accepts, from exactly the inputs each role may see. The reviewer's packet
9
+ * is the enforced case: it carries the spec, the repository facts and the
10
+ * artefact under review, never the author's packet, transcript or summary.
11
+ */
12
+ import { assertObject, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
13
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
14
+ import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
15
+ import { validateVerificationCommands } from "../contract/verification.mjs";
16
+
17
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
18
+ /** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
19
+ /** @typedef {"low"|"standard"|"high"} RiskTier */
20
+ /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
21
+ /** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[]}} PlanOutputNode */
22
+ /** @typedef {{nodes: PlanOutputNode[], justification?: string}} PlanOutput */
23
+ /** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
24
+
25
+ /**
26
+ * The taskKind catalogue a draft or revise classifies against. Exported here,
27
+ * not read from a separate document, so `TASK_KIND_CATALOGUE_PATH` (this
28
+ * module's own repo-relative path) is a real, always-present file a
29
+ * closed-context worker can be told to read for the authoritative list.
30
+ */
31
+ export const TASK_KINDS = Object.freeze(["docs", "implement", "test", "refactor", "infra", "judge"]);
32
+
33
+ /** The risk tiers a draft or revise classifies against. */
34
+ export const RISK_TIERS = Object.freeze(["low", "standard", "high"]);
35
+
36
+ /** This module's own repo-relative path: the taskKind catalogue's home. */
37
+ export const TASK_KIND_CATALOGUE_PATH = "src/plan/template.mjs";
38
+
39
+ /**
40
+ * Which of the caller's `runtimeDefaults` roles resolves this contract's
41
+ * single node. A draft or revise is authored by the worker role; a review or
42
+ * spec-review is graded by the judge role — there is no gate on this
43
+ * single-node contract, so the role only decides which runtime id the node
44
+ * itself carries.
45
+ *
46
+ * @type {Record<PlanningKind, "worker"|"judge">}
47
+ */
48
+ const KIND_ROLE = Object.freeze({
49
+ draft: "worker",
50
+ revise: "worker",
51
+ "spec-author": "worker",
52
+ review: "judge",
53
+ "spec-review": "judge",
54
+ });
55
+
56
+ /** @type {Record<PlanningKind, string[]>} */
57
+ const REQUIRED_INPUTS = Object.freeze({
58
+ draft: ["specPath", "repoFactsPath"],
59
+ revise: ["specPath", "repoFactsPath", "findingsPath"],
60
+ review: ["specPath", "repoFactsPath", "planPath"],
61
+ "spec-author": ["notesPath"],
62
+ "spec-review": ["specPath"],
63
+ });
64
+
65
+ const PLAN_OUTPUT_SHAPE = "{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, definitionOfDone, verification}], justification?}";
66
+ const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
67
+
68
+ /** @type {Record<PlanningKind, string>} */
69
+ const OBJECTIVES = Object.freeze({
70
+ draft: "Draft an execution plan for this phase: classify every node's taskKind and riskTier from the spec and the repository facts, and propose the dependency graph.",
71
+ revise: "Revise the plan to resolve every one of the reviewer's findings, keeping the same classification and graph shape as a fresh draft.",
72
+ review: "Review this plan against the spec and the repository facts, and report only findings.",
73
+ "spec-author": "Turn free notes into a structured spec document following the spec format.",
74
+ "spec-review": "Review this spec for traceability and completeness, and report only findings.",
75
+ });
76
+
77
+ /** @type {Record<PlanningKind, string[]>} */
78
+ const INSTRUCTIONS = Object.freeze({
79
+ draft: [
80
+ `Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
81
+ `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
82
+ "Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
83
+ ],
84
+ revise: [
85
+ "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
86
+ `Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
87
+ `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
88
+ "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
89
+ ],
90
+ review: [
91
+ "You are given only the spec, the repository facts, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
92
+ `Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
93
+ "severity must be one of critical, major, minor. Every finding's nodeId must name a node id that actually appears in the plan under review.",
94
+ ],
95
+ "spec-author": [
96
+ "Read the notes and turn them into a structured spec document: front matter, Intent, Requirements (each with a stable R<n> id and a proof), Non-goals, Constraints, Success criteria, and Risks.",
97
+ "Return exactly one worker-result JSON object. Put the authored spec text, as one markdown document, in output.spec and nothing else in output.",
98
+ ],
99
+ "spec-review": [
100
+ "You are given only the spec under review; you have not seen the author's notes or reasoning. Review the document alone.",
101
+ `Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
102
+ "severity must be one of critical, major, minor. Every finding's nodeId must name the requirement id, or section heading, it concerns.",
103
+ ],
104
+ });
105
+
106
+ /** @type {Record<PlanningKind, string[]>} */
107
+ const NON_GOALS = Object.freeze({
108
+ draft: ["Assigning a runtime, harness, or model to any node."],
109
+ revise: ["Assigning a runtime, harness, or model to any node.", "Reopening a finding the reviewer did not raise."],
110
+ review: ["Proposing a replacement plan.", "Assigning a runtime, harness, or model to any node."],
111
+ "spec-author": ["Declaring nodes, phases, or architecture in the spec."],
112
+ "spec-review": ["Proposing a replacement spec."],
113
+ });
114
+
115
+ /**
116
+ * @param {PlanningKind} kind
117
+ * @param {PlanningContractInputs} inputs
118
+ * @returns {string[]}
119
+ */
120
+ function readFilesForKind(kind, inputs) {
121
+ if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH];
122
+ if (kind === "revise") {
123
+ return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH, /** @type {string} */ (inputs.findingsPath)];
124
+ }
125
+ if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
126
+ if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
127
+ return [/** @type {string} */ (inputs.specPath)];
128
+ }
129
+
130
+ /**
131
+ * Build one of the planning pipeline's one-node discovery contracts. Pure:
132
+ * no file is read or written, and no model is invoked. The returned object is
133
+ * the raw, not-yet-validated contract JSON `validateContract` accepts.
134
+ *
135
+ * @param {PlanningKind} kind
136
+ * @param {PlanningContractInputs} inputs
137
+ * @returns {JsonObject}
138
+ */
139
+ export function buildPlanningContract(kind, inputs) {
140
+ const required = REQUIRED_INPUTS[kind];
141
+ if (!required) throw new TypeError(`buildPlanningContract: unknown kind ${kind}`);
142
+ requireId(inputs.campaignId, "inputs.campaignId");
143
+ requireString(inputs.phase, "inputs.phase");
144
+ if (!Number.isInteger(inputs.n) || inputs.n <= 0) throw new TypeError("inputs.n must be a positive integer");
145
+ assertObject(inputs.runtimes, "inputs.runtimes");
146
+ assertObject(inputs.runtimeDefaults ?? {}, "inputs.runtimeDefaults");
147
+ const inputRecord = /** @type {Record<string, unknown>} */ (inputs);
148
+ for (const field of required) requireString(inputRecord[field], `inputs.${field}`);
149
+
150
+ const readFiles = readFilesForKind(kind, inputs);
151
+ const role = KIND_ROLE[kind];
152
+ const runtimeId = (inputs.runtimeDefaults ?? {})[role];
153
+
154
+ /** @type {JsonObject} */
155
+ const taskPacket = {
156
+ mode: "discovery",
157
+ objective: OBJECTIVES[kind],
158
+ instructions: INSTRUCTIONS[kind],
159
+ readFiles,
160
+ writeFiles: [],
161
+ symbols: [],
162
+ decisions: [],
163
+ nonGoals: NON_GOALS[kind],
164
+ verification: [],
165
+ };
166
+
167
+ return {
168
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
169
+ contractVersion: CONTRACT_VERSION,
170
+ id: `${inputs.campaignId}-plan-${inputs.phase}-${kind}-${inputs.n}`,
171
+ campaignId: inputs.campaignId,
172
+ goal: inputs.goal ?? `Plan ${kind} for phase ${inputs.phase}`,
173
+ cwd: inputs.cwd ?? ".",
174
+ runtimes: inputs.runtimes,
175
+ runtimeDefaults: inputs.runtimeDefaults ?? {},
176
+ nodes: [
177
+ {
178
+ id: kind,
179
+ type: kind,
180
+ phase: inputs.phase,
181
+ dependsOn: [],
182
+ ...(runtimeId === undefined ? {} : { runtime: runtimeId }),
183
+ taskPacket,
184
+ gate: false,
185
+ },
186
+ ],
187
+ };
188
+ }
189
+
190
+ const PLAN_FIELDS = new Set(["nodes", "justification"]);
191
+ const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "definitionOfDone", "verification"]);
192
+
193
+ /**
194
+ * Validate a draft or revise worker's `output.plan`. Rejects a node naming a
195
+ * runtime, harness, model, or vendor (an unknown field, since a plan node's
196
+ * shape never includes one) and a node missing taskKind or riskTier.
197
+ *
198
+ * @param {unknown} plan
199
+ * @returns {PlanOutput}
200
+ */
201
+ export function validatePlanOutput(plan) {
202
+ assertObject(plan, "plan");
203
+ const record = /** @type {Record<string, unknown>} */ (plan);
204
+ rejectUnknown(record, PLAN_FIELDS, "plan");
205
+ if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
206
+ throw new TypeError("plan.nodes must be a non-empty array");
207
+ }
208
+ const nodes = record.nodes.map((node, index) => {
209
+ const label = `plan.nodes[${index}]`;
210
+ assertObject(node, label);
211
+ const nodeRecord = /** @type {Record<string, unknown>} */ (node);
212
+ rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, label);
213
+ requireId(nodeRecord.id, `${label}.id`);
214
+ requireString(nodeRecord.objective, `${label}.objective`);
215
+ if (typeof nodeRecord.taskKind !== "string" || !TASK_KINDS.includes(nodeRecord.taskKind)) {
216
+ throw new TypeError(`${label}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
217
+ }
218
+ if (typeof nodeRecord.riskTier !== "string" || !RISK_TIERS.includes(nodeRecord.riskTier)) {
219
+ throw new TypeError(`${label}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
220
+ }
221
+ const dependsOn = nodeRecord.dependsOn ?? [];
222
+ requireStringArray(dependsOn, `${label}.dependsOn`);
223
+ const readFiles = nodeRecord.readFiles ?? [];
224
+ requireStringArray(readFiles, `${label}.readFiles`);
225
+ const writeFiles = nodeRecord.writeFiles ?? [];
226
+ requireStringArray(writeFiles, `${label}.writeFiles`);
227
+ const definitionOfDone = validateDefinitionOfDone(nodeRecord.definitionOfDone ?? [], `${label}.definitionOfDone`);
228
+ const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
229
+ return /** @type {PlanOutputNode} */ ({
230
+ id: /** @type {string} */ (nodeRecord.id),
231
+ objective: /** @type {string} */ (nodeRecord.objective),
232
+ taskKind: /** @type {string} */ (nodeRecord.taskKind),
233
+ riskTier: /** @type {RiskTier} */ (nodeRecord.riskTier),
234
+ dependsOn: /** @type {string[]} */ (dependsOn),
235
+ readFiles: /** @type {string[]} */ (readFiles),
236
+ writeFiles: /** @type {string[]} */ (writeFiles),
237
+ definitionOfDone,
238
+ verification,
239
+ });
240
+ });
241
+ if (record.justification !== undefined) requireString(record.justification, "plan.justification");
242
+ return {
243
+ nodes,
244
+ ...(record.justification === undefined ? {} : { justification: /** @type {string} */ (record.justification) }),
245
+ };
246
+ }
247
+
248
+ const FINDING_FIELDS = new Set(["id", "severity", "nodeId", "text"]);
249
+ const FINDING_SEVERITIES = new Set(["critical", "major", "minor"]);
250
+
251
+ /**
252
+ * Validate a review or spec-review worker's `output.findings`. A finding
253
+ * without `severity` or `nodeId` is invalid.
254
+ *
255
+ * @param {unknown} findings
256
+ * @returns {PlanFindingOutput[]}
257
+ */
258
+ export function validateFindings(findings) {
259
+ if (!Array.isArray(findings)) throw new TypeError("findings must be an array");
260
+ return findings.map((finding, index) => {
261
+ const label = `findings[${index}]`;
262
+ assertObject(finding, label);
263
+ const record = /** @type {Record<string, unknown>} */ (finding);
264
+ rejectUnknown(record, FINDING_FIELDS, label);
265
+ requireId(record.id, `${label}.id`);
266
+ if (typeof record.severity !== "string" || !FINDING_SEVERITIES.has(record.severity)) {
267
+ throw new TypeError(`${label}.severity must be one of critical, major, minor`);
268
+ }
269
+ requireString(record.nodeId, `${label}.nodeId`);
270
+ requireString(record.text, `${label}.text`);
271
+ return /** @type {PlanFindingOutput} */ ({
272
+ id: /** @type {string} */ (record.id),
273
+ severity: /** @type {"critical"|"major"|"minor"} */ (record.severity),
274
+ nodeId: /** @type {string} */ (record.nodeId),
275
+ text: /** @type {string} */ (record.text),
276
+ });
277
+ });
278
+ }
@@ -503,7 +503,7 @@ export function promoteRun({ repo, runId, landBranch, runHead, baseSha, finalVer
503
503
  if (!head) throw refusal(`refusing to promote ${runId}: the run ref is unavailable`, "run_ref_missing");
504
504
  const checkedOutAt = worktreeCheckedOutAt(repo, landBranch);
505
505
  if (checkedOutAt) {
506
- throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}`, "land_branch_checked_out");
506
+ throw refusal(`refusing to promote ${landBranch}: it is checked out in ${checkedOutAt}; ${landBranchCheckedOutRemedy(checkedOutAt)}`, "land_branch_checked_out");
507
507
  }
508
508
  const branchRef = `refs/heads/${landBranch}`;
509
509
  const current = gitHead(repo, branchRef);
@@ -540,6 +540,11 @@ function refusal(message, code) {
540
540
  return Object.assign(new Error(message), { code });
541
541
  }
542
542
 
543
+ /** @param {string} checkedOutAt @returns {string} */
544
+ function landBranchCheckedOutRemedy(checkedOutAt) {
545
+ return `detach the checkout at ${checkedOutAt} or run the coordinator from a worktree`;
546
+ }
547
+
543
548
  /** @param {string} path @returns {string} */
544
549
  function readTextFile(path) {
545
550
  return readFileSync(path, "utf8");
@@ -26,7 +26,7 @@ const POINTER_ATTENTION_CHARS = 80;
26
26
  /** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
27
27
  /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
28
28
  /** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
29
- /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
29
+ /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
30
30
  /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
31
31
 
32
32
  /** The glyph each terminal state prints in a status table. */
@@ -43,13 +43,11 @@ export const MARK = {
43
43
  };
44
44
 
45
45
  /**
46
- * `status <run-dir>`: everything the operator needs, in the same order as
47
- * the dashboard page (TECH-SPEC lean, section 4's last paragraph) —
48
- * needs-you, now, nodes, cost. Every cell comes from the same payload
49
- * `status --json` and the per-run `status.json` file emit
50
- * (`buildStatusPayload`); `nodes` and `identityWarnings` from `loadRun` are
51
- * used only for the two facts the payload does not carry: throwing on an
52
- * invalid persisted snapshot, and `controllerStatus`'s lock read.
46
+ * `status <run-dir>`: needs-you, now, nodes, cost, in the dashboard's order
47
+ * (TECH-SPEC lean, section 4's last paragraph). Every cell comes from the same
48
+ * payload `status --json` emits (`buildStatusPayload`); `nodes` and
49
+ * `identityWarnings` from `loadRun` cover only what the payload does not:
50
+ * throwing on an invalid snapshot, and `controllerStatus`'s lock read.
53
51
  *
54
52
  * @param {string} runDir
55
53
  * @returns {string}
@@ -75,7 +73,7 @@ export function renderStatus(runDir) {
75
73
  const widths = [3, 24, 9, 3, 28, 8, 6, 6, 6, 10, 9, MAX_NOTE_LENGTH];
76
74
  /** @type {(cells: unknown[]) => string} */
77
75
  const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
78
- lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "VERDICT", "NOTE"]), row(widths.map((width) => "-".repeat(width))));
76
+ lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "GATE", "NOTE"]), row(widths.map((width) => "-".repeat(width))));
79
77
  for (const node of payload.nodes) {
80
78
  lines.push(row([
81
79
  MARK[node.status] ?? "[?]",
@@ -88,7 +86,7 @@ export function renderStatus(runDir) {
88
86
  compactTokens(node.usage?.cacheReadInputTokens),
89
87
  compactTokens(node.usage?.outputTokens),
90
88
  compactCost(node.costUsd),
91
- node.verdict ?? "-",
89
+ node.gateOutcome ?? "-",
92
90
  node.note ?? "-",
93
91
  ]));
94
92
  }
@@ -98,12 +96,10 @@ export function renderStatus(runDir) {
98
96
  }
99
97
 
100
98
  /**
101
- * The node the operator should look at right now, formatted the same way
102
- * the dashboard's now strip is (TECH-SPEC lean, section 4, item 1): the
103
- * active node's elapsed time and cost so far, or an idle line once every
104
- * node has settled. A node awaiting a controller verification command
105
- * appends which one, `k/n`, and its argv, so a minutes-long suite is not
106
- * silent between ticks.
99
+ * The node the operator should look at right now, formatted like the
100
+ * dashboard's now strip (TECH-SPEC lean, section 4, item 1): elapsed time and
101
+ * cost so far, or idle once every node has settled. A node awaiting a
102
+ * controller verification command appends which one, `k/n`, and its argv.
107
103
  *
108
104
  * @param {StatusPayload} payload
109
105
  * @param {number} now epoch ms
@@ -114,7 +110,8 @@ function nowLine(payload, now) {
114
110
  if (active) {
115
111
  const progress = active.verificationProgress;
116
112
  const verification = progress ? ` · verification ${progress.index}/${progress.total} · ${progress.argv}` : "";
117
- return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}`;
113
+ const candidate = !progress && active.executionPhase === "candidate" ? " · candidate verification" : "";
114
+ return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}${candidate}`;
118
115
  }
119
116
  const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
120
117
  return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
@@ -134,6 +131,27 @@ function verificationProgress(node) {
134
131
  return verification?.progress ?? null;
135
132
  }
136
133
 
134
+ /** Set by `verifyCandidateWorkspace` (engine/verify.mjs) for the run of the sealed candidate's own re-verification pass. @param {NodeSnapshot} node @returns {boolean} */
135
+ function candidateVerificationActive(node) {
136
+ const verification = /** @type {{candidate?: boolean}|null|undefined} */ (node.verification);
137
+ return verification?.candidate === true;
138
+ }
139
+
140
+ /** `candidate` while re-verifying the sealed candidate, `verification` mid-attempt-verification, else the persisted phase; the two never overlap. @param {NodeSnapshot} node @returns {string|null} */
141
+ function executionPhaseOf(node) {
142
+ if (candidateVerificationActive(node)) return "candidate";
143
+ if (verificationProgress(node)) return "verification";
144
+ return node.phase;
145
+ }
146
+
147
+ /** What the gate decided (independent of `verdict`: an advisory review or a below-threshold fail verdict still accepts the node, engine/review.mjs `applyJudgeResult`). @param {NodeSnapshot} node @returns {"passed"|"rejected"|null} */
148
+ export function gateOutcome(node) {
149
+ if (!node.gate) return null;
150
+ if (SUCCESS.has(node.status)) return "passed";
151
+ if (node.status === "failed" || node.status === "exhausted") return "rejected";
152
+ return null;
153
+ }
154
+
137
155
  /**
138
156
  * A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
139
157
  * settled into a terminal state, `startedAt` to `now` while it is still
@@ -216,7 +234,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
216
234
  // A running controller verification command does not move
217
235
  // `node.phase` (it stays `worker`, the phase that dispatched it), so
218
236
  // the surface that shows it is computed here rather than persisted.
219
- executionPhase: progress ? "verification" : node.phase,
237
+ executionPhase: executionPhaseOf(node),
220
238
  runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
221
239
  workerRuntime: workerRuntimeLabel(node),
222
240
  continuation: continuationMode(node),
@@ -227,6 +245,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
227
245
  usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
228
246
  costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
229
247
  verdict: node.gate?.verdict ?? null,
248
+ gateOutcome: gateOutcome(node),
230
249
  pendingHandoff: pendingHandoff(node),
231
250
  note: statusNote(node),
232
251
  scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
@@ -256,16 +275,11 @@ function activeStatusNode(nodes) {
256
275
  }
257
276
 
258
277
  /**
259
- * The bounded pointer record written to `.runs/status.json`: enough for a
260
- * quick ambient read (statusline, a stale watcher) without opening the
261
- * per-run status.json. Bounded the same way heartbeat.json used to be, so a
262
- * cheap bounded read stays valid for any reader still built that way.
263
- * `generatedAt` is unix seconds, not ISO: the statusline's no-jq fallback has
264
- * no clock and only jq's builtin `now` can compute an age from a live clock,
265
- * and neither path needs a `date` process to read an integer. `elapsedSec`
266
- * is likewise precomputed here (as of `generatedAt`, not live) so the
267
- * statusline never has to subtract two timestamps to show it — it just
268
- * prints the integer, whichever reader it is.
278
+ * The bounded pointer record written to `.runs/status.json`, for a quick
279
+ * ambient read without opening the per-run status.json (bounded like the old
280
+ * heartbeat.json). `generatedAt` is unix seconds, not ISO, and `elapsedSec` is
281
+ * precomputed from it, since the no-jq statusline fallback has no clock to
282
+ * subtract two timestamps with.
269
283
  *
270
284
  * @param {JsonObject} payload the per-run status.json payload
271
285
  * @param {NodeSnapshot[]} nodes
@@ -340,10 +354,7 @@ export function writeStatusArtifacts(runDir, runsDir, contract, states) {
340
354
  */
341
355
  export function controllerStatus(runDir, nodes) {
342
356
  const lock = readLock(runDir);
343
- // The heartbeat's `at` is the live tick, so a working controller and a dead
344
- // one are distinguishable on disk. Before this it was hard-coded null here
345
- // and computed from node updates only once the lock was already stale, which
346
- // is why all 47 recorded status.json files reported a null lastTick.
357
+ // The heartbeat's `at` distinguishes a working controller from a dead one on disk.
347
358
  const heartbeat = readHeartbeat(runDir);
348
359
  const lastTick = typeof heartbeat?.at === "string" ? heartbeat.at : null;
349
360
  if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) {
@@ -473,14 +484,11 @@ export function renderFindings(runDir) {
473
484
  }
474
485
 
475
486
  /**
476
- * A node the worker itself stopped on, rendered as the repair it asks for.
477
- *
478
- * `findings` used to answer only gate exhaustion, and a run whose nodes all
479
- * stopped on `blocked_context` reported nothing to act on while the workers
480
- * had each named exactly what they needed — measured on a four-node campaign
481
- * where three nodes were blocked and the command printed one line saying so.
482
- * The question is already structured, so the repair is mechanical: put the
483
- * named paths in the packet's `readFiles` and take a new run id.
487
+ * A node the worker itself stopped on, rendered as the repair it asks for:
488
+ * `findings` used to answer only gate exhaustion, so a run whose nodes all
489
+ * stopped on `blocked_context` reported nothing to act on (measured on a
490
+ * four-node campaign with three nodes blocked). The repair is mechanical: put
491
+ * the named paths in the packet's `readFiles` and take a new run id.
484
492
  *
485
493
  * @param {NodeSnapshot} node
486
494
  * @returns {string|null}
@@ -553,16 +561,11 @@ function continuationMode(node) {
553
561
  }
554
562
 
555
563
  /**
556
- * Who produced this node's work, for the RUNTIME column of both tables.
557
- *
558
- * `state.runtime` is the last runtime *dispatched*, and the judge dispatch
559
- * overwrites the worker's: every gated node that reached its gate therefore
560
- * reported its judge as the runtime, and a six-node campaign whose workers
561
- * were five different harnesses rendered as though three of them had never
562
- * run (measured 2026-09-13). The invocation ledger keeps both roles, so the
563
- * label is derived from it: the worker that ran, falling back to the live
564
- * runtime for a node that has not dispatched one yet. The judge is not lost —
565
- * it owns the VERDICT column, and `nowLine` still names whatever is running.
564
+ * Who produced this node's work, for the RUNTIME column: `state.runtime` is
565
+ * the last runtime *dispatched*, and the judge dispatch overwrites the
566
+ * worker's (measured 2026-09-13: a six-harness campaign rendered as though
567
+ * three workers never ran), so this reads the invocation ledger for the
568
+ * worker that ran, falling back to the live runtime if none dispatched yet.
566
569
  *
567
570
  * @param {NodeSnapshot} node
568
571
  * @returns {string|null}
@@ -633,7 +636,7 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
633
636
  export function statusNote(node) {
634
637
  const scope = scopeFindingsNote(node.scopeFindings);
635
638
  const review = reviewNote(node);
636
- const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? node.phase;
639
+ const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
637
640
  const note = boundedNote([review, detail]);
638
641
  if (!scope) return note;
639
642
  return boundedNote([scope, note]);
@@ -657,13 +660,10 @@ function nodeNote(node) {
657
660
  /** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
658
661
 
659
662
  /**
660
- * Per-role usage from the invocation ledger alone, with the provenance that
661
- * says whether the role's cost total is honest. A role whose invocations all
662
- * carry a provider cost is `priced` and its `costUsd` is the sum; a role with
663
- * any unpriced invocation is `partial` (some priced) or `unpriced` (none), and
664
- * its `costUsd` stays null rather than summing the known half and understating
665
- * it. A role with no invocation is `none`. Token totals are always carried, so
666
- * a role the provider would not price still reads as work.
663
+ * Per-role usage from the invocation ledger, with provenance for whether the
664
+ * cost total is honest: `priced` (all invocations costed), `partial`/`unpriced`
665
+ * (some/none costed, so `costUsd` stays null rather than understating), or
666
+ * `none` (no invocation). Token totals are always carried.
667
667
  *
668
668
  * @param {NodeSnapshot[]} nodes
669
669
  * @returns {{worker: RoleUsage, judge: RoleUsage}}