omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/setup.ts CHANGED
@@ -29,8 +29,10 @@ import {
29
29
  POLICY_BRIEF_NAME,
30
30
  composeOrchestrator,
31
31
  policyPathForRoot,
32
+ readSharedPolicy,
32
33
  refreshComposedBrief,
33
34
  renderBriefTemplate,
35
+ sharedPolicyPath,
34
36
  } from "./brief-upgrade.ts";
35
37
  import {
36
38
  clonePolicy,
@@ -55,6 +57,7 @@ import {
55
57
  WEEKDAYS,
56
58
  type BaseFreshness,
57
59
  type BehindBaseAction,
60
+ type AuthorityHolder,
58
61
  type Caps,
59
62
  type ConductorConfig,
60
63
  type DigestCadence,
@@ -411,6 +414,31 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
411
414
  " one is a hard boundary, not a preference.",
412
415
  };
413
416
 
417
+ /**
418
+ * The Promotion paragraph, worded from `authority.promotion`.
419
+ *
420
+ * The queue label is a sign-off: adding it to an issue lets a worker claim it
421
+ * and starts spend. POLICY.md must state who may do that — never a blank the
422
+ * floor defers into — and an unstated owner reads operator-only, because a
423
+ * gate nobody claimed must not resolve to the session that wants to use it.
424
+ *
425
+ * The paragraph refers to "the queue label" rather than naming it: the label
426
+ * itself is rendered into the section heading from config, and a second copy
427
+ * here would be a label that can disagree with the one the daemon reads.
428
+ */
429
+ export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
430
+ human:
431
+ "**Default: the operator promotes.** Adding the queue label to an issue is promotion, and\n" +
432
+ "promotion is the operator's: the orchestrator grooms, routes and proposes, but the label\n" +
433
+ "goes on only when the operator puts it there — it is the sign-off that starts spend. To\n" +
434
+ "delegate, re-run `omp-conductor setup` or replace this paragraph with one naming the\n" +
435
+ "orchestrator as promoter.",
436
+ orchestrator:
437
+ "**Delegated in setup: the orchestrator promotes.** Adding the queue label to an issue is\n" +
438
+ "promotion, and the orchestrator may do it after grooming the issue, one at a time. The\n" +
439
+ "operator can take it back by editing this paragraph.",
440
+ };
441
+
414
442
  export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
415
443
 
416
444
  /** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
@@ -427,12 +455,14 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
427
455
  const REQUIRED_SCOPES = ["repo", "project"] as const;
428
456
 
429
457
  /** GitHub's own palette, so the tracker reads at a glance: green means queued,
430
- * blue means moving, amber means waiting on you, red means it gave up. */
458
+ * blue means moving, amber means waiting on you, red means it gave up, and
459
+ * purple is routing — the operator's input, the one label the loop never writes. */
431
460
  const LABEL_COLOURS = {
432
461
  queue: "0e8a16",
433
462
  inProgress: "1d76db",
434
463
  blocked: "fbca04",
435
464
  failed: "b60205",
465
+ routing: "6f42c1",
436
466
  } as const;
437
467
 
438
468
  interface GhResult {
@@ -514,11 +544,18 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
514
544
  }
515
545
 
516
546
  /**
517
- * The four labels the loop reads and writes, and which already exist. Read-only:
518
- * this is what the operator is shown before being asked to consent to creation.
547
+ * Every label setup wants on the tracker, before existence is known: the queue
548
+ * and state labels the loop reads and writes, plus one routing label per routed
549
+ * repo. Pure — the prefix and the repo keys are already answered at this point
550
+ * in the interview — which is what lets a test pin the whole set without `gh`.
551
+ *
552
+ * Routing labels are provisioned, never applied: `route()` requires exactly one
553
+ * `<prefix><repo>` label per issue and treats zero or two as unroutable, so a
554
+ * tracker without them can queue nothing. Creating them queues nothing either —
555
+ * applying one, with the queue label, stays the operator's sign-off.
519
556
  */
520
- export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
521
- const wanted: Omit<LabelPlan, "exists">[] = [
557
+ export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
558
+ return [
522
559
  {
523
560
  name: a.queueLabel,
524
561
  colour: LABEL_COLOURS.queue,
@@ -539,13 +576,28 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
539
576
  colour: LABEL_COLOURS.failed,
540
577
  description: "The conductor gave up on this issue after its retry budget",
541
578
  },
579
+ ...a.targetRepos.map((r) => ({
580
+ name: `${a.routingLabelPrefix}${r.name}`,
581
+ colour: LABEL_COLOURS.routing,
582
+ description: `Routes this issue to the ${r.name} checkout`,
583
+ })),
542
584
  ];
585
+ }
543
586
 
