faberun 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +152 -100
  2. package/package.json +8 -2
  3. package/skills/faberun/SKILL.md +6 -5
  4. package/skills/faberun/references/contract.md +23 -11
  5. package/skills/faberun/references/engineering.md +3 -1
  6. package/skills/faberun/references/operations.md +19 -12
  7. package/skills/faberun/references/rules.md +3 -1
  8. package/src/campaign/chain.mjs +6 -2
  9. package/src/campaign/index.mjs +17 -1
  10. package/src/campaign/metrics.mjs +3 -3
  11. package/src/cli/brand.mjs +2 -1
  12. package/src/cli/setup.mjs +109 -30
  13. package/src/cli/skills.mjs +308 -8
  14. package/src/cli.mjs +2 -1
  15. package/src/contract/final-verification.mjs +31 -2
  16. package/src/contract/index.mjs +27 -24
  17. package/src/contract/runtime.mjs +5 -1
  18. package/src/contract/task-packet.mjs +20 -9
  19. package/src/contract/verification.mjs +1 -1
  20. package/src/engine/backoff.mjs +1 -1
  21. package/src/engine/dispatch.mjs +5 -3
  22. package/src/engine/gate.mjs +12 -0
  23. package/src/engine/process-identity.mjs +39 -0
  24. package/src/engine/prompts.mjs +18 -0
  25. package/src/engine/resume.mjs +2 -2
  26. package/src/engine/review.mjs +9 -1
  27. package/src/engine/run-command.mjs +23 -2
  28. package/src/engine/run-identity.mjs +14 -0
  29. package/src/engine/scheduler.mjs +45 -12
  30. package/src/engine/settle.mjs +29 -0
  31. package/src/engine/supervise.mjs +32 -6
  32. package/src/engine/verify.mjs +98 -9
  33. package/src/harnesses/agy/index.mjs +3 -0
  34. package/src/harnesses/claude/index.mjs +5 -0
  35. package/src/harnesses/codex/index.mjs +3 -0
  36. package/src/harnesses/dsh/index.mjs +26 -0
  37. package/src/harnesses/exec-jsonl/index.mjs +2 -0
  38. package/src/harnesses/index.mjs +10 -3
  39. package/src/harnesses/replay/index.mjs +2 -0
  40. package/src/harnesses/zcode/index.mjs +3 -0
  41. package/src/host/preflight.mjs +5 -1
  42. package/src/notify/index.mjs +45 -2
  43. package/src/repo/source-identity.mjs +4 -3
  44. package/src/report/render.mjs +128 -49
  45. package/src/web/index.html +1 -1
@@ -22,9 +22,12 @@ const POINTER_ATTENTION_CHARS = 80;
22
22
  /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
23
23
  /** @typedef {import("../contract/index.mjs").NodeStatus} NodeStatus */
24
24
  /** @typedef {Record<string, unknown>} JsonObject */
25
+ /** @typedef {"priced"|"partial"|"unpriced"|"none"} CostProvenance */
26
+ /** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
25
27
  /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
26
- /** @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[]}} StatusPayloadNode */
27
- /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
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}} StatusPayloadNode */
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 */
28
31
 
29
32
  /** The glyph each terminal state prints in a status table. */
30
33
  export const MARK = {
@@ -89,7 +92,7 @@ export function renderStatus(runDir) {
89
92
  node.note ?? "-",
90
93
  ]));
91
94
  }
92
- lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · cost ${compactCost(usage.costUsd)}`);
95
+ lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${compactCost(usage.costUsd)}`);
93
96
  return `${lines.join("\n")}\n`;
94
97
  }
95
98
 
