faberun 0.9.0 → 0.11.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.
@@ -498,6 +498,50 @@ export function isVerdictCandidate(text) {
498
498
  }
499
499
  }
500
500
 
501
+ /**
502
+ * A judge's final message can carry its whole verdict on one line with no
503
+ * newline before the JSON: `Confirmed. Now write the verdict JSON.{"verdict":
504
+ * "fail", ...}`, measured 2026-09-17 at 5662 characters. A line-boundary-only
505
+ * suffix scan never finds it, extractJson returns null, isVerdictCandidate
506
+ * returns false, and a correct, evidenced verdict is discarded as
507
+ * `judge_unavailable`. The candidate starts tried here are every `{` and `[`
508
+ * offset, not just line starts, so a JSON object glued directly to prose is
509
+ * still reachable.
510
+ */
511
+ const SUFFIX_CANDIDATE_LIMIT = 200;
512
+
513
+ /**
514
+ * The last parseable suffix of `trimmed`, trying every `{`/`[` offset from the
515
+ * end backwards. Bounded to the last `SUFFIX_CANDIDATE_LIMIT` offsets: without
516
+ * a cap this is one `JSON.parse` per bracket character, quadratic on a
517
+ * pathological message built mostly of brackets. 200 is comfortably above the
518
+ * bracket count of any real verdict object (the measured 5662-character
519
+ * message resolves on one of its first few tries, since its outermost `{` is
520
+ * also its first) and keeps every call to at most 200 parses regardless of
521
+ * message length.
522
+ *
523
+ * @param {string} trimmed
524
+ * @returns {string|null}
525
+ */
526
+ function suffixJsonCandidate(trimmed) {
527
+ const offsets = [];
528
+ for (let index = 0; index < trimmed.length; index += 1) {
529
+ const char = trimmed[index];
530
+ if (char === "{" || char === "[") offsets.push(index);
531
+ }
532
+ const floor = Math.max(0, offsets.length - SUFFIX_CANDIDATE_LIMIT);
533
+ for (let index = offsets.length - 1; index >= floor; index -= 1) {
534
+ const candidate = trimmed.slice(offsets[index]);
535
+ try {
536
+ JSON.parse(candidate);
537
+ return candidate;
538
+ } catch {
539
+ // This suffix is not JSON; keep trying earlier bracket offsets.
540
+ }
541
+ }
542
+ return null;
543
+ }
544
+
501
545
  /**
502
546
  * Extract a JSON value from a provider response that may carry prose, taking
503
547
  * the last parseable suffix or fenced JSON block.
@@ -514,16 +558,8 @@ export function extractJson(value) {
514
558
  } catch {
515
559
  // Not JSON as a whole: fall through to the suffix and fenced-block scans below.
516
560
  }
517
- const lines = trimmed.split(/\r?\n/u);
518
- for (let index = lines.length - 1; index > 0; index -= 1) {
519
- const candidate = lines.slice(index).join("\n").trim();
520
- try {
521
- JSON.parse(candidate);
522
- return candidate;
523
- } catch {
524
- // This suffix is not JSON; keep trying earlier line boundaries.
525
- }
526
- }
561
+ const suffix = suffixJsonCandidate(trimmed);
562
+ if (suffix !== null) return suffix;
527
563
  const blocks = [...value.matchAll(/```json\s*([\s\S]*?)```/giu)];
528
564
  for (const block of blocks.reverse()) {
529
565
  const candidate = block[1].trim();
@@ -254,6 +254,22 @@ export function declaredVerificationCommands(contract) {
254
254
  return [...commands.values()];
255
255
  }
256
256
 
257
+ /**
258
+ * Environment names removed before spawning a timing candidate. The probe
259
+ * only measures how long a command takes; a subtraction of a named few, not
260
+ * an allowlist, so PATH, HOME and every ordinary variable the command needs
261
+ * to run at all still pass through unchanged.
262
+ */
263
+ const SIDE_EFFECT_ENV_KEYS = [
264
+ NOTIFY_BIN_ENV, // a measurement must not notify a human
265
+ "FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
266
+ "FABERUN_CLAUDE_BIN", // same, for the claude harness
267
+ "FABERUN_AGY_BIN", // same, for the agy harness
268
+ "FABERUN_DSH_BIN", // same, for the dsh harness
269
+ "FABERUN_ZCODE_BIN", // same, for the zcode harness
270
+ "FABERUN_EXEC_JSONL_BIN", // same, for the exec-jsonl harness
271
+ ];
272
+
257
273
  /**
258
274
  * Run every declared verification command once and report what it actually
259
275
  * costs against the timeout the contract gives it.
@@ -275,6 +291,8 @@ export function declaredVerificationCommands(contract) {
275
291
  export function timeVerificationCommands(contract, probes = {}) {
276
292
  const now = probes.now ?? (() => Date.now());
277
293
  const run = probes.run ?? spawnSync;
294
+ const env = { ...process.env };
295
+ for (const key of SIDE_EFFECT_ENV_KEYS) delete env[key];
278
296
  return declaredVerificationCommands(contract).map((command) => {
279
297
  const label = command.argv.join(" ");
280
298
  const name = `verification timing · ${label}`;
@@ -288,6 +306,7 @@ export function timeVerificationCommands(contract, probes = {}) {
288
306
  timeout: ceilingSec * 1_000,
289
307
  stdio: "ignore",
290
308
  encoding: "utf8",
309
+ env,
291
310
  });
292
311
  const seconds = (now() - startedAt) / 1_000;
293
312
  const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
@@ -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}}
@@ -185,18 +185,18 @@ function tabBodyHtml(tab, detail) {
185
185
  function drawerHtml(drawer, tab) {
186
186
  if (!drawer) return `<section id="drawer"><h2>Run drawer</h2><div class="card" style="padding:16px 18px"><p class="empty">select a run above</p></div></section>`;
187
187
  const rows = drawer.nodes.map((node) => `<tr class="clickable ${node.id === drawer.selectedNodeId ? "selected" : ""}" data-node="${esc(node.id)}">
188
- <td>${pill(node.status)}</td>
188
+ <td>${pill(node.status)}${node.executionPhase === "candidate" ? ` <span class="dim">· candidate verification</span>` : node.executionPhase === "verification" ? ` <span class="dim">· verification</span>` : ""}</td>
189
189
  <td class="mono">${esc(node.id)}</td>
190
190
  <td class="num">${node.attempt}</td>
191
191
  <td class="mono">${esc(node.model ?? "–")}</td>
192
192
  <td class="num">${fmtDur(node.elapsedMs)}</td>
193
193
  <td class="num">${fmtUsd(node.costUsd)}</td>
194
- <td>${node.verdict ? pill(node.verdict) : ""}</td>
194
+ <td>${node.gateOutcome ? pill(node.gateOutcome) : ""}</td>
195
195
  <td class="goal" title="${esc(node.note ?? "")}">${esc(node.note ?? "")}</td>
196
196
  </tr>`).join("");
197
197
  const tabs = TABS.map(([key, label]) => `<button data-tab="${key}" class="${tab === key ? "active" : ""}">${esc(label)}</button>`).join("");
198
198
  return `<section id="drawer"><h2>Run drawer · ${esc(drawer.runId)}</h2><div class="card" style="padding:16px 18px">
199
- <table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>verdict</th><th>note</th></tr></thead>
199
+ <table><thead><tr><th>state</th><th>node</th><th>try</th><th>model</th><th>elapsed</th><th>USD</th><th>gate</th><th>note</th></tr></thead>
200
200
  <tbody>${rows}</tbody></table>
201
201
  <div class="tabs">${tabs}</div>
202
202
  <div id="tabbody">${tabBodyHtml(tab, drawer.detail)}</div>
@@ -294,7 +294,8 @@ function nodeRow(runDir, statusNode) {
294
294
  model: node.runtime?.model ?? null,
295
295
  elapsedMs: elapsedMsBetween(node.startedAt, closed ? node.updatedAt : null),
296
296
  costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
297
- verdict: node.gate?.verdict ?? null,
297
+ gateOutcome: statusNode.gateOutcome ?? null,
298
+ executionPhase: statusNode.executionPhase ?? null,
298
299
  note: statusNode.note ?? null,
299
300
  errorCode: statusNode.errorCode ?? null,
300
301
  blockedBy: statusNode.blockedBy ?? [],