544
- const existing = await listLabelNames(trackerRepo);
545
-
546
- // GitHub label names are unique case-insensitively, so an operator who answers
547
- // "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
548
- // creation is pending that would then fail.
587
+ /**
588
+ * Turns wanted labels into the plan the operator is shown, deduped
589
+ * case-insensitively and marked against what already exists.
590
+ *
591
+ * GitHub label names are unique case-insensitively, so an operator who answers
592
+ * "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
593
+ * creation is pending that would then fail — and a multi-repo project whose
594
+ * names case-fold onto each other or onto a lifecycle label wants one creation,
595
+ * not two.
596
+ */
597
+ export function planAgainstLabels(
598
+ wanted: readonly Omit<LabelPlan, "exists">[],
599
+ existing: ReadonlySet<string>,
600
+ ): LabelPlan[] {
549
601
  const seen = new Set<string>();
550
602
  const plan: LabelPlan[] = [];
551
603
  for (const w of wanted) {
@@ -557,6 +609,18 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
557
609
  return plan;
558
610
  }
559
611
 
612
+ /**
613
+ * The labels a tracker needs, and which already exist. Read-only: this is what
614
+ * the operator is shown before being asked to consent to creation.
615
+ *
616
+ * The queue and state labels are the ones the loop reads and writes; the
617
+ * routing labels (one per repo) are the operator's input that makes an issue
618
+ * routable, and setup provisions them only so there is something to apply.
619
+ */
620
+ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
621
+ return planAgainstLabels(wantedLabels(a), await listLabelNames(trackerRepo));
622
+ }
623
+
560
624
  /** Lower-cased label names currently on the repo. Throws rather than guessing:
561
625
  * a repo whose labels cannot be listed cannot be serviced either. */
562
626
  async function listLabelNames(trackerRepo: string): Promise<Set<string>> {
@@ -1023,6 +1087,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
1023
1087
  QUEUE_LABEL: p.queueLabel,
1024
1088
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
1025
1089
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
1090
+ PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
1026
1091
  POLICY_SOURCE: policySourceLine(p),
1027
1092
  };
1028
1093
  }
@@ -1073,14 +1138,19 @@ export function renderPolicyForProject(p: ProjectConfig): string {
1073
1138
  }
1074
1139
 
1075
1140
  /**
1076
- * Composed session brief: rendered floor + POLICY scaffold (or a caller's policy).
1141
+ * Composed session brief: rendered floor + shared policy (when present) +
1142
+ * POLICY scaffold (or a caller's policy).
1077
1143
  *
1078
1144
  * Setup writes the policy half to `POLICY.md` and this compose to
1079
- * `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md so package
1080
- * floor updates apply without brief-upgrade.
1145
+ * `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md and the live
1146
+ * host-wide shared policy, so package floor updates apply without brief-upgrade.
1081
1147
  */
1082
1148
  export function renderBriefForProject(p: ProjectConfig, policyText?: string): string {
1083
- return composeOrchestrator(renderFloorForProject(p), policyText ?? renderPolicyForProject(p));
1149
+ return composeOrchestrator(
1150
+ renderFloorForProject(p),
1151
+ policyText ?? renderPolicyForProject(p),
1152
+ readSharedPolicy(),
1153
+ );
1084
1154
  }
1085
1155
 
1086
1156
  /** Wizard-time path, via the project the answers describe. */
@@ -1094,7 +1164,7 @@ export function renderOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}
1094
1164
  // would write" and "what setup writes" cannot diverge — the preview an operator
1095
1165
  // consents to is the file they get.
1096
1166
  const project = buildProject(a);
1097
- return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose));
1167
+ return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose), readSharedPolicy());
1098
1168
  }
1099
1169
 
1100
1170
  /** `POLICY.md` for these answers: the project's own template plus the interview. */
@@ -1149,7 +1219,7 @@ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {})
1149
1219
  mkdirSync(dirname(orchestratorPath), { recursive: true });
1150
1220
  const policy = renderPolicy(a, prose);