@@ -97,7 +100,9 @@ export function renderStatus(runDir) {
97
100
  * The node the operator should look at right now, formatted the same way
98
101
  * the dashboard's now strip is (TECH-SPEC lean, section 4, item 1): the
99
102
  * active node's elapsed time and cost so far, or an idle line once every
100
- * node has settled.
103
+ * node has settled. A node awaiting a controller verification command
104
+ * appends which one, `k/n`, and its argv, so a minutes-long suite is not
105
+ * silent between ticks.
101
106
  *
102
107
  * @param {StatusPayload} payload
103
108
  * @param {number} now epoch ms
@@ -105,11 +110,29 @@ export function renderStatus(runDir) {
105
110
  */
106
111
  function nowLine(payload, now) {
107
112
  const active = activeStatusNode(payload.nodes);
108
- if (active) return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}`;
113
+ if (active) {
114
+ const progress = active.verificationProgress;
115
+ const verification = progress ? ` · verification ${progress.index}/${progress.total} · ${progress.argv}` : "";
116
+ return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}${verification}`;
117
+ }
109
118
  const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
110
119
  return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
111
120
  }
112
121
 
122
+ /**
123
+ * The `verificationProgress` a controller verification pass leaves on a
124
+ * running node's persisted `verification` record — nested there rather than
125
+ * as its own node-snapshot field, since `VerificationState`'s own validator
126
+ * accepts extra keys where the node snapshot's does not.
127
+ *
128
+ * @param {NodeSnapshot} node
129
+ * @returns {VerificationProgress|null}
130
+ */
131
+ function verificationProgress(node) {
132
+ const verification = /** @type {{progress?: VerificationProgress}|null|undefined} */ (node.verification);
133
+ return verification?.progress ?? null;
134
+ }
135
+
113
136
  /**
114
137
  * A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
115
138
  * settled into a terminal state, `startedAt` to `now` while it is still
@@ -179,30 +202,38 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
179
202
  cacheReadInputTokens: usage.cacheReadInputTokens,
180
203
  costUsd: usage.costUsd,
181
204
  },
205
+ roles: roleUsage(nodes),
182
206
  controller: controllerStatus(runDir, nodes).status,
183
207
  identityWarnings,
184
208
  summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
185
- nodes: nodes.map((node) => ({
186
- id: node.id,
187
- status: node.status,
188
- phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
189
- executionPhase: node.phase,
190
- runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
191
- workerRuntime: workerRuntimeLabel(node),
192
- continuation: continuationMode(node),
193
- attempt: node.attempt,
194
- revisions: node.revisions,
195
- startedAt: node.startedAt ?? null,
196
- updatedAt: node.updatedAt ?? null,
197
- usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
198
- costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
199
- verdict: node.gate?.verdict ?? null,
200
- pendingHandoff: pendingHandoff(node),
201
- note: statusNote(node),
202
- scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
203
- errorCode: node.error?.code ?? null,
204
- blockedBy: node.blockedBy ?? [],
205
- })),
209
+ nodes: nodes.map((node) => {
210
+ const progress = verificationProgress(node);
211
+ return {
212
+ id: node.id,
213
+ status: node.status,
214
+ phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
215
+ // A running controller verification command does not move
216
+ // `node.phase` (it stays `worker`, the phase that dispatched it), so
217
+ // the surface that shows it is computed here rather than persisted.
218
+ executionPhase: progress ? "verification" : node.phase,
219
+ runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
220
+ workerRuntime: workerRuntimeLabel(node),
221
+ continuation: continuationMode(node),
222
+ attempt: node.attempt,
223
+ revisions: node.revisions,
224
+ startedAt: node.startedAt ?? null,
225
+ updatedAt: node.updatedAt ?? null,
226
+ usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
227
+ costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
228
+ verdict: node.gate?.verdict ?? null,
229
+ pendingHandoff: pendingHandoff(node),
230
+ note: statusNote(node),
231
+ scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
232
+ errorCode: node.error?.code ?? null,
233
+ blockedBy: node.blockedBy ?? [],
234
+ verificationProgress: progress,
235
+ };
236
+ }),
206
237
  };
207
238
  }
208
239
 
@@ -361,8 +392,8 @@ export function renderReport(runDir) {
361
392
  lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, node.attempt ?? 0, node.revisions ?? 0, runtime, compactTokens(usage.inputTokens), compactTokens(usage.outputTokens), compactTokens(usage.cacheReadInputTokens), formatCost(cost), note]));
362
393
  }
363
394
  totals.costUsd = aggregateCost.costUsd;
364
- const roles = roleCosts(nodes);
365
- lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${formatCost(aggregateCost)}`);
395
+ const roles = roleUsage(nodes);
396
+ lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${formatRole(roles.worker)} · judge ${formatRole(roles.judge)} · cost ${formatCost(aggregateCost)}`);
366
397
  return `${lines.join("\n")}\n`;
367
398
  }
368
399
 
@@ -401,9 +432,9 @@ export function renderReportJson(runDir) {
401
432
  const aggregateCost = aggregateCostProjection(costs);
402
433
  totals.costUsd = aggregateCost.costUsd;
403
434
  totals.costStatus = aggregateCost.status;
404
- const roles = roleCosts(nodes);
405
- totals.workerCostUsd = roles.worker;
406
- totals.judgeCostUsd = roles.judge;
435
+ const roles = roleUsage(nodes);
436
+ totals.workerCostUsd = roles.worker.costUsd;
437
+ totals.judgeCostUsd = roles.judge.costUsd;
407
438
  const payload = {
408
439
  schemaVersion: 1,
409
440
  run: basename(runDir),
@@ -411,6 +442,7 @@ export function renderReportJson(runDir) {
411
442
  campaignId: contract.campaignId,
412
443
  summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
413
444
  totals,
445
+ roles,
414
446
  nodes: listed,
415
447
  };
416
448
  return `${JSON.stringify(payload, null, 2)}\n`;
@@ -621,39 +653,86 @@ function nodeNote(node) {
621
653
  /** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
622
654
 
623
655
  /**
624
- * Per-role cost, summed from the invocation ledger alone. A role that appears
625
- * only on invocations that all carry a provider cost is `known`; a role with no
626
- * invocation at all, or with any invocation whose cost is missing, is `null`.
627
- * That is deliberately not zero: an unavailable or partial role cost must not
628
- * fabricate `$0`, and summing only the known invocations would understate a
629
- * partial one. The invocation ledger is the single source, so the node's own
630
- * `costUsd` (itself the sum of these invocations) is never added on top and
631
- * cannot double-count.
656
+ * Per-role usage from the invocation ledger alone, with the provenance that
657
+ * says whether the role's cost total is honest. A role whose invocations all
658
+ * carry a provider cost is `priced` and its `costUsd` is the sum; a role with
659
+ * any unpriced invocation is `partial` (some priced) or `unpriced` (none), and
660
+ * its `costUsd` stays null rather than summing the known half and understating
661
+ * it. A role with no invocation is `none`. Token totals are always carried, so
662
+ * a role the provider would not price still reads as work.
632
663
  *
633
664
  * @param {NodeSnapshot[]} nodes
634
- * @returns {{worker: number|null, judge: number|null}}
665
+ * @returns {{worker: RoleUsage, judge: RoleUsage}}
635
666
  */
