faberun 0.3.0 → 0.7.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 (51) hide show
  1. package/README.md +152 -100
  2. package/package.json +10 -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/campaign.mjs +2 -0
  13. package/src/cli/contract.mjs +2 -0
  14. package/src/cli/manual.mjs +341 -0
  15. package/src/cli/seat.mjs +2 -0
  16. package/src/cli/setup.mjs +109 -30
  17. package/src/cli/skills.mjs +310 -8
  18. package/src/cli.mjs +3 -2
  19. package/src/contract/final-verification.mjs +31 -2
  20. package/src/contract/index.mjs +28 -25
  21. package/src/contract/runtime.mjs +5 -1
  22. package/src/contract/snapshot.mjs +7 -1
  23. package/src/contract/task-packet.mjs +20 -9
  24. package/src/contract/verification.mjs +1 -1
  25. package/src/engine/backoff.mjs +1 -1
  26. package/src/engine/dispatch.mjs +31 -4
  27. package/src/engine/gate.mjs +12 -0
  28. package/src/engine/process-identity.mjs +39 -0
  29. package/src/engine/prompts.mjs +18 -0
  30. package/src/engine/resume.mjs +2 -2
  31. package/src/engine/review.mjs +9 -1
  32. package/src/engine/run-command.mjs +23 -2
  33. package/src/engine/run-identity.mjs +14 -0
  34. package/src/engine/scheduler.mjs +45 -12
  35. package/src/engine/settle.mjs +29 -0
  36. package/src/engine/supervise.mjs +32 -6
  37. package/src/engine/verify.mjs +98 -9
  38. package/src/harnesses/agy/index.mjs +3 -0
  39. package/src/harnesses/claude/index.mjs +5 -0
  40. package/src/harnesses/codex/index.mjs +3 -0
  41. package/src/harnesses/dsh/index.mjs +26 -0
  42. package/src/harnesses/exec-jsonl/index.mjs +2 -0
  43. package/src/harnesses/index.mjs +10 -3
  44. package/src/harnesses/replay/index.mjs +2 -0
  45. package/src/harnesses/zcode/index.mjs +3 -0
  46. package/src/host/preflight.mjs +5 -1
  47. package/src/notify/index.mjs +45 -2
  48. package/src/repo/source-identity.mjs +4 -3
  49. package/src/report/final.mjs +3 -2
  50. package/src/report/render.mjs +134 -51
  51. 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, declaredReadBytes: number|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,8 @@ 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