1151
1221
  writeFileSync(policyPath, policy);
1152
- writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
1222
+ writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy, readSharedPolicy()));
1153
1223
  return orchestratorPath;
1154
1224
  }
1155
1225
 
@@ -1246,7 +1316,8 @@ function releaseLines(j: OperatorJudgment): string[] {
1246
1316
  }
1247
1317
 
1248
1318
  /**
1249
- * Recompose `ORCHESTRATOR.md` from the package floor + live `POLICY.md`.
1319
+ * Recompose `ORCHESTRATOR.md` from the package floor + host-wide shared policy
1320
+ * (when present) + live `POLICY.md`.
1250
1321
  *
1251
1322
  * Returns false when POLICY.md is missing (caller should migrate or set up).
1252
1323
  */
@@ -1255,6 +1326,7 @@ export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
1255
1326
  policyPath: policyPathForProject(p),
1256
1327
  orchestratorPath: briefPathForProject(p),
1257
1328
  floor: renderFloorForProject(p),
1329
+ sharedPolicyPath: sharedPolicyPath(),
1258
1330
  });
1259
1331
  }
1260
1332
 
@@ -1355,6 +1427,15 @@ function describeAvailabilityDays(days: readonly Weekday[]): string {
1355
1427
  : days.join(",");
1356
1428
  }
1357
1429
 
1430
+ /**
1431
+ * The caps table's key column. Wide enough that the longest cap key,
1432
+ * `maxConcurrentWorkersPerRepo`, cannot fuse with its value — the very defect
1433
+ * that made #448's consent screen read `maxConcurrentWorkersPerRepo1` while
1434
+ * the daemon ran 3. Exported so tests pin the rendered table against the same
1435
+ * column the renderer uses.
1436
+ */
1437
+ export const CAPS_PLAN_KEY_PAD = 28;
1438
+
1358
1439
  /**
1359
1440
  * Everything that would change, as plain text, with no side effects at all.
1360
1441
  *
@@ -1368,11 +1449,15 @@ export function summarisePlan(
1368
1449
  scopes: ScopeCheck,
1369
1450
  labels: LabelPlan[],
1370
1451
  tg: TelegramPresence,
1452
+ defaults: Caps = DEFAULT_CAPS,
1371
1453
  ): string {
1372
1454
  const project = buildProject(a);
1373
- // Caps are shown against the shipped baseline: the summary describes what
1374
- // these answers mean on their own, before any hand-edited global block.
1375
- const effective = resolveCaps(project, DEFAULT_CAPS);
1455
+ // Caps are resolved against the caller's defaults — the loaded config's
1456
+ // global block on a re-run, the shipped baseline on a first run, where the
1457
+ // two are the same object. That is the exact baseline the daemon resolves
1458
+ // against, so the consent screen and runtime cannot disagree (#372/#448).
1459
+ // The shipped baseline itself is never labelled "effective".
1460
+ const effective = resolveCaps(project, defaults);
1376
1461
  const states = Object.values(a.stateLabels).join(", ");
1377
1462
 
1378
1463
  const lines: string[] = [];
@@ -1441,10 +1526,10 @@ export function summarisePlan(
1441
1526
  lines.push("", "caps (effective)");
1442
1527
  for (const [key, value] of Object.entries(effective)) {
1443
1528
  const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
1444
- lines.push(` ${key.padEnd(22)}${String(value)}${answered}`);
1529
+ lines.push(` ${key.padEnd(CAPS_PLAN_KEY_PAD)}${String(value)}${answered}`);
1445
1530
  }
1446
1531
  if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
1447
- lines.push(` ${"worker model".padEnd(22)}${a.workerModel.trim()} (answered)`);
1532
+ lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
1448
1533
  }
1449
1534
 
1450
1535
  lines.push("", "escalation");
@@ -1482,7 +1567,7 @@ export function summarisePlan(
1482
1567
  const granted = RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] === "orchestrator");
1483
1568
  lines.push(
1484
1569
  "",
1485
- `authority merge=${a.authority.merge} release=${a.authority.release}`,
1570
+ `authority merge=${a.authority.merge} release=${a.authority.release} promotion=${a.authority.promotion ?? DEFAULT_AUTHORITY.promotion}`,
1486
1571
  `tool gate ${
1487
1572
  granted.length === 0
1488
1573
  ? "every release/deploy shape blocked for workers and the orchestrator"
@@ -1602,13 +1687,16 @@ export type AmendAreaId = (typeof AMEND_AREA_IDS)[number];
1602
1687
  * `describe` is the reason the pick-list is worth anything — an operator picking
1603
1688
  * blind from eight nouns cannot tell which one holds the setting they came to
1604
1689
  * change, so every row carries its own current value. It reads only the config,
1605
- * so the whole menu can be rendered and reviewed without a terminal.
1690
+ * so the whole menu can be rendered and reviewed without a terminal. The caps
1691
+ * row resolves inherited values against the defaults the caller is amending —
1692
+ * the loaded config's global block on a re-run — so the menu and the daemon
1693
+ * agree on what a project actually runs (#448).
1606
1694
  */
