omp-conductor 0.19.6 → 0.20.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 (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
package/src/setup.ts CHANGED
@@ -21,12 +21,14 @@
21
21
  * `src/gh.ts` exposing `gh()` (throwing) over `ghTry()` (classifying).
22
22
  */
23
23
 
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
- import { dirname, join } from "node:path";
26
+ import { dirname, join, relative } from "node:path";
27
27
  import { parse as parseYaml } from "yaml";
28
28
  import {
29
+ AGENTS_BRIEF_NAME,
29
30
  COMPOSE_BANNER,
31
+ CONSOLE_BRIEF_NAME,
30
32
  ORCHESTRATOR_BRIEF_NAME,
31
33
  POLICY_BRIEF_NAME,
32
34
  composeOrchestrator,
@@ -51,6 +53,7 @@ import {
51
53
  } from "./config.ts";
52
54
  import { graphProjectPath, graphRepos } from "./graph.ts";
53
55
  import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
56
+ import { shellQuote } from "./shell.ts";
54
57
  import {
55
58
  CONFIG_VERSION,
56
59
  DEFAULT_ARM_PROOF,
@@ -168,6 +171,12 @@ export interface SetupAnswers {
168
171
  * to survive an amend of some other area (#369).
169
172
  */
170
173
  groomBelow?: GroomTrigger;
174
+ /**
175
+ * The OMP model role the daemon's own grooming scouts run under (#1041).
176
+ * Hand-edited like {@link groomBelow} — the wizard never asks — so it exists
177
+ * here only to survive an amend of some other area (#369).
178
+ */
179
+ groomRole?: string;
171
180
  /**
172
181
  * Ordered fallback worker models and the provider-failure threshold that
173
182
  * starts them. The wizard never asks for either — they are hand-edited —
@@ -583,7 +592,7 @@ export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
583
592
  "operator can take it back by editing this paragraph.",
584
593
  };
585
594
 
586
- export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
595
+ export { AGENTS_BRIEF_NAME, CONSOLE_BRIEF_NAME, ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
587
596
 
588
597
  /** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
589
598
  const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
@@ -591,6 +600,13 @@ const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator
591
600
  /** Shipped POLICY.md scaffold — Releases, Project context, Reporting, Amendments. */
592
601
  const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
593
602
 
603
+ /**
604
+ * Shipped console floor — the operator console's role, delivery contract,
605
+ * authority and boundaries. Render-only: it has no `POLICY.md` half, is never
606
+ * composed, and is never classified by `inspectBriefLayout`.
607
+ */
608
+ const CONSOLE_TEMPLATE_PATH = join(import.meta.dir, "briefs", "console.md");
609
+
594
610
  /**
595
611
  * `repo` writes labels and closes issues; `project` moves cards on the board.
596
612
  * Both are load-bearing for an unattended loop, so a missing one is reported
@@ -1014,6 +1030,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
1014
1030
  : {}),
1015
1031
  // A documented hand-edited key, so an unrelated amend must not delete it.
1016
1032
  ...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
1033
+ // Same contract for the daemon's grooming-scout role: hand-edited, and an
1034
+ // amend that dropped it would silently move grooming back to the harness
1035
+ // default model (#1041).
1036
+ ...(a.groomRole === undefined ? {} : { groomRole: a.groomRole }),
1017
1037
  // The failover chain is hand-edited too: dropping it on an unrelated amend
1018
1038
  // would pin every run of the project onto a dead provider again (#286).
1019
1039
  ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
@@ -1256,6 +1276,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1256
1276
  // what keeps the rewritten config identical to the one that was read.
1257
1277
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
1258
1278
  if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
1279
+ if (p.groomRole !== undefined) answers.groomRole = p.groomRole;
1259
1280
  if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
1260
1281
  if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
1261
1282
  if (p.workerEscalationModel !== undefined) answers.workerEscalationModel = p.workerEscalationModel;
@@ -1365,6 +1386,205 @@ export function renderPolicyForProject(p: ProjectConfig): string {
1365
1386
  return renderBriefTemplate(shippedPolicyTemplate(), briefVarsForProject(p));
1366
1387
  }
1367
1388
 
1389
+ // ------------------------------------------------------- the operator console
1390
+ //
1391
+ // The console is the second session of the two-session split: the tick brain
1392
+ // keeps its pane and never receives an operator turn, and this one owns the
1393
+ // Telegram conversation 24/7. Everything below provisions its workspace, and
1394
+ // the whole point of the shape is what it does NOT do:
1395
+ //
1396
+ // - **No tick config.** The tick extension activates only when
1397
+ // `.conductor-tick.json` exists in the session cwd, so a console cwd
1398
+ // without one is inert by construction rather than by a runtime check that
1399
+ // could be got wrong. Nothing here ever writes that file.
1400
+ // - **No POLICY.md, no compose, no layout classification.** The floor is
1401
+ // rendered and written; there is no operator half to merge, so
1402
+ // `refreshComposedBrief` and `inspectBriefLayout` never see this file.
1403
+ // - **Derived, never stamped.** The cwd is a function of the state dir and
1404
+ // the project name, in TypeScript here and in the same shape in
1405
+ // `recover.sh`. A config key would be a second copy of the answer, and the
1406
+ // copy nobody updated is the one a pane would `cd` into.
1407
+
1408
+ /**
1409
+ * The console session's own directory: `<stateDir>/console/<project>`.
1410
+ *
1411
+ * Under the state root rather than beside the project's worktrees because the
1412
+ * console is a fleet surface, not a checkout — and because a directory the
1413
+ * worker machinery never touches cannot be force-removed by a re-claim.
1414
+ */
1415
+ export function consoleCwdForProject(p: ProjectConfig): string {
1416
+ return join(stateDir(), "console", p.name);
1417
+ }
1418
+
1419
+ /** The rendered console floor inside {@link consoleCwdForProject}. */
1420
+ export function consoleBriefPathForProject(p: ProjectConfig): string {
1421
+ return join(consoleCwdForProject(p), CONSOLE_BRIEF_NAME);
1422
+ }
1423
+
1424
+ /** Shipped console floor template, placeholders and all. */
1425
+ export function shippedConsoleTemplate(): string {
1426
+ return readFileSync(CONSOLE_TEMPLATE_PATH, "utf8");
1427
+ }
1428
+
1429
+ /**
1430
+ * Rendered console floor for a configured project.
1431
+ *
1432
+ * The same `briefVarsForProject` coordinates the orchestrator floor gets, so a
1433
+ * placeholder either template names is filled from one table — a console that
1434
+ * shipped a literal `{{PROJECT}}` to a session would be reading a template,
1435
+ * not a brief.
1436
+ */
1437
+ export function renderConsoleForProject(p: ProjectConfig): string {
1438
+ return renderBriefTemplate(shippedConsoleTemplate(), briefVarsForProject(p));
1439
+ }
1440
+
1441
+ /**
1442
+ * The shell line that launches the console pane, produced here so
1443
+ * `omp-conductor console` and `recover.sh` cannot print two different commands
1444
+ * for one surface.
1445
+ *
1446
+ * `dmOwner` is the Telegram DM force-claim, and it is a host-level judgement,
1447
+ * not a preference: one state dir may have exactly one DM owner, so a host
1448
+ * with several fleet cwds launches its consoles without it and lets inbound
1449
+ * messages arrive by forum-topic routing instead. Nothing else in this package
1450
+ * sets `OMP_TELEGRAM_DM_OWNER`.
1451
+ */
1452
+ export function consolePaneCommand(opts: { cwd: string; dmOwner: boolean }): string {
1453
+ // Quoted because the reader copies what a surface prints: a state root under
1454
+ // a path with a space or a `$` must still `cd` where it says.
1455
+ return `cd ${shellQuote(opts.cwd)} && ${opts.dmOwner ? "OMP_TELEGRAM_DM_OWNER=1 " : ""}omp`;
1456
+ }
1457
+
1458
+ /**
1459
+ * The `AGENTS.md` link in the console cwd and what provisioning will do about
1460
+ * it, with the same never-clobber discipline the fleet pane's link gets: an
1461
+ * already-correct symlink is kept, a stale one is replaced, and a **regular
1462
+ * file** is an operator's own brief — reported and left untouched.
1463
+ */
1464
+ export interface ConsoleLinkPlan {
1465
+ path: string;
1466
+ /** Relative target inside the symlink — `CONSOLE.md`, its own directory. */
1467
+ target: string;
1468
+ action: "create" | "update" | "keep" | "skip";
1469
+ /** Why {@link action} is `skip`; present only then. */
1470
+ skippedReason?: string;
1471
+ }
1472
+
1473
+ /** What provisioning the console workspace will write, before it writes it. */
1474
+ export interface ConsolePlan {
1475
+ /** The directory the console pane runs in. Created by the write. */
1476
+ cwd: string;
1477
+ /** The rendered floor: `create` on a fresh cwd, `keep` when already current. */
1478
+ brief: { path: string; content: string; action: "create" | "update" | "keep" };
1479
+ link: ConsoleLinkPlan;
1480
+ }
1481
+
1482
+ /**
1483
+ * Plans the console workspace for a project. Pure: reads the filesystem,
1484
+ * writes nothing, so `setup host` can show the whole thing before consent and
1485
+ * `omp-conductor console` can report what it did.
1486
+ */
1487
+ export function planConsole(p: ProjectConfig): ConsolePlan {
1488
+ const cwd = consoleCwdForProject(p);
1489
+ const briefPath = consoleBriefPathForProject(p);
1490
+ const content = renderConsoleForProject(p);
1491
+ const linkPath = join(cwd, AGENTS_BRIEF_NAME);
1492
+ // Relative, and inside the same directory, so the whole console cwd stays
1493
+ // movable — the same reason the fleet pane's link is relative.
1494
+ const target = relative(dirname(linkPath), briefPath);
1495
+ // An unreadable existing brief is planned as `update`: rewriting it is the
1496
+ // repair, and a plan that reported `keep` on a file it could not read would
1497
+ // leave the console without a floor.
1498
+ let live: string | undefined;
1499
+ try {
1500
+ live = readFileSync(briefPath, "utf8");
1501
+ } catch {
1502
+ live = undefined;
1503
+ }
1504
+ return {
1505
+ cwd,
1506
+ brief: {
1507
+ path: briefPath,
1508
+ content,
1509
+ action: live === undefined ? (existsSync(briefPath) ? "update" : "create") : live === content ? "keep" : "update",
1510
+ },
1511
+ link: planConsoleLink(linkPath, target),
1512
+ };
1513
+ }
1514
+
1515
+ function planConsoleLink(path: string, target: string): ConsoleLinkPlan {
1516
+ let st;
1517
+ try {
1518
+ st = lstatSync(path);
1519
+ } catch {
1520
+ // ENOENT, and any unreadable parent, both collapse to a best-effort
1521
+ // create; a filesystem that refuses the link surfaces at write time.
1522
+ return { path, target, action: "create" };
1523
+ }
1524
+ if (st.isSymbolicLink()) {
1525
+ return { path, target, action: readlinkSync(path, "utf8") === target ? "keep" : "update" };
1526
+ }
1527
+ return {
1528
+ path,
1529
+ target,
1530
+ action: "skip",
1531
+ skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
1532
+ };
1533
+ }
1534
+
1535
+ /** What {@link writeConsole} wrote, plus anything it could not. */
1536
+ export interface ConsoleWrite {
1537
+ wrote: string[];
1538
+ /** Best-effort failures the caller surfaces; provisioning continues. */
1539
+ warnings: string[];
1540
+ }
1541
+
1542
+ /**
1543
+ * Materialises a planned console workspace. Idempotent: a `keep` brief and a
1544
+ * `keep` link touch nothing, so re-running `omp-conductor console` or
1545
+ * `setup host` rewrites nothing and reports an empty write.
1546
+ *
1547
+ * The link is re-`lstat`ed here rather than trusted from the plan, because the
1548
+ * entry can change across the consent gap: an operator who drops their own
1549
+ * `AGENTS.md` where a stale symlink was planned for replacement must keep it.
1550
+ */
1551
+ export function writeConsole(plan: ConsolePlan): ConsoleWrite {
1552
+ const wrote: string[] = [];
1553
+ const warnings: string[] = [];
1554
+ mkdirSync(plan.cwd, { recursive: true });
1555
+ if (plan.brief.action !== "keep") {
1556
+ writeFileSync(plan.brief.path, plan.brief.content);
1557
+ wrote.push(plan.brief.path);
1558
+ }
1559
+ if (plan.link.action === "create" || plan.link.action === "update") {
1560
+ const { path, target } = plan.link;
1561
+ try {
1562
+ let existing;
1563
+ try {
1564
+ existing = lstatSync(path);
1565
+ } catch {
1566
+ existing = undefined;
1567
+ }
1568
+ if (existing !== undefined && !existing.isSymbolicLink()) {
1569
+ warnings.push(
1570
+ `${path} is a regular file, not ours to overwrite; console AGENTS.md link skipped`,
1571
+ );
1572
+ } else if (existing !== undefined && readlinkSync(path, "utf8") === target) {
1573
+ // Already correct, however the plan read it.
1574
+ } else {
1575
+ if (existing !== undefined) rmSync(path, { force: true });
1576
+ symlinkSync(target, path);
1577
+ wrote.push(path);
1578
+ }
1579
+ } catch (err) {
1580
+ warnings.push(
1581
+ `could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`,
1582
+ );
1583
+ }
1584
+ }
1585
+ return { wrote, warnings };
1586
+ }
1587
+
1368
1588
  /**
1369
1589
  * Composed session brief: rendered floor + shared policy (when present) +
1370
1590
  * POLICY scaffold (or a caller's policy).
@@ -1811,6 +2031,12 @@ export function summarisePlan(
1811
2031
  ` ${"cap escalation".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerEscalationModel.trim()} (hand-edited)`,
1812
2032
  );
1813
2033
  }
2034
+ // The daemon spends real money grooming unattended (#1041), so which role it
2035
+ // spends it under belongs on the plan a human approves — hand-edited, like
2036
+ // the cap-escalation tier above it, and silent when nobody answered.
2037
+ if (a.groomRole !== undefined && a.groomRole.trim().length > 0) {
2038
+ lines.push(` ${"groom role".padEnd(CAPS_PLAN_KEY_PAD)}${a.groomRole.trim()} (hand-edited)`);
2039
+ }
1814
2040
 
1815
2041
  lines.push("", "escalation");
1816
2042
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
package/src/stats.ts CHANGED
@@ -17,10 +17,32 @@
17
17
  * the doctor `spend-telemetry` finding): a run whose `spendUsd` reads 0.00
18
18
  * means harness telemetry was absent, not that the run was free. Such runs are
19
19
  * counted separately as **unmetered** and never averaged into a per-issue cost.
20
+ *
21
+ * ## Attribution (Phase 4)
22
+ *
23
+ * Two additions, both about *where the money went* rather than what it bought:
24
+ *
25
+ * - **Per model.** Worker runs grouped by `resolvedModel` — the model the
26
+ * harness actually ran and therefore the one that was billed. Never grouped
27
+ * by the requested model: a requested model is not evidence of a resolved
28
+ * one, and a row that observed none groups under {@link UNRESOLVED_MODEL}
29
+ * rather than being guessed into a bucket.
30
+ * - **Daemon-owned sessions.** To-spec grooming and review adjudication run as
31
+ * the daemon's own sessions, not as worker runs, so their cost was logged and
32
+ * then lost. They arrive here as `sessionSpendSince` rows and are summed *per
33
+ * role*, deliberately outside the issue-journey aggregation: a role row in
34
+ * `runs` would corrupt the merged/settled counts, which is why the store
35
+ * keeps them in their own table.
36
+ *
37
+ * The orchestrator's own tick turns are absent from both, and that absence is
38
+ * reported rather than hidden ({@link StatsReport.unmeteredRoles}): the tick
39
+ * extension observes no usage events at all, so there is no number — and a
40
+ * `$0.00` for it would read as free.
20
41
  */
21
42
 
22
43
  import { utcDay } from "./store.ts";
23
- import type { RunRecord } from "./types.ts";
44
+ import { SESSION_SPEND_ROLES } from "./types.ts";
45
+ import type { RunRecord, SessionSpendRole, SessionSpendRow } from "./types.ts";
24
46
 
25
47
  /** The bounded window a report answers over. Day keys are UTC (`YYYY-MM-DD`). */
26
48
  export interface StatsWindow {
@@ -57,6 +79,51 @@ export interface RepoStats {
57
79
  failureClasses: Record<string, number>;
58
80
  }
59
81
 
82
+ /**
83
+ * Worker runs attributed to one billed model (Phase 4).
84
+ *
85
+ * Counted over every run in the outcomes this report counts — the failures
86
+ * included, because money spent losing is precisely what a per-model
87
+ * comparison exists to expose: a model that burns a budget and never merges
88
+ * must not read as cheap.
89
+ *
90
+ * So this is deliberately a *wider* figure than {@link RepoStats.spendUsd},
91
+ * which sums merged chains only. The two answer different questions ("what did
92
+ * delivered work cost" versus "what did this model cost us"), and they are not
93
+ * meant to add up to each other.
94
+ */
95
+ export interface ModelStats {
96
+ /** `resolvedModel`, or {@link UNRESOLVED_MODEL} when the run observed none. */
97
+ model: string;
98
+ /** Runs in the window on this model, across every issue. */
99
+ runs: number;
100
+ /** Runs whose state is `merged` — the attempt that actually delivered. */
101
+ merges: number;
102
+ /** Metered (spendUsd > 0) spend on this model, rounded to cents. */
103
+ spendUsd: number;
104
+ /** `spendUsd / merges`; `null` with no merge to divide by — never a zero,
105
+ * and never a division by zero. */
106
+ spendPerMerge: number | null;
107
+ /** Runs on this model whose spend read $0.00: telemetry absent, not free. */
108
+ unmeteredRuns: number;
109
+ }
110
+
111
+ /** One daemon-owned session role's accounting (Phase 4). */
112
+ export interface SessionRoleStats {
113
+ role: SessionSpendRole;
114
+ sessions: number;
115
+ turns: number;
116
+ /** Summed cost of the sessions that reported one, rounded to cents. */
117
+ spendUsd: number;
118
+ /**
119
+ * Sessions whose provider reported no cost at all. Distinct from a reported
120
+ * `0` by contract (`SessionSpendRow.spendUsd` is optional for exactly this
121
+ * reason), so this count is the honest "cost unknown" — never folded into
122
+ * `spendUsd` as a zero.
123
+ */
124
+ unmeteredSessions: number;
125
+ }
126
+
60
127
  /** The stable report shape `--json` prints; the human renderer reads it too. */
61
128
  export interface StatsReport {
62
129
  project: string;
@@ -72,6 +139,27 @@ export interface StatsReport {
72
139
  total: RepoStats;
73
140
  /** Per-repo aggregates of the same shape, busiest (by merges) first. */
74
141
  repos: RepoStats[];
142
+ /** Worker runs by billed model, busiest (by runs) first (Phase 4). */
143
+ models: ModelStats[];
144
+ /** Daemon-owned session spend by role, in `SESSION_SPEND_ROLES` order. */
145
+ sessions: SessionRoleStats[];
146
+ /**
147
+ * Metered spend on to-spec grooming sessions — the plan's `groomSpendUsd`,
148
+ * named at the top level because "what did keeping the queue full cost" is a
149
+ * question asked on its own. Same number as the `groom` row in
150
+ * {@link StatsReport.sessions}; `groomUnmeteredSessions` is the part of it
151
+ * whose cost is unknown rather than zero.
152
+ */
153
+ groomSpendUsd: number;
154
+ groomUnmeteredSessions: number;
155
+ /**
156
+ * Roles whose spend cannot be observed at all, so no row exists to sum and
157
+ * no total here includes them. Present in the shape so a consumer adding
158
+ * `total.spendUsd` to `sessions` knows what is still missing instead of
159
+ * reading the sum as the fleet's whole bill. Today: `orchestrator` — the
160
+ * tick extension observes no usage events.
161
+ */
162
+ unmeteredRoles: string[];
75
163
  }
76
164
 
77
165
  /** Terminal non-merged run states — everything the failure histogram counts. */
@@ -86,6 +174,16 @@ const FAILED_STATES: ReadonlySet<RunRecord["state"]> = new Set([
86
174
  /** Runs with no classified failure fall into this explicit bucket. */
87
175
  export const UNCLASSIFIED = "unclassified";
88
176
 
177
+ /**
178
+ * Runs that never observed a resolved model group here (Phase 4).
179
+ *
180
+ * Parenthesised so it cannot collide with a real model id, and explicit rather
181
+ * than omitted: a run predating the column, or one whose harness never reported
182
+ * the model it ran, still cost money, and dropping it would make the per-model
183
+ * spend quietly fail to add up to the total.
184
+ */
185
+ export const UNRESOLVED_MODEL = "(unresolved)";
186
+
89
187
  /** The timestamp a run is placed on the window timeline. Mirrors the store's
90
188
  * `COALESCE(endedAt, startedAt)` convention everywhere else in this package. */
91
189
  const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt;
@@ -216,11 +314,19 @@ export function parseStatsWindow(raw: string, now: number): StatsWindow | undefi
216
314
  return undefined;
217
315
  }
218
316
 
317
+ /**
318
+ * `sessions` is `store.sessionSpendSince(project, window.sinceEpochMs)`. It is
319
+ * optional because a caller with nothing to say about daemon-owned sessions —
320
+ * and every pre-Phase-4 caller — must still get a report; an empty list means
321
+ * "no grooming or adjudication in this window", which the renderer states as
322
+ * that rather than as a $0.00 bill.
323
+ */
219
324
  export function computeStats(args: {
220
325
  project: string;
221
326
  window: StatsWindow;
222
327
  ghCalls: number;
223
328
  runs: readonly RunRecord[];
329
+ sessions?: readonly SessionSpendRow[];
224
330
  }): StatsReport {
225
331
  const chains = new Map<number, RunRecord[]>();
226
332
  for (const run of args.runs) {
@@ -234,6 +340,7 @@ export function computeStats(args: {
234
340
  const failureRuns: { repo: string; cls: string }[] = [];
235
341
  let unmeteredRuns = 0;
236
342
  const unmeteredByRepo = new Map<string, number>();
343
+ const byModel = new Map<string, ModelBucket>();
237
344
 
238
345
  for (const chain of chains.values()) {
239
346
  // One issue merges once; if a weird history ever carried two merged rows,
@@ -262,6 +369,14 @@ export function computeStats(args: {
262
369
  unmeteredRuns += 1;
263
370
  unmeteredByRepo.set(run.repo, (unmeteredByRepo.get(run.repo) ?? 0) + 1);
264
371
  }
372
+ // Attributed over exactly the runs this report already counts, so the
373
+ // per-model breakdown adds up to the totals printed beside it. Grouped on
374
+ // the *resolved* model: that is the one that was billed.
375
+ const model = modelBucket(byModel, run.resolvedModel ?? UNRESOLVED_MODEL);
376
+ model.runs += 1;
377
+ if (run.state === "merged") model.merges += 1;
378
+ if (run.spendUsd > 0) model.spend += run.spendUsd;
379
+ else model.unmetered += 1;
265
380
  if (merged === undefined && FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs) {
266
381
  failureRuns.push({ repo: run.repo, cls: run.failureClass ?? UNCLASSIFIED });
267
382
  }
@@ -297,6 +412,21 @@ export function computeStats(args: {
297
412
  )
298
413
  .sort((a, b) => b.merged - a.merged || b.settled - a.settled || a.repo.localeCompare(b.repo));
299
414
 
415
+ const models = [...byModel.entries()]
416
+ .map(([model, b]) => ({
417
+ model,
418
+ runs: b.runs,
419
+ merges: b.merges,
420
+ spendUsd: cents(b.spend),
421
+ // No merge to divide by is "not measured", never a zero and never a
422
+ // division by zero — the same rule the per-issue cost follows.
423
+ spendPerMerge: b.merges === 0 ? null : cents(b.spend / b.merges),
424
+ unmeteredRuns: b.unmetered,
425
+ }))
426
+ .sort((a, b) => b.runs - a.runs || b.spendUsd - a.spendUsd || a.model.localeCompare(b.model));
427
+ const sessions = summarizeSessions(args.sessions ?? []);
428
+ const groom = sessions.find((s) => s.role === "groom");
429
+
300
430
  return {
301
431
  project: args.project,
302
432
  window: args.window,
@@ -304,9 +434,63 @@ export function computeStats(args: {
304
434
  empty: outcomes.length === 0,
305
435
  total: summarize({ repo: "(all)", outcomes, failureRuns, unmeteredRuns }),
306
436
  repos,
437
+ models,
438
+ sessions,
439
+ groomSpendUsd: groom?.spendUsd ?? 0,
440
+ groomUnmeteredSessions: groom?.unmeteredSessions ?? 0,
441
+ unmeteredRoles: [...UNMETERED_ROLES],
307
442
  };
308
443
  }
309
444
 
445
+ /** One model's accumulating totals while the chains are walked. */
446
+ interface ModelBucket {
447
+ runs: number;
448
+ merges: number;
449
+ spend: number;
450
+ unmetered: number;
451
+ }
452
+
453
+ function modelBucket(map: Map<string, ModelBucket>, model: string): ModelBucket {
454
+ let found = map.get(model);
455
+ if (found === undefined) {
456
+ found = { runs: 0, merges: 0, spend: 0, unmetered: 0 };
457
+ map.set(model, found);
458
+ }
459
+ return found;
460
+ }
461
+
462
+ /**
463
+ * Roles the fleet runs whose spend nothing can observe, so no row exists to
464
+ * sum. The orchestrator session is the case: its tick extension reads content
465
+ * text out of `message_start` and no usage event at all, so there is no number
466
+ * to record. Named in the report rather than left out, because a total that
467
+ * silently omits a whole role is the misreading this surface exists to prevent.
468
+ */
469
+ const UNMETERED_ROLES = ["orchestrator"] as const;
470
+
471
+ /**
472
+ * Daemon-owned session rows reduced per role, in `SESSION_SPEND_ROLES` order so
473
+ * the shape is stable whether or not a role ran in the window.
474
+ *
475
+ * A session whose `spendUsd` is absent is counted in `unmeteredSessions` and
476
+ * contributes nothing to `spendUsd`: the store's contract distinguishes "the
477
+ * provider reported no cost" from "the provider reported zero", and collapsing
478
+ * them here would throw away the only place that distinction exists.
479
+ */
480
+ function summarizeSessions(rows: readonly SessionSpendRow[]): SessionRoleStats[] {
481
+ return SESSION_SPEND_ROLES.map((role) => {
482
+ const mine = rows.filter((r) => r.role === role);
483
+ const metered = mine.filter((r) => r.spendUsd !== undefined);
484
+ return {
485
+ role,
486
+ sessions: mine.length,
487
+ turns: mine.reduce((sum, r) => sum + r.turns, 0),
488
+ spendUsd: cents(metered.reduce((sum, r) => sum + (r.spendUsd ?? 0), 0)),
489
+ unmeteredSessions: mine.length - metered.length,
490
+ };
491
+ });
492
+ }
493
+
310
494
  /** `N` and its unit, for a count line that must not print "0" as a measure. */
311
495
  function countLine(n: number, noun: string, plural: string): string {
312
496
  return `${n} ${n === 1 ? noun : plural}`;
@@ -350,17 +534,88 @@ function renderRepo(repo: RepoStats, indent: string): string {
350
534
  return lines.join("\n");
351
535
  }
352
536
 
537
+ /**
538
+ * The attribution block (Phase 4): where the money went, as opposed to what it
539
+ * bought. Rendered from the same report `--json` carries, and rendered even in
540
+ * the empty case — a dry queue is exactly when daemon-owned grooming runs, so
541
+ * hiding this behind "nothing settled" would hide the spend that had no
542
+ * outcome, which is the one an operator most wants to see.
543
+ */
544
+ function renderAttribution(report: StatsReport, indent: string): string[] {
545
+ const lines: string[] = [];
546
+ if (report.models.length > 0) {
547
+ lines.push(`${indent}per model:`);
548
+ for (const m of report.models) {
549
+ // Nothing metered at all is stated as unknown, not as "$0.00 metered":
550
+ // the dollar figure is true but reads as free, which is the exact
551
+ // misreading this whole surface exists to prevent.
552
+ const cost =
553
+ m.spendUsd === 0 && m.unmeteredRuns === m.runs
554
+ ? `cost unknown — no run on this model metered`
555
+ : m.spendPerMerge === null
556
+ ? `$${m.spendUsd.toFixed(2)} metered (no merge on this model to divide by)`
557
+ : `$${m.spendUsd.toFixed(2)} metered ($${m.spendPerMerge.toFixed(2)}/merge)`;
558
+ const unmetered =
559
+ m.unmeteredRuns === 0 || m.unmeteredRuns === m.runs
560
+ ? ""
561
+ : ` · ${countLine(m.unmeteredRuns, "run", "runs")} unmetered`;
562
+ lines.push(
563
+ `${indent} ${m.model} ${countLine(m.runs, "run", "runs")}, ` +
564
+ `${countLine(m.merges, "merge", "merges")} ${cost}${unmetered}`,
565
+ );
566
+ }
567
+ }
568
+ const ran = report.sessions.filter((s) => s.sessions > 0);
569
+ // Silence rather than a row of zeros: "no grooming sessions in this window"
570
+ // and "grooming cost nothing" are different claims, and only the first is true.
571
+ if (ran.length > 0) {
572
+ lines.push(`${indent}daemon sessions:`);
573
+ for (const s of ran) {
574
+ // Same rule as the per-model line: an unmetered session's cost is
575
+ // unknown, so it is a count, never a zero folded into the dollar figure.
576
+ if (s.spendUsd === 0 && s.unmeteredSessions === s.sessions) {
577
+ lines.push(
578
+ `${indent} ${s.role} ${countLine(s.sessions, "session", "sessions")}, ` +
579
+ `${countLine(s.turns, "turn", "turns")} cost unknown — no session metered`,
580
+ );
581
+ continue;
582
+ }
583
+ const unknown =
584
+ s.unmeteredSessions === 0
585
+ ? ""
586
+ : ` · ${countLine(s.unmeteredSessions, "session", "sessions")} unmetered (cost unknown, not $0.00)`;
587
+ lines.push(
588
+ `${indent} ${s.role} ${countLine(s.sessions, "session", "sessions")}, ` +
589
+ `${countLine(s.turns, "turn", "turns")} $${s.spendUsd.toFixed(2)} metered${unknown}`,
590
+ );
591
+ }
592
+ }
593
+ if (report.unmeteredRoles.length > 0) {
594
+ lines.push(
595
+ `${indent} ${report.unmeteredRoles.join(", ")} unmetered — no usage events are observable for ` +
596
+ `${report.unmeteredRoles.length === 1 ? "this session" : "these sessions"}, so the cost is unknown and no figure above includes it`,
597
+ );
598
+ }
599
+ return lines;
600
+ }
601
+
353
602
  /** Render the human form of the same {@link StatsReport} `--json` prints. */
354
603
  export function renderStatsHuman(report: StatsReport): string {
355
604
  const w = report.window;
356
605
  const header = `${report.project} — outcomes ${w.sinceDay} → ${w.untilDay}`;
357
606
  if (report.empty) {
358
- return `${header}\n nothing settled in the window — no runs recorded, so there are no measurements to report yet (a fresh store or an idle fleet).\n`;
607
+ return [
608
+ header,
609
+ " nothing settled in the window — no runs recorded, so there are no measurements to report yet (a fresh store or an idle fleet).",
610
+ ...renderAttribution(report, " "),
611
+ "",
612
+ ].join("\n");
359
613
  }
360
614
  const lines = [header, renderRepo(report.total, " "), ` gh api ${report.ghCalls} tracked ${report.ghCalls === 1 ? "call" : "calls"} in the window`];
361
615
  if (report.repos.length > 0) {
362
616
  lines.push(" per repo:");
363
617
  for (const repo of report.repos) lines.push(renderRepo(repo, " "));
364
618
  }
619
+ lines.push(...renderAttribution(report, " "));
365
620
  return `${lines.join("\n")}\n`;
366
621
  }