omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -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 +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  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 +150 -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 +100 -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 +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -30,7 +30,7 @@ import {
30
30
  type QueuePreview,
31
31
  } from "./daemon.ts";
32
32
  import { armTicks, telegramStateDir } from "./fleet.ts";
33
- import { restartDaemon } from "./lifecycle.ts";
33
+ import { restartDaemon, type RestartResult } from "./lifecycle.ts";
34
34
  import { defaultGraphRoot, graphRepos } from "./graph.ts";
35
35
  import {
36
36
  formatHostRuntimePlan,
@@ -40,8 +40,9 @@ import {
40
40
  SYSTEMD_UNIT_DIR,
41
41
  tickCwdForProject,
42
42
  writeHostRuntime,
43
+ type SetupSmokeResult,
43
44
  } from "./setup-host.ts";
44
- import { runGraphInstall, runHostInstall } from "./setup-install.ts";
45
+ import { runGraphInstall, runHostInstall, type GraphInstallOptions, type InstallOutcome } from "./setup-install.ts";
45
46
  import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
46
47
  import {
47
48
  discoverFacts,
@@ -74,7 +75,9 @@ import {
74
75
  summarisePlan,
75
76
  writeOrchestratorBrief,
76
77
  type AmendAreaId,
78
+ type LabelPlan,
77
79
  type OperatorJudgment,
80
+ type ScopeCheck,
78
81
  type SetupAnswers,
79
82
  type ProbedProse,
80
83
  } from "./setup.ts";
@@ -82,11 +85,13 @@ import {
82
85
  BASE_FRESHNESS,
83
86
  BEHIND_BASE_ACTIONS,
84
87
  DEFAULT_CAPS,
88
+ DENIED_RELEASE_GRANTS,
85
89
  DRAFT_POLICIES,
86
90
  RELEASE_REQUIREMENTS,
87
91
  INTERRUPT_CATEGORIES,
88
92
  RELEASE_SHAPES,
89
93
  WEEKDAYS,
94
+ type AuthorityHolder,
90
95
  type Caps,
91
96
  type ConductorConfig,
92
97
  type OrchestratorMode,
@@ -374,8 +379,18 @@ async function askReleaseRequirements(
374
379
  * them out of POLICY.md is that they are config: a condition an operator can
375
380
  * only reach by opening `config.json` is one that stays at its default while
376
381
  * their prose says something else, which is the drift this key ended.
382
+ *
383
+ * The merge half is always asked — it gates work that lands whatever the
384
+ * release authority is. The release half only matters when the orchestrator
385
+ * actually cuts releases: with `release: human` a release verb is refused
386
+ * before these fields are read (see `verbs/server`), so asking them would be a
387
+ * dead question. They are skipped, keeping the seed's value.
377
388
  */
378
- async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promise<ProjectPolicy> {
389
+ async function askPolicyPreconditions(
390
+ ui: WizardUi,
391
+ prior: ProjectPolicy,
392
+ releaseAuthority: AuthorityHolder,
393
+ ): Promise<ProjectPolicy> {
379
394
  const merge = {
380
395
  requiredChecks: await askNameList(
381
396
  ui,
@@ -405,33 +420,58 @@ async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promi
405
420
  ),
406
421
  };
407
422
 
408
- const release = {
409
- requires: await askReleaseRequirements(ui, prior.release.requires),
410
- requiredChecks: await askNameList(
411
- ui,
412
- `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
413
- prior.release.requiredChecks,
414
- ),
415
- artefacts: await askNameList(
416
- ui,
417
- `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
418
- prior.release.artefacts,
419
- ),
420
- environments: await askNameList(
421
- ui,
422
- `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
423
- prior.release.environments,
424
- ),
425
- };
423
+ const release =
424
+ releaseAuthority === "orchestrator"
425
+ ? {
426
+ requires: await askReleaseRequirements(ui, prior.release.requires),
427
+ requiredChecks: await askNameList(
428
+ ui,
429
+ `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
430
+ prior.release.requiredChecks,
431
+ ),
432
+ artefacts: await askNameList(
433
+ ui,
434
+ `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
435
+ prior.release.artefacts,
436
+ ),
437
+ environments: await askNameList(
438
+ ui,
439
+ `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
440
+ prior.release.environments,
441
+ ),
442
+ }
443
+ : prior.release;
426
444
 
427
445
  return { merge, release };
428
446
  }
429
447
 
430
448
  /**
431
- * Who merges and who releases. Two confirms rather than one four-way list:
432
- * these are independent grants — delegating merges is routine, delegating
433
- * releases is not — and a menu of four combinations frames them as equally
434
- * ordinary choices, which is exactly the framing a release grant must not get.
449
+ * The release questions a delegated release authority unlocks, in the order the
450
+ * interview asks them after the grant: one tool gate per release shape, then
451
+ * the release-policy conditions.
452
+ *
453
+ * The authority preview counts and names them from this list — and from
454
+ * `RELEASE_SHAPES` directly — so the prompt stays honest with the questions
455
+ * that follow: a shape added to `RELEASE_SHAPES` or a condition added here moves
456
+ * the number the preview quotes at the same time the later questions appear. A
457
+ * hard-coded count is the drift this exists to prevent.
458
+ */
459
+ export function releaseUnlockPlan(): { gates: readonly string[]; policy: readonly string[] } {
460
+ return {
461
+ // `RELEASE_SHAPES`, not a copy that can rot: these are exactly the per-shape
462
+ // gates `askReleaseGrants` asks (seven, not five (#500)).
463
+ gates: [...RELEASE_SHAPES],
464
+ // The release half of `askPolicyPreconditions`, in the order it asks them.
465
+ policy: ["requirements", "required checks", "artefacts", "environments"],
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Who merges, who releases and who promotes. Three confirms rather than one
471
+ * four-way list: these are independent grants — delegating merges is routine,
472
+ * delegating releases and promotion is not — and a menu of combinations frames
473
+ * them as equally ordinary choices, which is exactly the framing a release or
474
+ * promotion grant must not get.
435
475
  *
436
476
  * Neither confirm can start on "yes", so a re-run that Enters through the
437
477
  * wizard revokes rather than renews. That is the safe direction, and the
@@ -448,15 +488,31 @@ async function askAuthority(
448
488
  "re-checked against the base branch first. Default: humans merge" +
449
489
  `${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
450
490
  );
491
+ const unlock = releaseUnlockPlan();
492
+ const unlockCount = unlock.gates.length + unlock.policy.length;
451
493
  const release = await askYesNo(
452
494
  ui,
453
495
  "Release authority",
454
496
  "Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
455
497
  "procedure you write into its brief — and its brief forbids cutting one before you have. " +
456
- "Default: humans release" +
498
+ `Delegating unlocks ${unlockCount} later decisions: the ${unlock.gates.length} release-tool ` +
499
+ `gates (${unlock.gates.join(", ")}) and the release-policy conditions (${unlock.policy.join(", ")}). ` +
500
+ "Declining means humans release and none are asked. Default: humans release" +
457
501
  `${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
458
502
  );
459
- return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
503
+ const promotion = await askYesNo(
504
+ ui,
505
+ "Promotion authority",
506
+ "Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " +
507
+ "issue — the sign-off that lets a worker claim it, and the act that starts spend. " +
508
+ "Default: the operator promotes" +
509
+ `${prior.promotion === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
510
+ );
511
+ return {
512
+ merge: merge ? "orchestrator" : "human",
513
+ release: release ? "orchestrator" : "human",
514
+ promotion: promotion ? "orchestrator" : "human",
515
+ };
460
516
  }
461
517
 
462
518
  /**
@@ -478,6 +534,14 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
478
534
  deploy:
479
535
  "deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
480
536
  "This is the one grant that mutates a live environment rather than producing an artifact",
537
+ // #486: the last leg of the loop. Releasing publishes; installing replaces the
538
+ // running conductor itself, detached from the session, and only the first
539
+ // tick after the restart proves it. Fund as deliberate as `deploy` is — the
540
+ // package floor's "nobody patches the running conductor" is this grant's
541
+ // deny default.
542
+ install:
543
+ "install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
544
+ "and Herdr plugin pinned to one published release, executed detached from the fleet",
481
545
  };
482
546
 
483
547
  /**
@@ -1137,11 +1201,25 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
1137
1201
  return next;
1138
1202
  };
1139
1203
 
1140
- /** The two ownership questions, then the mechanical gate one shape at a time:
1204
+ /** The three ownership questions, then the mechanical gate one shape at a time:
1141
1205
  * together they are what decides what an unattended fleet may do unasked. */
1142
1206
  const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1143
1207
  const authority = await askAuthority(ui, a.authority);
1144
- const releaseGrants = await askReleaseGrants(ui, a.releaseGrants);
1208
+ // Declining release authority makes the five per-shape grants moot — they
1209
+ // exist to scope an authority that was just refused, and asking them after
1210
+ // "humans release" teaches the operator that answers do not matter (#368).
1211
+ //
1212
+ // Humans-only authority clears any stale per-shape grant to the fail-closed
1213
+ // baseline rather than offering an individual-tool path: `releaseVerb`
1214
+ // refuses before a shape grant is ever read (see `verbs/server`), so a shape
1215
+ // granted under humans-only authority is a dead prompt that says the opposite
1216
+ // of what the operator answered. Clearing keeps the tick tripwire, which
1217
+ // reads the grant map independently of authority, from enforcing a shape the
1218
+ // operator just revoked.
1219
+ const releaseGrants =
1220
+ authority.release === "human"
1221
+ ? { ...DENIED_RELEASE_GRANTS }
1222
+ : await askReleaseGrants(ui, a.releaseGrants);
1145
1223
  // Asked here because it is the same decision one layer down: the grants say
1146
1224
  // who may act, and these say where that permission stops. Amending authority
1147
1225
  // therefore re-asks the boundary, which is the point — a grant widened without
@@ -1151,8 +1229,13 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1151
1229
  };
1152
1230
 
1153
1231
  /** What a merge and a release must satisfy. Asked straight after the grants:
1154
- * who may act, then under what conditions (#129). */
1155
- const askPolicy: AreaAsker = async (ui, a) => ({ ...a, policy: await askPolicyPreconditions(ui, a.policy) });
1232
+ * who may act, then under what conditions. The release half is only asked
1233
+ * when the orchestrator cuts releases — with humans releasing it has no gates
1234
+ * to configure, so it would be a dead prompt (#368). */
1235
+ const askPolicy: AreaAsker = async (ui, a) => ({
1236
+ ...a,
1237
+ policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
1238
+ });
1156
1239
 
1157
1240
  /** How a stuck run reaches a human, and who triages it when it does. */
1158
1241
  const askEscalation: AreaAsker = async (ui, a) => {
@@ -1421,6 +1504,32 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
1421
1504
  await askBrief(ui, { ...a, judgment: await askJudgment(ui, a.releaseGrants, a.judgment ?? {}) }, probes),
1422
1505
  };
1423
1506
 
1507
+ /**
1508
+ * The full interview's areas, in the exact order `collectAnswers` walks them.
1509
+ *
1510
+ * This array is the single source of both the questions and their progress
1511
+ * marker: the "m" in every `Area n of m` line is this list's `.length`, never a
1512
+ * separate constant, so an area a branch predicate prunes takes its row and its
1513
+ * slot in the denominator with it — the count cannot drift from the iterable.
1514
+ *
1515
+ * The order is load-bearing and each step documents its own rationale: the
1516
+ * graph sits straight after the repos because the answer is derived per repo;
1517
+ * the worker model sits after the policy block because the boundary is an
1518
+ * authority-shaped decision, not a runtime knob; the brief is last because its
1519
+ * write question needs the real path the rest of the answers derive.
1520
+ */
1521
+ const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [
1522
+ { label: "tracker & repos", asker: askTrackerAndRepos },
1523
+ { label: "code graph", asker: askGraph },
1524
+ { label: "caps", asker: askCaps },
1525
+ { label: "authority & release grants", asker: askAuthorityArea },
1526
+ { label: "merge & release preconditions", asker: askPolicy },
1527
+ { label: "worker model", asker: askWorkerModel },
1528
+ { label: "escalation & triage", asker: askEscalation },
1529
+ { label: "reporting", asker: askReporting },
1530
+ { label: "orchestrator brief", asker: askBrief },
1531
+ ];
1532
+
1424
1533
  /** The two ways to answer the first question a configured project gets. Labels,
1425
1534
  * because the harness's select resolves to the label it displayed. */
1426
1535
  const AMEND_ONE = "Change one area";
@@ -1444,7 +1553,7 @@ type AmendChoice =
1444
1553
  * add-a-project path. Only asked when a project is already configured — a first
1445
1554
  * run has nothing to amend and is never shown this.
1446
1555
  */
1447
- async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
1556
+ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise<AmendChoice> {
1448
1557
  const mode = await ui.select(
1449
1558
  `"${prior.name}" is already configured — what would you like to do?`,
1450
1559
  [
@@ -1473,7 +1582,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<Amen
1473
1582
  return { kind: "reinterview" };
1474
1583
  }
1475
1584
 
1476
- const choices = amendChoices(prior);
1585
+ const choices = amendChoices(prior, defaults);
1477
1586
  const picked = await ui.select(
1478
1587
  "Which area? Each row shows what it says now",
1479
1588
  choices.map((c) => ({ label: c.label, description: c.description })),
@@ -1536,19 +1645,14 @@ async function collectAnswers(
1536
1645
  mirrorRoot: scoped.mirrorRoot,
1537
1646
  };
1538
1647
  }
1539
- a = await askTrackerAndRepos(ui, a, probes);
1540
- // Straight after the repos, because it is a fact about them: one clone per
1541
- // routed repo, under one root.
1542
- a = await askGraph(ui, a, probes);
1543
- a = await askCaps(ui, a, probes);
1544
- a = await askAuthorityArea(ui, a, probes, discovered);
1545
- a = await askPolicy(ui, a, probes, discovered);
1546
- a = await askWorkerModel(ui, a, probes);
1547
- a = await askEscalation(ui, a, probes);
1548
- a = await askReporting(ui, a, probes);
1549
- // Asked last, and asked with the real path in the question — which needs the
1550
- // rest of the answers to derive.
1551
- return await askBrief(ui, a, probes);
1648
+ // Walk the ordered area list once; the marker's denominator is the list's
1649
+ // length, so an area a branch prunes drops its row and its slot together.
1650
+ const areas = INTERVIEW_AREAS;
1651
+ for (const [index, area] of areas.entries()) {
1652
+ ui.notify(`Area ${index + 1} of ${areas.length}: ${area.label}`, "info");
1653
+ a = await area.asker(ui, a, probes, discovered);
1654
+ }
1655
+ return a;
1552
1656
  }
1553
1657
 
1554
1658
  /** The dry run, rendered. Same routing code the loop uses, so this is what the
@@ -1661,7 +1765,7 @@ export async function collectSetup(
1661
1765
  };
1662
1766
  }
1663
1767
 
1664
- const choice = await chooseAmendArea(ui, prior);
1768
+ const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS);
1665
1769
  if (choice.kind === "add-project") {
1666
1770
  // Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
1667
1771
  // here: the operator just chose to add, so the name comes from the prompt.
@@ -1743,11 +1847,82 @@ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig)
1743
1847
 
1744
1848
 
1745
1849
 
1850
+ /**
1851
+ * The side-effecting apply tail of {@link setup}, as one injectable object — the
1852
+ * wizard has `collectSetup` fully covered but none of its apply tail today. The
1853
+ * default is the real implementations; a test stubs whichever of gh/daemon/root
1854
+ * it proves, and drives the real tail (including the real `runGraphInstall`
1855
+ * with injected `unitDir`/`privileged`/probe) through the same seam, so a
1856
+ * regression cannot hide behind a rewritten status line.
1857
+ */
1858
+ export interface SetupApplyDeps {
1859
+ scopes: () => Promise<ScopeCheck>;
1860
+ labels: (trackerRepo: string, a: SetupAnswers) => Promise<LabelPlan[]>;
1861
+ preview: (project: ProjectConfig) => Promise<QueuePreview>;
1862
+ createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise<string[]>;
1863
+ smoke: (project: string) => Promise<SetupSmokeResult>;
1864
+ restart: (o: { project?: string }) => Promise<RestartResult>;
1865
+ arm: (project: string) => Promise<string>;
1866
+ resume: (project: string) => void;
1867
+ hostInstall: (
1868
+ project: ProjectConfig,
1869
+ caps: Caps,
1870
+ telegramStateDir: string,
1871
+ ui: WizardUi,
1872
+ ) => Promise<InstallOutcome>;
1873
+ graphInstall: (
1874
+ project: ProjectConfig,
1875
+ ui: WizardUi,
1876
+ options?: GraphInstallOptions,
1877
+ ) => Promise<InstallOutcome>;
1878
+ }
1879
+
1880
+ export const DEFAULT_APPLY: SetupApplyDeps = {
1881
+ scopes: checkTokenScopes,
1882
+ labels: planLabels,
1883
+ preview: previewProject,
1884
+ createLabels: createMissingLabels,
1885
+ smoke: runSetupSmoke,
1886
+ restart: (o) => restartDaemon(o),
1887
+ arm: (project) => ensureSetupArm(project),
1888
+ resume: (project) => setPaused(false, undefined, project),
1889
+ hostInstall: (project, caps, telegramStateDir, ui) =>
1890
+ runHostInstall(project, caps, telegramStateDir, ui),
1891
+ graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
1892
+ };
1893
+
1894
+ /**
1895
+ * The post-apply code-graph offer. Shared by the tail and the live-worker early
1896
+ * return, so neither path can silently leave the graph install unreferenced.
1897
+ * `graphInstall` is injectable: the tail and the CLI both call the real
1898
+ * {@link runGraphInstall}, and a test drives apply through this same seam.
1899
+ */
1900
+ async function offerCodeGraph(
1901
+ project: ProjectConfig,
1902
+ ui: WizardUi,
1903
+ graphInstall: SetupApplyDeps["graphInstall"],
1904
+ ): Promise<void> {
1905
+ if (graphRepos(project).length === 0) return;
1906
+ const wantsGraph = await ui.confirm(
1907
+ "Install the code-graph indexes now?",
1908
+ "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
1909
+ "indexing run so the first fetch happens while you watch. Minutes per repo. " +
1910
+ "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
1911
+ );
1912
+ if (wantsGraph === true) await graphInstall(project, ui);
1913
+ else
1914
+ ui.notify(
1915
+ "Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
1916
+ "info",
1917
+ );
1918
+ }
1919
+
1746
1920
  export async function setup(
1747
1921
  ui: WizardUi,
1748
1922
  projectArg: string | undefined,
1749
1923
  areaArg?: AmendAreaId,
1750
1924
  probes: SetupProbes = DEFAULT_PROBES,
1925
+ apply: SetupApplyDeps = DEFAULT_APPLY,
1751
1926
  ): Promise<void> {
1752
1927
  const path = configPath();
1753
1928
  // A config that exists but does not parse is a fault to report, never
@@ -1768,7 +1943,7 @@ export async function setup(
1768
1943
  }
1769
1944
  const { answers, amend, added } = collected;
1770
1945
 
1771
- const scopes = await checkTokenScopes();
1946
+ const scopes = await apply.scopes();
1772
1947
  if (!scopes.ok) {
1773
1948
  ui.notify(
1774
1949
  `Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
@@ -1783,7 +1958,7 @@ export async function setup(
1783
1958
  // files will contain. Each draft carries its own preview and confirm; declining
1784
1959
  // one keeps the shipped stub, so an operator who wants none reaches the same plan.
1785
1960
  const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
1786
- const labels = await planLabels(answers.trackerRepo, answers);
1961
+ const labels = await apply.labels(answers.trackerRepo, answers);
1787
1962
  const telegram = detectTelegram();
1788
1963
  let nextConfig;
1789
1964
  try {
@@ -1820,7 +1995,7 @@ export async function setup(
1820
1995
  );
1821
1996
  let queuePreview: string[];
1822
1997
  try {
1823
- queuePreview = formatPreview(await previewProject(project));
1998
+ queuePreview = formatPreview(await apply.preview(project));
1824
1999
  } catch (err) {
1825
2000
  const message = err instanceof Error ? err.message : String(err);
1826
2001
  ui.notify(
@@ -1834,8 +2009,8 @@ export async function setup(
1834
2009
  [
1835
2010
  // The delta first when there is one, then the whole plan: the confirm has
1836
2011
  // to name every mutation it authorises, and a delta names none of them.
1837
- ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
1838
- summarisePlan(answers, scopes, labels, telegram),
2012
+ ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
2013
+ summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
1839
2014
  "",
1840
2015
  formatHostRuntimePlan(runtime),
1841
2016
  "",
@@ -1863,11 +2038,23 @@ export async function setup(
1863
2038
  runtime.service.action === "keep"
1864
2039
  ? `Keeps the staged systemd unit at ${runtime.service.path}.`
1865
2040
  : `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
2041
+ runtime.herdrUnit === undefined
2042
+ ? ""
2043
+ : runtime.herdrUnit.action === "keep"
2044
+ ? `Keeps the staged Herdr session unit at ${runtime.herdrUnit.path}.`
2045
+ : `${runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${runtime.herdrUnit.path} (account's login shell for panes).`,
1866
2046
  runtime.tick === undefined
1867
2047
  ? ""
1868
2048
  : runtime.tick.action === "keep"
1869
2049
  ? `Keeps the external heartbeat config at ${runtime.tick.path}.`
1870
2050
  : `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
2051
+ runtime.briefLink === undefined
2052
+ ? ""
2053
+ : runtime.briefLink.action === "skip"
2054
+ ? `Leaves the existing file at ${runtime.briefLink.path} alone (an operator's file, not overwritten).`
2055
+ : runtime.briefLink.action === "keep"
2056
+ ? `Keeps the brief link at ${runtime.briefLink.path}.`
2057
+ : `${runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${runtime.briefLink.path} -> ${runtime.briefLink.target}.`,
1871
2058
  answers.writeOrchestratorBrief
1872
2059
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
1873
2060
  : "",
@@ -1887,11 +2074,12 @@ export async function setup(
1887
2074
  // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
1888
2075
  // partially applied setup unable to claim work.
1889
2076
  prepareConductor(project.name);
1890
- const created = await createMissingLabels(answers.trackerRepo, labels);
2077
+ const created = await apply.createLabels(answers.trackerRepo, labels);
1891
2078
  saveConfig(nextConfig);
1892
2079
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
1893
2080
  const runtimeFiles = writeHostRuntime(runtime);
1894
- const smoke = await runSetupSmoke(project.name);
2081
+ for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
2082
+ const smoke = await apply.smoke(project.name);
1895
2083
  let smokeLine =
1896
2084
  `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
1897
2085
  `stored status for ${smoke.status.project}`;
@@ -1911,6 +2099,12 @@ export async function setup(
1911
2099
  ].join("\n"),
1912
2100
  "warning",
1913
2101
  );
2102
+ // The early return must not swallow the graph offer. This host is the
2103
+ // live reproduction: the shared reindex units were already installed by
2104
+ // the first project, so only the clone+seed are outstanding — and nothing
2105
+ // above names them. Offer regardless of live workers: cloning seeds and
2106
+ // enables the timer, which does not touch the running daemon.
2107
+ await offerCodeGraph(project, ui, apply.graphInstall);
1914
2108
  ui.notify(formatHerdrHandoff(project, nextConfig), "info");
1915
2109
  return;
1916
2110
  }
@@ -1928,7 +2122,7 @@ export async function setup(
1928
2122
  "info",
1929
2123
  );
1930
2124
  } else {
1931
- const restarted = await restartDaemon({ project: project.name });
2125
+ const restarted = await apply.restart({ project: project.name });
1932
2126
  restartVia = restarted.via;
1933
2127
  smokeLine =
1934
2128
  `existing /healthz and stored status; restarted through ${restarted.via}; ` +
@@ -1940,7 +2134,7 @@ export async function setup(
1940
2134
  if (project.escalation.orchestrator === "external") {
1941
2135
  ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1942
2136
  try {
1943
- armLine = await ensureSetupArm(project.name);
2137
+ armLine = await apply.arm(project.name);
1944
2138
  } catch (err) {
1945
2139
  ui.notify(
1946
2140
  [
@@ -1955,7 +2149,7 @@ export async function setup(
1955
2149
  return;
1956
2150
  }
1957
2151
  }
1958
- setPaused(false, undefined, project.name);
2152
+ apply.resume(project.name);
1959
2153
 
1960
2154
  ui.notify(
1961
2155
  [
@@ -1964,9 +2158,9 @@ export async function setup(
1964
2158
  briefPath === undefined
1965
2159
  ? "Kept the existing orchestrator brief."
1966
2160
  : `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
1967
- runtimeFiles.length === 0
2161
+ runtimeFiles.wrote.length === 0
1968
2162
  ? "Host runtime files were already current."
1969
- : `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
2163
+ : `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
1970
2164
  `Smoke passed: ${smokeLine}.`,
1971
2165
  `Heartbeat: ${armLine}.`,
1972
2166
  "",
@@ -2004,24 +2198,11 @@ export async function setup(
2004
2198
  "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
2005
2199
  );
2006
2200
  if (install === true)
2007
- await runHostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2201
+ await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2008
2202
  else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
2009
2203
  }
2010
2204
 
2011
- if (graphRepos(project).length > 0) {
2012
- const wantsGraph = await ui.confirm(
2013
- "Install the code-graph indexes now?",
2014
- "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
2015
- "indexing run so the first fetch happens while you watch. Minutes per repo. " +
2016
- "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
2017
- );
2018
- if (wantsGraph === true) await runGraphInstall(project, ui);
2019
- else
2020
- ui.notify(
2021
- "Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
2022
- "info",
2023
- );
2024
- }
2205
+ await offerCodeGraph(project, ui, apply.graphInstall);
2025
2206
 
2026
2207
  // Always print: first install, amend, and add-a-project all need the operator
2027
2208
  // to create/verify the herdr pane. Added projects especially — the CLI wrote