1607
1695
  export const AMEND_AREAS: {
1608
1696
  readonly [K in AmendAreaId]: {
1609
1697
  readonly name: string;
1610
1698
  readonly asks: string;
1611
- readonly describe: (p: ProjectConfig) => string;
1699
+ readonly describe: (p: ProjectConfig, defaults?: Caps) => string;
1612
1700
  };
1613
1701
  } = {
1614
1702
  tracker: {
@@ -1636,8 +1724,8 @@ export const AMEND_AREAS: {
1636
1724
  // an area no menu offers is a setting only a full re-interview can reach.
1637
1725
  name: "caps & worker model",
1638
1726
  asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
1639
- describe: (p) => {
1640
- const c = resolveCaps(p, DEFAULT_CAPS);
1727
+ describe: (p, defaults = DEFAULT_CAPS) => {
1728
+ const c = resolveCaps(p, defaults);
1641
1729
  const answered = Object.keys(p.caps).length > 0;
1642
1730
  const spend =
1643
1731
  c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
@@ -1664,7 +1752,7 @@ export const AMEND_AREAS: {
1664
1752
  },
1665
1753
  authority: {
1666
1754
  name: "authority",
1667
- asks: "who lands green PRs, who cuts releases, then one question per release/deploy shape",
1755
+ asks: "who lands green PRs, who cuts releases, who promotes, then one question per release/deploy shape",
1668
1756
  describe: (p) => {
1669
1757
  const grants = resolveReleaseGrants(p);
1670
1758
  const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
@@ -1673,7 +1761,8 @@ export const AMEND_AREAS: {
1673
1761
  // set that mutates a running environment and the one #122 cost us. The full
1674
1762
  // table is in `status` and in the amend summary.
1675
1763
  return (
1676
- `merge=${p.authority.merge}, release=${p.authority.release}, tools: ` +
1764
+ `merge=${p.authority.merge}, release=${p.authority.release}, ` +
1765
+ `promote=${p.authority.promotion ?? DEFAULT_AUTHORITY.promotion}, tools: ` +
1677
1766
  `${granted.length === 0 ? "all blocked" : `${granted.length}/${RELEASE_SHAPES.length} granted`}` +
1678
1767
  `, deploy=${grants.deploy}`
1679
1768
  );
@@ -1742,10 +1831,10 @@ const AMEND_LABEL_MAX = 96;
1742
1831
  * its own text alone — and it is the value, not the noun, that tells them
1743
1832
  * whether this is the row they came for.
1744
1833
  */
1745
- export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string; description: string }[] {
1834
+ export function amendChoices(p: ProjectConfig, defaults: Caps = DEFAULT_CAPS): { id: AmendAreaId; label: string; description: string }[] {
1746
1835
  return AMEND_AREA_IDS.map((id) => {
1747
1836
  const area = AMEND_AREAS[id];
1748
- const current = area.describe(p);
1837
+ const current = area.describe(p, defaults);
1749
1838
  return {
1750
1839
  id,
1751
1840
  label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`,
@@ -1762,11 +1851,18 @@ export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string
1762
1851
  * mutation it authorises — creating labels, writing the config, replacing a
1763
1852
  * brief — and a delta alone names none of them. What this adds is the sentence
1764
1853
  * the operator is actually looking for: one area changed, everything else came
1765
- * back off disk.
1854
+ * back off disk. Inherited caps on both sides of the "was"/"now" compare
1855
+ * against the same `defaults`, so a changed per-repo limit reads as a change
1856
+ * and a carried value reads as no change (#448).
1766
1857
  */
1767
- export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: SetupAnswers): string {
1858
+ export function summariseAmend(
1859
+ area: AmendAreaId,
1860
+ before: ProjectConfig,
1861
+ a: SetupAnswers,
1862
+ defaults: Caps = DEFAULT_CAPS,
1863
+ ): string {
1768
1864
  const it = AMEND_AREAS[area];
1769
- const was = it.describe(before);
1865
+ const was = it.describe(before, defaults);
1770
1866
  // The brief is a decision, not a config key, so its "after" is what the wizard
1771
1867
  // is about to do rather than what a rebuilt project would say.
1772
1868
  const now =
@@ -1774,7 +1870,7 @@ export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: Setu
1774
1870
  ? a.writeOrchestratorBrief
1775
1871
  ? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}`
1776
1872
  : "not written — left exactly as it is"
1777
- : it.describe(buildProject(a));
1873
+ : it.describe(buildProject(a), defaults);
1778
1874
 
1779
1875
  const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name);
1780
1876
  const lines = [`amending ${it.name} — project ${before.name}`];
package/src/stats.ts ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * `stats` — what the fleet accomplished and at what cost, over a window
3
+ * (#282, legibility 5/5).
4
+ *
5
+ * Pure by construction: every fact arrives as injected run rows, a window and
6
+ * a gh-call count, so the aggregation is testable without a daemon or a store
7
+ * (the same seam split `doctor` uses). The CLI command in ./commands/stats.ts
8
+ * supplies the rows through read-only store queries, then renders the same
9
+ * numbers the --json shape carries.
10
+ *
11
+ * The unit of outcome is the **issue journey**, never the run row: an issue
12
+ * that took six attempts is one merged outcome with six runs and one lead
13
+ * time. Counting rows would make a stranded issue look like productive
14
+ * throughput, which is the exact misreading this surface exists to prevent.
15
+ *
16
+ * Spend semantics follow the store's own convention (README Limitations, and
17
+ * the doctor `spend-telemetry` finding): a run whose `spendUsd` reads 0.00
18
+ * means harness telemetry was absent, not that the run was free. Such runs are
19
+ * counted separately as **unmetered** and never averaged into a per-issue cost.
20
+ */
21
+
22
+ import type { RunRecord } from "./types.ts";
23
+
24
+ /** The bounded window a report answers over. Day keys are UTC (`YYYY-MM-DD`). */
25
+ export interface StatsWindow {
26
+ sinceDay: string;
27
+ untilDay: string;
28
+ sinceEpochMs: number;
29
+ untilEpochMs: number;
30
+ }
31
+
32
+ /** One aggregate, per repo and for the whole selection. */
33
+ export interface RepoStats {
34
+ /** The repo name; `"(all)"` on the totals row. */
35
+ repo: string;
36
+ /** Distinct issues whose PR merged and settled inside the window. */
37
+ merged: number;
38
+ /** `merged` plus issues whose terminal state inside the window did not merge. */
39
+ settled: number;
40
+ /** `merged / settled`; `null` when nothing settled (never a fake 0). */
41
+ mergeRate: number | null;
42
+ /** Mean attempts per merged issue, over the whole continuation chain. */
43
+ runsPerMerged: number | null;
44
+ /** First queue-label claim → merge settlement, over merged issues, in ms. */
45
+ leadTimeMedianMs: number | null;
46
+ leadTimeP90Ms: number | null;
47
+ /** Metered (spendUsd > 0) spend across merged chains, rounded to cents. */
48
+ spendUsd: number;
49
+ /** `spendUsd / merged issues with ≥1 metered run`; null when none. */
50
+ spendPerMerged: number | null;
51
+ /** Runs examined with `spendUsd === 0` — telemetry absent, not free. */
52
+ unmeteredRuns: number;
53
+ /** Merged issues whose whole chain metered nothing — cost unknown, not $0. */
54
+ unmeteredMerged: number;
55
+ /** Terminal non-merged runs in the window, bucketed by failure class. */
56
+ failureClasses: Record<string, number>;
57
+ }
58
+
59
+ /** The stable report shape `--json` prints; the human renderer reads it too. */
60
+ export interface StatsReport {
61
+ project: string;
62
+ window: StatsWindow;
63
+ /** Tracked GitHub API calls over the window's UTC days (#198). */
64
+ ghCalls: number;
65
+ /**
66
+ * True when nothing settled in the window: a fresh store or an idle fleet.
67
+ * A consumer must render this as "no measurements", never as zero outcomes.
68
+ */
69
+ empty: boolean;
70
+ /** The whole selection (`--project NAME`, or the sole configured project). */
71
+ total: RepoStats;
72
+ /** Per-repo aggregates of the same shape, busiest (by merges) first. */
73
+ repos: RepoStats[];
74
+ }
75
+
76
+ /** Terminal non-merged run states — everything the failure histogram counts. */
77
+ const FAILED_STATES: ReadonlySet<RunRecord["state"]> = new Set([
78
+ "failed",
79
+ "blocked",
80
+ "killed",
81
+ "orphaned",
82
+ "stopped",
83
+ ]);
84
+
85
+ /** Runs with no classified failure fall into this explicit bucket. */
86
+ export const UNCLASSIFIED = "unclassified";
87
+
88
+ /** The timestamp a run is placed on the window timeline. Mirrors the store's
89
+ * `COALESCE(endedAt, startedAt)` convention everywhere else in this package. */
90
+ const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt;
91
+
92
+ /** One issue's settled outcome, reduced from its full attempt chain. */
93
+ interface IssueOutcome {
94
+ repo: string;
95
+ merged: boolean;
96
+ /** Attempts in the whole chain (continuations collapse into one journey). */
97
+ attempts: number;
98
+ /** First claim → merge settlement, in ms. Present iff `merged`. */
99
+ leadTimeMs?: number;
100
+ /** Sum of metered (spendUsd > 0) spend across the chain, in USD. */
101
+ meteredSpend: number;
102
+ /** Runs in the chain whose spend actually metered. */
103
+ meteredRuns: number;
104
+ }
105
+
106
+ function median(sorted: readonly number[]): number {
107
+ if (sorted.length === 0) return Number.NaN;
108
+ const lo = sorted[Math.floor((sorted.length - 1) / 2)]!;
109
+ const hi = sorted[Math.ceil((sorted.length - 1) / 2)]!;
110
+ return (lo + hi) / 2;
111
+ }
112
+
113
+ /** Nearest-rank p90: at least 90% of the samples are at or below the result. */
114
+ function p90(sorted: readonly number[]): number {
115
+ if (sorted.length === 0) return Number.NaN;
116
+ return sorted[Math.min(sorted.length - 1, Math.ceil(0.9 * sorted.length) - 1)]!;
117
+ }
118
+
119
+ function cents(usd: number): number {
120
+ return Math.round(usd * 100) / 100;
121
+ }
122
+
123
+ function emptyRepo(repo: string): RepoStats {
124
+ return {
125
+ repo,
126
+ merged: 0,
127
+ settled: 0,
128
+ mergeRate: null,
129
+ runsPerMerged: null,
130
+ leadTimeMedianMs: null,
131
+ leadTimeP90Ms: null,
132
+ spendUsd: 0,
133
+ spendPerMerged: null,
134
+ unmeteredRuns: 0,
135
+ unmeteredMerged: 0,
136
+ failureClasses: {},
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Aggregate one repo's settled issues and failure runs into {@link RepoStats}.
142
+ * Lead times and per-issue cost are reported only where a merged issue exists;
143
+ * `null` is "not measured", never a zero that could read as a measurement.
144
+ */
145
+ function summarize(args: {
146
+ repo: string;
147
+ outcomes: readonly IssueOutcome[];
148
+ failureRuns: readonly { repo: string; cls: string }[];
149
+ unmeteredRuns: number;
150
+ }): RepoStats {
151
+ const out = emptyRepo(args.repo);
152
+ const merged = args.outcomes.filter((o) => o.merged);
153
+ const leadTimes = merged
154
+ .map((o) => o.leadTimeMs!)
155
+ .sort((a, b) => a - b);
156
+ const meteredMerged = merged.filter((o) => o.meteredRuns > 0);
157
+ const meteredSpend = merged.reduce((sum, o) => sum + o.meteredSpend, 0);
158
+
159
+ out.merged = merged.length;
160
+ out.settled = args.outcomes.length;
161
+ out.mergeRate = args.outcomes.length === 0 ? null : merged.length / args.outcomes.length;
162
+ if (merged.length > 0) {
163
+ out.runsPerMerged = merged.reduce((sum, o) => sum + o.attempts, 0) / merged.length;
164
+ out.leadTimeMedianMs = Math.round(median(leadTimes));
165
+ out.leadTimeP90Ms = Math.round(p90(leadTimes));
166
+ }
167
+ out.spendUsd = cents(meteredSpend);
168
+ out.spendPerMerged = meteredMerged.length === 0 ? null : cents(meteredSpend / meteredMerged.length);
169
+ out.unmeteredMerged = merged.filter((o) => o.meteredRuns === 0).length;
170
+ out.unmeteredRuns = args.unmeteredRuns;
171
+ for (const run of args.failureRuns) {
172
+ out.failureClasses[run.cls] = (out.failureClasses[run.cls] ?? 0) + 1;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Compute the report from store rows. `runs` is whatever the store's
179
+ * stats-window query returned for the project: rows settling at or after
180
+ * `window.sinceEpochMs`, plus the full chain of every issue whose merge
181
+ * settled there. Rows within an issue must be ordered by `startedAt`, so the
182
+ * first row of a chain is its first queue-label claim.
183
+ */
184
+ export function computeStats(args: {
185
+ project: string;
186
+ window: StatsWindow;
187
+ ghCalls: number;
188
+ runs: readonly RunRecord[];
189
+ }): StatsReport {
190
+ const chains = new Map<number, RunRecord[]>();
191
+ for (const run of args.runs) {
192
+ const chain = chains.get(run.issue);
193
+ if (chain === undefined) chains.set(run.issue, [run]);
194
+ else chain.push(run);
195
+ }
196
+
197
+ const outcomes: IssueOutcome[] = [];
198
+ const byRepo = new Map<string, IssueOutcome[]>();
199
+ const failureRuns: { repo: string; cls: string }[] = [];
200
+ let unmeteredRuns = 0;
201
+ const unmeteredByRepo = new Map<string, number>();
202
+
203
+ for (const chain of chains.values()) {
204
+ // One issue merges once; if a weird history ever carried two merged rows,
205
+ // the newest settlement wins.
206
+ let merged: RunRecord | undefined;
207
+ for (const run of chain) {
208
+ if (
209
+ run.state === "merged" &&
210
+ (merged === undefined || WINDOW_KEY(run) > WINDOW_KEY(merged))
211
+ ) {
212
+ merged = run;
213
+ }
214
+ }
215
+
216
+ const terminatedInWindow = chain.some(
217
+ (run) => FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs,
218
+ );
219
+ // A chain appears in the result for two reasons: its merge settled in the
220
+ // window, or an individual run ended there. Without a merged row and
221
+ // without a terminal row in the window it is still in flight — news, not
222
+ // an outcome.
223
+ if (merged === undefined && !terminatedInWindow) continue;
224
+
225
+ for (const run of chain) {
226
+ if (run.spendUsd === 0) {
227
+ unmeteredRuns += 1;
228
+ unmeteredByRepo.set(run.repo, (unmeteredByRepo.get(run.repo) ?? 0) + 1);
229
+ }
230
+ if (merged === undefined && FAILED_STATES.has(run.state) && WINDOW_KEY(run) >= args.window.sinceEpochMs) {
231
+ failureRuns.push({ repo: run.repo, cls: run.failureClass ?? UNCLASSIFIED });
232
+ }
233
+ }
234
+
235
+ const claimAt = chain[0]!.startedAt;
236
+ const mergedAt = merged === undefined ? undefined : WINDOW_KEY(merged);
237
+ const repo =
238
+ merged?.repo ??
239
+ chain.find((r) => FAILED_STATES.has(r.state) && WINDOW_KEY(r) >= args.window.sinceEpochMs)!.repo;
240
+ const outcome: IssueOutcome = {
241
+ repo,
242
+ merged: merged !== undefined,
243
+ attempts: chain.length,
244
+ leadTimeMs: mergedAt === undefined ? undefined : Math.max(0, mergedAt - claimAt),
245
+ meteredSpend: chain.reduce((sum, run) => sum + (run.spendUsd > 0 ? run.spendUsd : 0), 0),
246
+ meteredRuns: chain.filter((run) => run.spendUsd > 0).length,
247
+ };
248
+ outcomes.push(outcome);
249
+ const bucket = byRepo.get(repo);
250
+ if (bucket === undefined) byRepo.set(repo, [outcome]);
251
+ else bucket.push(outcome);
252
+ }
253
+
254
+ const repos = [...byRepo.entries()]
255
+ .map(([repo, list]) =>
256
+ summarize({
257
+ repo,
258
+ outcomes: list,
259
+ failureRuns: failureRuns.filter((r) => r.repo === repo),
260
+ unmeteredRuns: unmeteredByRepo.get(repo) ?? 0,
261
+ }),
262
+ )
263
+ .sort((a, b) => b.merged - a.merged || b.settled - a.settled || a.repo.localeCompare(b.repo));
264
+
265
+ return {
266
+ project: args.project,
267
+ window: args.window,
268
+ ghCalls: args.ghCalls,
269
+ empty: outcomes.length === 0,
270
+ total: summarize({ repo: "(all)", outcomes, failureRuns, unmeteredRuns }),
271
+ repos,
272
+ };
273
+ }
274
+
275
+ /** `N` and its unit, for a count line that must not print "0" as a measure. */
276
+ function countLine(n: number, noun: string, plural: string): string {
277
+ return `${n} ${n === 1 ? noun : plural}`;
278
+ }
279
+
280
+ function humanDuration(ms: number): string {
281
+ const s = Math.max(0, Math.round(ms / 1000));
282
+ if (s < 60) return `${s}s`;
283
+ const m = Math.floor(s / 60);
284
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
285
+ const h = Math.floor(m / 60);
286
+ if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`;
287
+ return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
288
+ }
289
+
290
+ function renderRepo(repo: RepoStats, indent: string): string {
291
+ const lines: string[] = [];
292
+ const leadTimeMedian = repo.leadTimeMedianMs;
293
+ const leadTimeP90 = repo.leadTimeP90Ms;
294
+ const lead =
295
+ leadTimeMedian === null || leadTimeP90 === null
296
+ ? "—"
297
+ : `median ${humanDuration(leadTimeMedian)} · p90 ${humanDuration(leadTimeP90)}`;
298
+ const cost =
299
+ repo.spendPerMerged === null
300
+ ? `$${repo.spendUsd.toFixed(2)} metered on merged work (no metered merge to divide by)`
301
+ : `$${repo.spendUsd.toFixed(2)} metered ($${repo.spendPerMerged.toFixed(2)}/merged)`;
302
+ const unmetered: string[] = [];
303
+ if (repo.unmeteredRuns > 0) unmetered.push(`${countLine(repo.unmeteredRuns, "run", "runs")} unmetered`);
304
+ if (repo.unmeteredMerged > 0) unmetered.push(`${countLine(repo.unmeteredMerged, "merged issue", "merged issues")} never metered`);
305
+ const rates = repo.mergeRate === null ? "nothing settled" : `${(repo.mergeRate * 100).toFixed(1)}% of ${repo.settled} settled`;
306
+ const attempts = repo.runsPerMerged === null ? "—" : `${repo.runsPerMerged.toFixed(2)} per merged issue`;
307
+ lines.push(`${indent}${repo.repo} merged ${repo.merged} (${rates})`);
308
+ lines.push(`${indent} lead ${lead} (first claim → merge)`);
309
+ lines.push(`${indent} attempts ${attempts}`);
310
+ lines.push(`${indent} cost ${cost}${unmetered.length === 0 ? "" : ` · ${unmetered.join(" · ")}`}`);
311
+ const classes = Object.entries(repo.failureClasses).sort((a, b) => b[1] - a[1]);
312
+ if (classes.length > 0) {
313
+ lines.push(`${indent} failures ${classes.map(([cls, n]) => `${cls}: ${n}`).join(" · ")}`);
314
+ }
315
+ return lines.join("\n");
316
+ }
317
+
318
+ /** Render the human form of the same {@link StatsReport} `--json` prints. */
319
+ export function renderStatsHuman(report: StatsReport): string {
320
+ const w = report.window;
321
+ const header = `${report.project} — outcomes ${w.sinceDay} → ${w.untilDay}`;
322
+ if (report.empty) {
323
+ 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`;
324
+ }
325
+ const lines = [header, renderRepo(report.total, " "), ` gh api ${report.ghCalls} tracked ${report.ghCalls === 1 ? "call" : "calls"} in the window`];
326
+ if (report.repos.length > 0) {
327
+ lines.push(" per repo:");
328
+ for (const repo of report.repos) lines.push(renderRepo(repo, " "));
329
+ }
330
+ return `${lines.join("\n")}\n`;
331
+ }