636
- export function roleCosts(nodes) {
637
- /** @type {Record<"worker"|"judge", {total: number, present: number, unknown: number}>} */
667
+ export function roleUsage(nodes) {
668
+ /** @type {Record<"worker"|"judge", {total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}>} */
638
669
  const roles = {
639
- worker: { total: 0, present: 0, unknown: 0 },
640
- judge: { total: 0, present: 0, unknown: 0 },
670
+ worker: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
671
+ judge: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
641
672
  };
642
673
  for (const node of nodes) {
643
674
  for (const invocation of node.invocations ?? []) {
644
675
  if (invocation.role !== "worker" && invocation.role !== "judge") continue;
645
676
  const bucket = roles[invocation.role];
646
677
  const cost = finite(invocation.costUsd);
647
- if (cost === null) bucket.unknown += 1;
678
+ if (cost === null) bucket.unpriced += 1;
648
679
  else {
649
- bucket.present += 1;
680
+ bucket.priced += 1;
650
681
  bucket.total += cost;
651
682
  }
683
+ bucket.inputTokens += finite(invocation.usage?.inputTokens) ?? 0;
684
+ bucket.outputTokens += finite(invocation.usage?.outputTokens) ?? 0;
685
+ bucket.cacheReadInputTokens += finite(invocation.usage?.cacheReadInputTokens) ?? 0;
652
686
  }
653
687
  }
654
- const complete = (/** @type {{present: number, unknown: number, total: number}} */ bucket) =>
655
- bucket.present > 0 && bucket.unknown === 0 ? bucket.total : null;
656
- return { worker: complete(roles.worker), judge: complete(roles.judge) };
688
+ return { worker: summarizeRole(roles.worker), judge: summarizeRole(roles.judge) };
689
+ }
690
+
691
+ /**
692
+ * @param {{total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} bucket
693
+ * @returns {RoleUsage}
694
+ */
695
+ function summarizeRole(bucket) {
696
+ const costProvenance = bucket.priced > 0
697
+ ? (bucket.unpriced > 0 ? "partial" : "priced")
698
+ : (bucket.unpriced > 0 ? "unpriced" : "none");
699
+ return {
700
+ costUsd: costProvenance === "priced" ? bucket.total : null,
701
+ costProvenance,
702
+ inputTokens: bucket.inputTokens,
703
+ outputTokens: bucket.outputTokens,
704
+ cacheReadInputTokens: bucket.cacheReadInputTokens,
705
+ pricedInvocations: bucket.priced,
706
+ unpricedInvocations: bucket.unpriced,
707
+ };
708
+ }
709
+
710
+ /**
711
+ * Per-role cost, summed from the invocation ledger alone. Kept for the closing
712
+ * artifacts, which render only the dollar cell; `roleUsage` carries the
713
+ * provenance that cell cannot express.
714
+ *
715
+ * @param {NodeSnapshot[]} nodes
716
+ * @returns {{worker: number|null, judge: number|null}}
717
+ */
718
+ export function roleCosts(nodes) {
719
+ const roles = roleUsage(nodes);
720
+ return { worker: roles.worker.costUsd, judge: roles.judge.costUsd };
721
+ }
722
+
723
+ /**
724
+ * A role's cell in a totals line: the dollar total when every invocation is
725
+ * priced, `unpriced` with the token totals it did record when any invocation
726
+ * has no cost, and `-` only when the role has no invocation at all. An
727
+ * unpriced role reads as real work instead of vanishing into a dash.
728
+ *
729
+ * @param {RoleUsage} role
730
+ * @returns {string}
731
+ */
732
+ function formatRole(role) {
733
+ if (role.costUsd !== null) return compactCost(role.costUsd);
734
+ if (role.costProvenance === "none") return "-";
735
+ return `unpriced (in ${compactTokens(role.inputTokens)} · out ${compactTokens(role.outputTokens)} · cache ${compactTokens(role.cacheReadInputTokens)})`;
657
736
  }
658
737
 
659
738
  /**
@@ -208,7 +208,7 @@ function handoffHtml(handoff) {
208
208
  }
209
209
 
210
210
  function renderSectionsHtml(snapshot) {
211
- if (!snapshot?.selectedCampaignId) return `<p class="empty">no campaign found under .runs; initialize one with: runner.mjs campaign init</p>`;
211
+ if (!snapshot?.selectedCampaignId) return `<p class="empty">no campaign found under .runs; initialize one with: faberun campaign init</p>`;
212
212
  return nowHtml(snapshot.now) + needsYouHtml(snapshot.needsYou) + runsHtml(snapshot.runs, state.runId) + drawerHtml(snapshot.drawer, state.tab) + handoffHtml(snapshot.handoff);
213
213
  }
214
214