+ const readBytes = payload.nodes.reduce((total, node) => total + (node.declaredReadBytes ?? 0), 0);
96
+ 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)} · read ${compactTokens(readBytes)}`);
93
97
  return `${lines.join("\n")}\n`;
94
98
  }
95
99
 
@@ -97,7 +101,9 @@ export function renderStatus(runDir) {
97
101
  * The node the operator should look at right now, formatted the same way
98
102
  * the dashboard's now strip is (TECH-SPEC lean, section 4, item 1): the
99
103
  * active node's elapsed time and cost so far, or an idle line once every
100
- * node has settled.
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.
101
107
  *
102
108
  * @param {StatusPayload} payload
103
109
  * @param {number} now epoch ms
@@ -105,11 +111,29 @@ export function renderStatus(runDir) {
105
111
  */
106
112
  function nowLine(payload, now) {
107
113
  const active = activeStatusNode(payload.nodes);
108
- if (active) return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}`;
114
+ if (active) {
115
+ const progress = active.verificationProgress;
116
+ 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}`;
118
+ }
109
119
  const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
110
120
  return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
111
121
  }
112
122
 
123
+ /**
124
+ * The `verificationProgress` a controller verification pass leaves on a
125
+ * running node's persisted `verification` record — nested there rather than
126
+ * as its own node-snapshot field, since `VerificationState`'s own validator
127
+ * accepts extra keys where the node snapshot's does not.
128
+ *
129
+ * @param {NodeSnapshot} node
130
+ * @returns {VerificationProgress|null}
131
+ */
132
+ function verificationProgress(node) {
133
+ const verification = /** @type {{progress?: VerificationProgress}|null|undefined} */ (node.verification);
134
+ return verification?.progress ?? null;
135
+ }
136
+
113
137
  /**
114
138
  * A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
115
139
  * settled into a terminal state, `startedAt` to `now` while it is still
@@ -179,30 +203,39 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
179
203
  cacheReadInputTokens: usage.cacheReadInputTokens,
180
204
  costUsd: usage.costUsd,
181
205
  },
206
+ roles: roleUsage(nodes),
182
207
  controller: controllerStatus(runDir, nodes).status,
183
208
  identityWarnings,
184
209
  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
- })),
210
+ nodes: nodes.map((node) => {
211
+ const progress = verificationProgress(node);
212
+ return {
213
+ id: node.id,
214
+ status: node.status,
215
+ phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
216
+ // A running controller verification command does not move
217
+ // `node.phase` (it stays `worker`, the phase that dispatched it), so
218
+ // the surface that shows it is computed here rather than persisted.
219
+ executionPhase: progress ? "verification" : node.phase,
220
+ runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
221
+ workerRuntime: workerRuntimeLabel(node),
222
+ continuation: continuationMode(node),
223
+ attempt: node.attempt,
224
+ revisions: node.revisions,
225
+ startedAt: node.startedAt ?? null,
226
+ updatedAt: node.updatedAt ?? null,
227
+ usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
228
+ costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
229
+ verdict: node.gate?.verdict ?? null,
230
+ pendingHandoff: pendingHandoff(node),
231
+ note: statusNote(node),
232
+ scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
233
+ errorCode: node.error?.code ?? null,
234
+ blockedBy: node.blockedBy ?? [],
235
+ verificationProgress: progress,
236
+ declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
237
+ };
238
+ }),
206
239
  };
207
240
  }
208
241
 
@@ -361,8 +394,8 @@ export function renderReport(runDir) {
361
394
  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
395
  }
363
396
  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)}`);
397
+ const roles = roleUsage(nodes);
398
+ 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
399
  return `${lines.join("\n")}\n`;
367
400
  }
368
401
 
@@ -376,13 +409,14 @@ export function renderReportJson(runDir) {
376
409
  const { contract, nodes } = loadRun(runDir);
377
410
  const counts = new Map();
378
411
  for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
379
- /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null}} */
380
- const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null };
412
+ /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null, declaredReadBytes: number}} */
413
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null, declaredReadBytes: 0 };
381
414
  const costs = nodes.map(costProjection);
382
415
  const listed = nodes.map((node, index) => {
383
416
  const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
384
417
  for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (["inputTokens", "outputTokens", "cacheReadInputTokens"])) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
385
418
  const cost = costs[index];
419
+ totals.declaredReadBytes += typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : 0;
386
420
  return {
387
421
  id: node.id,
388
422
  status: node.status,
@@ -396,14 +430,15 @@ export function renderReportJson(runDir) {
396
430
  costStatus: cost.status,
397
431
  continuation: continuationMode(node),
398
432
  note: nodeNote(node),
433
+ declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
399
434
  };
400
435
  });
401
436
  const aggregateCost = aggregateCostProjection(costs);
402
437
  totals.costUsd = aggregateCost.costUsd;
403
438
  totals.costStatus = aggregateCost.status;
404
- const roles = roleCosts(nodes);
405
- totals.workerCostUsd = roles.worker;
406
- totals.judgeCostUsd = roles.judge;
439
+ const roles = roleUsage(nodes);
440
+ totals.workerCostUsd = roles.worker.costUsd;
441
+ totals.judgeCostUsd = roles.judge.costUsd;
407
442
  const payload = {
408
443
  schemaVersion: 1,
409
444
  run: basename(runDir),
@@ -411,6 +446,7 @@ export function renderReportJson(runDir) {
411
446
  campaignId: contract.campaignId,
412
447
  summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
413
448
  totals,
449
+ roles,
414
450
  nodes: listed,
415
451
  };
416
452
  return `${JSON.stringify(payload, null, 2)}\n`;
@@ -621,39 +657,86 @@ function nodeNote(node) {
621
657
  /** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
622
658
 
623
659
  /**
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.
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.
632
667
  *
633
668
  * @param {NodeSnapshot[]} nodes
634
- * @returns {{worker: number|null, judge: number|null}}
669
+ * @returns {{worker: RoleUsage, judge: RoleUsage}}
635
670
  */
636
- export function roleCosts(nodes) {
637
- /** @type {Record<"worker"|"judge", {total: number, present: number, unknown: number}>} */
671
+ export function roleUsage(nodes) {
672
+ /** @type {Record<"worker"|"judge", {total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}>} */
638
673
  const roles = {
639
- worker: { total: 0, present: 0, unknown: 0 },
640
- judge: { total: 0, present: 0, unknown: 0 },
674
+ worker: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
675
+ judge: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
641
676
  };
642
677
  for (const node of nodes) {
643
678
  for (const invocation of node.invocations ?? []) {
644
679
  if (invocation.role !== "worker" && invocation.role !== "judge") continue;
645
680
  const bucket = roles[invocation.role];
646
681
  const cost = finite(invocation.costUsd);
647
- if (cost === null) bucket.unknown += 1;
682
+ if (cost === null) bucket.unpriced += 1;
648
683
  else {
649
- bucket.present += 1;
684
+ bucket.priced += 1;
650
685
  bucket.total += cost;
651
686
  }
687
+ bucket.inputTokens += finite(invocation.usage?.inputTokens) ?? 0;
688
+ bucket.outputTokens += finite(invocation.usage?.outputTokens) ?? 0;
689
+ bucket.cacheReadInputTokens += finite(invocation.usage?.cacheReadInputTokens) ?? 0;
652
690
  }
653
691
  }
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) };
692
+ return { worker: summarizeRole(roles.worker), judge: summarizeRole(roles.judge) };
693
+ }
694
+
695
+ /**
696
+ * @param {{total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} bucket
697
+ * @returns {RoleUsage}
698
+ */
699
+ function summarizeRole(bucket) {
700
+ const costProvenance = bucket.priced > 0
701
+ ? (bucket.unpriced > 0 ? "partial" : "priced")
702
+ : (bucket.unpriced > 0 ? "unpriced" : "none");
703
+ return {
704
+ costUsd: costProvenance === "priced" ? bucket.total : null,
705
+ costProvenance,
706
+ inputTokens: bucket.inputTokens,
707
+ outputTokens: bucket.outputTokens,
708
+ cacheReadInputTokens: bucket.cacheReadInputTokens,
709
+ pricedInvocations: bucket.priced,
710
+ unpricedInvocations: bucket.unpriced,
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Per-role cost, summed from the invocation ledger alone. Kept for the closing
716
+ * artifacts, which render only the dollar cell; `roleUsage` carries the
717
+ * provenance that cell cannot express.
718
+ *
719
+ * @param {NodeSnapshot[]} nodes
720
+ * @returns {{worker: number|null, judge: number|null}}
721
+ */
722
+ export function roleCosts(nodes) {
723
+ const roles = roleUsage(nodes);
724
+ return { worker: roles.worker.costUsd, judge: roles.judge.costUsd };
725
+ }
726
+
727
+ /**
728
+ * A role's cell in a totals line: the dollar total when every invocation is
729
+ * priced, `unpriced` with the token totals it did record when any invocation
730
+ * has no cost, and `-` only when the role has no invocation at all. An
731
+ * unpriced role reads as real work instead of vanishing into a dash.
732
+ *
733
+ * @param {RoleUsage} role
734
+ * @returns {string}
735
+ */
736
+ function formatRole(role) {
737
+ if (role.costUsd !== null) return compactCost(role.costUsd);
738
+ if (role.costProvenance === "none") return "-";
739
+ return `unpriced (in ${compactTokens(role.inputTokens)} · out ${compactTokens(role.outputTokens)} · cache ${compactTokens(role.cacheReadInputTokens)})`;
657
740
  }
658
741
 
659
742
  /**
@@ -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