omp-conductor 0.15.10 → 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 -2544
  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 +2 -2
  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 +193 -60
  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
@@ -15,7 +15,7 @@
15
15
  * call in here; `privileged.ts` stays the primitive underneath.
16
16
  */
17
17
 
18
- import { existsSync } from "node:fs";
18
+ import { existsSync, readFileSync } from "node:fs";
19
19
  import { platform } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { stateDir } from "./config.ts";
@@ -41,7 +41,9 @@ import {
41
41
  STAGED_SERVICE_NAME,
42
42
  SYSTEMD_UNIT_DIR,
43
43
  type EscalationDeps,
44
+ type ServiceRuntime,
44
45
  } from "./setup-host.ts";
46
+ import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
45
47
  import type { WizardUi } from "./wizard-ui.ts";
46
48
  import type { Caps, ProjectConfig } from "./types.ts";
47
49
 
@@ -62,6 +64,12 @@ export interface InstallDeps {
62
64
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
63
65
  platform?: () => string;
64
66
  unitDir?: string;
67
+ /**
68
+ * The runtime the plan derives from. Injectable so tests pin herdr
69
+ * present/absent deterministically; the default ({@link defaultServiceRuntime})
70
+ * detects herdr from the host's own PATH.
71
+ */
72
+ runtime?: ServiceRuntime;
65
73
  }
66
74
 
67
75
  /**
@@ -75,6 +83,22 @@ function linuxOnly(deps: InstallDeps): string | undefined {
75
83
  : "systemd install is Linux-only; staged files are at";
76
84
  }
77
85
 
86
+ /**
87
+ * Whether two files hold identical bytes. The "keep" side of setup-host.ts's
88
+ * `actionFor`: a unit systemd already reads that matches what would be written
89
+ * needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
90
+ * read error is a mismatch, never silently "matches", because the whole point
91
+ * is to make the plan only claim a unit install is redundant when it can see
92
+ * the installed copy.
93
+ */
94
+ function sameFile(a: string, b: string): boolean {
95
+ try {
96
+ return readFileSync(a, "utf8") === readFileSync(b, "utf8");
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
78
102
  /**
79
103
  * Install the supervised daemon unit: re-render, stage, then run the four steps
80
104
  * `planHostRuntime` used to only print.
@@ -92,10 +116,12 @@ export async function runHostInstall(
92
116
  return { kind: "refused", reason: verdict.message };
93
117
  }
94
118
 
95
- const plan = planHostRuntime(project, caps, telegramStateDir, undefined, hostInstallWorkers(caps));
96
- const wrote = writeHostRuntime(plan);
119
+ const plan = planHostRuntime(project, caps, telegramStateDir, deps.runtime, hostInstallWorkers(caps));
120
+ const { wrote, warnings } = writeHostRuntime(plan);
121
+ for (const warning of warnings) ui.notify(warning, "warning");
97
122
  const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
98
123
  const installed = join(unitDir, STAGED_SERVICE_NAME);
124
+ const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
99
125
 
100
126
  const blocked = linuxOnly(deps);
101
127
  if (blocked !== undefined) {
@@ -111,13 +137,29 @@ export async function runHostInstall(
111
137
  { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
112
138
  { title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
113
139
  { title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
140
+ // The herdr session server, provisioned alongside the daemon (#456). Only
141
+ // when herdr is installed and the plan therefore staged a unit.
142
+ ...(plan.herdrUnit === undefined
143
+ ? []
144
+ : [
145
+ { title: `install ${DEFAULT_HERDR_UNIT}`, argv: ["install", "-m", "0644", plan.herdrUnit.path, installedHerdr] },
146
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
147
+ { title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
148
+ { title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
149
+ ]),
114
150
  ];
115
151
 
116
152
  const outcome = await runPrivileged(steps, ui, {
117
153
  ...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
118
- title: "Install and start the supervised daemon?",
154
+ title: "Install and start the supervised session?",
119
155
  preamble: [
120
156
  `Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
157
+ ...(plan.herdrUnit === undefined
158
+ ? []
159
+ : [
160
+ `Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
161
+ "run it instead of dash, then enables and restarts it.",
162
+ ]),
121
163
  "The unit runs as the account that staged it; nothing here changes that.",
122
164
  ],
123
165
  });
@@ -219,6 +261,15 @@ export async function runGraphInstall(
219
261
  const blocked = linuxOnly(options);
220
262
  const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
221
263
  const from = unitPaths(stateDir());
264
+ // The units systemd already reads, compared to what staging just wrote — the
265
+ // same distinction setup-host.ts draws between `installedAction` and
266
+ // `service.action` (read the installed file, compare to the fresh content,
267
+ // "keep" vs "update"). "Installed" is not enough: a green timer serving a
268
+ // stale unit is the older bug, so when they differ the install runs. Only
269
+ // when both units already match does the plan omit them — then just the
270
+ // outstanding clone and seed remain.
271
+ const unitsCurrent =
272
+ blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
222
273
  // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
223
274
  // one preview and one confirm, and a second confirm here also invented a
224
275
  // third outcome — a declined seed — that neither the caller nor the
@@ -230,9 +281,13 @@ export async function runGraphInstall(
230
281
  const install: PrivilegedStep[] =
231
282
  blocked === undefined
232
283
  ? [
233
- { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
234
- { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
235
- { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
284
+ ...(unitsCurrent
285
+ ? []
286
+ : [
287
+ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
288
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
289
+ { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
290
+ ]),
236
291
  ...seed,
237
292
  ]
238
293
  : [];
@@ -242,19 +297,19 @@ export async function runGraphInstall(
242
297
  return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
243
298
  }
244
299
 
245
- if (blocked !== undefined) {
246
- ui.notify(`${blocked} ${service} and ${timer}`, "warning");
247
- return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
248
- }
249
-
250
300
  const outcome = await runPrivileged([...clones, ...install], ui, {
251
301
  ...(options.privileged === undefined ? {} : { deps: options.privileged }),
252
- title: "Clone, install and enable the code-graph timer?",
302
+ title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
253
303
  preamble: [
254
304
  `Staged: ${staged.written.join(", ")}.`,
255
305
  ...(clones.length === 0
256
306
  ? ["Every indexed clone already exists."]
257
- : [`${clones.length} clone(s) run as you; the unit install needs root.`]),
307
+ : [
308
+ `${clones.length} clone(s) run as you; ${
309
+ unitsCurrent ? "only the seed needs root — the reindex units already match." : "the unit install needs root."
310
+ }`,
311
+ ]),
312
+ ...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
258
313
  `Indexer: ${prereqs.indexer ?? "on PATH"}.`,
259
314
  ],
260
315
  });
@@ -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,37 @@ 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
+ * Who merges, who releases and who promotes. Three confirms rather than one
450
+ * four-way list: these are independent grants — delegating merges is routine,
451
+ * delegating releases and promotion is not — and a menu of combinations frames
452
+ * them as equally ordinary choices, which is exactly the framing a release or
453
+ * promotion grant must not get.
435
454
  *
436
455
  * Neither confirm can start on "yes", so a re-run that Enters through the
437
456
  * wizard revokes rather than renews. That is the safe direction, and the
@@ -456,7 +475,19 @@ async function askAuthority(
456
475
  "Default: humans release" +
457
476
  `${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
458
477
  );
459
- return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
478
+ const promotion = await askYesNo(
479
+ ui,
480
+ "Promotion authority",
481
+ "Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " +
482
+ "issue — the sign-off that lets a worker claim it, and the act that starts spend. " +
483
+ "Default: the operator promotes" +
484
+ `${prior.promotion === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
485
+ );
486
+ return {
487
+ merge: merge ? "orchestrator" : "human",
488
+ release: release ? "orchestrator" : "human",
489
+ promotion: promotion ? "orchestrator" : "human",
490
+ };
460
491
  }
461
492
 
462
493
  /**
@@ -478,6 +509,14 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
478
509
  deploy:
479
510
  "deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
480
511
  "This is the one grant that mutates a live environment rather than producing an artifact",
512
+ // #486: the last leg of the loop. Releasing publishes; installing replaces the
513
+ // running conductor itself, detached from the session, and only the first
514
+ // tick after the restart proves it. Fund as deliberate as `deploy` is — the
515
+ // package floor's "nobody patches the running conductor" is this grant's
516
+ // deny default.
517
+ install:
518
+ "install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
519
+ "and Herdr plugin pinned to one published release, executed detached from the fleet",
481
520
  };
482
521
 
483
522
  /**
@@ -1137,11 +1176,25 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
1137
1176
  return next;
1138
1177
  };
1139
1178
 
1140
- /** The two ownership questions, then the mechanical gate one shape at a time:
1179
+ /** The three ownership questions, then the mechanical gate one shape at a time:
1141
1180
  * together they are what decides what an unattended fleet may do unasked. */
1142
1181
  const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1143
1182
  const authority = await askAuthority(ui, a.authority);
1144
- const releaseGrants = await askReleaseGrants(ui, a.releaseGrants);
1183
+ // Declining release authority makes the five per-shape grants moot — they
1184
+ // exist to scope an authority that was just refused, and asking them after
1185
+ // "humans release" teaches the operator that answers do not matter (#368).
1186
+ //
1187
+ // Humans-only authority clears any stale per-shape grant to the fail-closed
1188
+ // baseline rather than offering an individual-tool path: `releaseVerb`
1189
+ // refuses before a shape grant is ever read (see `verbs/server`), so a shape
1190
+ // granted under humans-only authority is a dead prompt that says the opposite
1191
+ // of what the operator answered. Clearing keeps the tick tripwire, which
1192
+ // reads the grant map independently of authority, from enforcing a shape the
1193
+ // operator just revoked.
1194
+ const releaseGrants =
1195
+ authority.release === "human"
1196
+ ? { ...DENIED_RELEASE_GRANTS }
1197
+ : await askReleaseGrants(ui, a.releaseGrants);
1145
1198
  // Asked here because it is the same decision one layer down: the grants say
1146
1199
  // who may act, and these say where that permission stops. Amending authority
1147
1200
  // therefore re-asks the boundary, which is the point — a grant widened without
@@ -1151,8 +1204,13 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1151
1204
  };
1152
1205
 
1153
1206
  /** 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) });
1207
+ * who may act, then under what conditions. The release half is only asked
1208
+ * when the orchestrator cuts releases — with humans releasing it has no gates
1209
+ * to configure, so it would be a dead prompt (#368). */
1210
+ const askPolicy: AreaAsker = async (ui, a) => ({
1211
+ ...a,
1212
+ policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
1213
+ });
1156
1214
 
1157
1215
  /** How a stuck run reaches a human, and who triages it when it does. */
1158
1216
  const askEscalation: AreaAsker = async (ui, a) => {
@@ -1444,7 +1502,7 @@ type AmendChoice =
1444
1502
  * add-a-project path. Only asked when a project is already configured — a first
1445
1503
  * run has nothing to amend and is never shown this.
1446
1504
  */
1447
- async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
1505
+ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise<AmendChoice> {
1448
1506
  const mode = await ui.select(
1449
1507
  `"${prior.name}" is already configured — what would you like to do?`,
1450
1508
  [
@@ -1473,7 +1531,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<Amen
1473
1531
  return { kind: "reinterview" };
1474
1532
  }
1475
1533
 
1476
- const choices = amendChoices(prior);
1534
+ const choices = amendChoices(prior, defaults);
1477
1535
  const picked = await ui.select(
1478
1536
  "Which area? Each row shows what it says now",
1479
1537
  choices.map((c) => ({ label: c.label, description: c.description })),
@@ -1661,7 +1719,7 @@ export async function collectSetup(
1661
1719
  };
1662
1720
  }
1663
1721
 
1664
- const choice = await chooseAmendArea(ui, prior);
1722
+ const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS);
1665
1723
  if (choice.kind === "add-project") {
1666
1724
  // Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
1667
1725
  // here: the operator just chose to add, so the name comes from the prompt.
@@ -1743,11 +1801,82 @@ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig)
1743
1801
 
1744
1802
 
1745
1803
 
1804
+ /**
1805
+ * The side-effecting apply tail of {@link setup}, as one injectable object — the
1806
+ * wizard has `collectSetup` fully covered but none of its apply tail today. The
1807
+ * default is the real implementations; a test stubs whichever of gh/daemon/root
1808
+ * it proves, and drives the real tail (including the real `runGraphInstall`
1809
+ * with injected `unitDir`/`privileged`/probe) through the same seam, so a
1810
+ * regression cannot hide behind a rewritten status line.
1811
+ */
1812
+ export interface SetupApplyDeps {
1813
+ scopes: () => Promise<ScopeCheck>;
1814
+ labels: (trackerRepo: string, a: SetupAnswers) => Promise<LabelPlan[]>;
1815
+ preview: (project: ProjectConfig) => Promise<QueuePreview>;
1816
+ createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise<string[]>;
1817
+ smoke: (project: string) => Promise<SetupSmokeResult>;
1818
+ restart: (o: { project?: string }) => Promise<RestartResult>;
1819
+ arm: (project: string) => Promise<string>;
1820
+ resume: (project: string) => void;
1821
+ hostInstall: (
1822
+ project: ProjectConfig,
1823
+ caps: Caps,
1824
+ telegramStateDir: string,
1825
+ ui: WizardUi,
1826
+ ) => Promise<InstallOutcome>;
1827
+ graphInstall: (
1828
+ project: ProjectConfig,
1829
+ ui: WizardUi,
1830
+ options?: GraphInstallOptions,
1831
+ ) => Promise<InstallOutcome>;
1832
+ }
1833
+
1834
+ export const DEFAULT_APPLY: SetupApplyDeps = {
1835
+ scopes: checkTokenScopes,
1836
+ labels: planLabels,
1837
+ preview: previewProject,
1838
+ createLabels: createMissingLabels,
1839
+ smoke: runSetupSmoke,
1840
+ restart: (o) => restartDaemon(o),
1841
+ arm: (project) => ensureSetupArm(project),
1842
+ resume: (project) => setPaused(false, undefined, project),
1843
+ hostInstall: (project, caps, telegramStateDir, ui) =>
1844
+ runHostInstall(project, caps, telegramStateDir, ui),
1845
+ graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
1846
+ };
1847
+
1848
+ /**
1849
+ * The post-apply code-graph offer. Shared by the tail and the live-worker early
1850
+ * return, so neither path can silently leave the graph install unreferenced.
1851
+ * `graphInstall` is injectable: the tail and the CLI both call the real
1852
+ * {@link runGraphInstall}, and a test drives apply through this same seam.
1853
+ */
1854
+ async function offerCodeGraph(
1855
+ project: ProjectConfig,
1856
+ ui: WizardUi,
1857
+ graphInstall: SetupApplyDeps["graphInstall"],
1858
+ ): Promise<void> {
1859
+ if (graphRepos(project).length === 0) return;
1860
+ const wantsGraph = await ui.confirm(
1861
+ "Install the code-graph indexes now?",
1862
+ "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
1863
+ "indexing run so the first fetch happens while you watch. Minutes per repo. " +
1864
+ "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
1865
+ );
1866
+ if (wantsGraph === true) await graphInstall(project, ui);
1867
+ else
1868
+ ui.notify(
1869
+ "Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
1870
+ "info",
1871
+ );
1872
+ }
1873
+
1746
1874
  export async function setup(
1747
1875
  ui: WizardUi,
1748
1876
  projectArg: string | undefined,
1749
1877
  areaArg?: AmendAreaId,
1750
1878
  probes: SetupProbes = DEFAULT_PROBES,
1879
+ apply: SetupApplyDeps = DEFAULT_APPLY,
1751
1880
  ): Promise<void> {
1752
1881
  const path = configPath();
1753
1882
  // A config that exists but does not parse is a fault to report, never
@@ -1768,7 +1897,7 @@ export async function setup(
1768
1897
  }
1769
1898
  const { answers, amend, added } = collected;
1770
1899
 
1771
- const scopes = await checkTokenScopes();
1900
+ const scopes = await apply.scopes();
1772
1901
  if (!scopes.ok) {
1773
1902
  ui.notify(
1774
1903
  `Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
@@ -1783,7 +1912,7 @@ export async function setup(
1783
1912
  // files will contain. Each draft carries its own preview and confirm; declining
1784
1913
  // one keeps the shipped stub, so an operator who wants none reaches the same plan.
1785
1914
  const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
1786
- const labels = await planLabels(answers.trackerRepo, answers);
1915
+ const labels = await apply.labels(answers.trackerRepo, answers);
1787
1916
  const telegram = detectTelegram();
1788
1917
  let nextConfig;
1789
1918
  try {
@@ -1820,7 +1949,7 @@ export async function setup(
1820
1949
  );
1821
1950
  let queuePreview: string[];
1822
1951
  try {
1823
- queuePreview = formatPreview(await previewProject(project));
1952
+ queuePreview = formatPreview(await apply.preview(project));
1824
1953
  } catch (err) {
1825
1954
  const message = err instanceof Error ? err.message : String(err);
1826
1955
  ui.notify(
@@ -1834,8 +1963,8 @@ export async function setup(
1834
1963
  [
1835
1964
  // The delta first when there is one, then the whole plan: the confirm has
1836
1965
  // 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),
1966
+ ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
1967
+ summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
1839
1968
  "",
1840
1969
  formatHostRuntimePlan(runtime),
1841
1970
  "",
@@ -1863,11 +1992,21 @@ export async function setup(
1863
1992
  runtime.service.action === "keep"
1864
1993
  ? `Keeps the staged systemd unit at ${runtime.service.path}.`
1865
1994
  : `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
1995
+ runtime.herdrUnit === undefined
1996
+ ? ""
1997
+ : runtime.herdrUnit.action === "keep"
1998
+ ? `Keeps the staged Herdr session unit at ${runtime.herdrUnit.path}.`
1999
+ : `${runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${runtime.herdrUnit.path} (account's login shell for panes).`,
1866
2000
  runtime.tick === undefined
1867
2001
  ? ""
1868
2002
  : runtime.tick.action === "keep"
1869
2003
  ? `Keeps the external heartbeat config at ${runtime.tick.path}.`
1870
2004
  : `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
2005
+ runtime.briefLink.action === "skip"
2006
+ ? `Leaves the existing file at ${runtime.briefLink.path} alone (an operator's file, not overwritten).`
2007
+ : runtime.briefLink.action === "keep"
2008
+ ? `Keeps the brief link at ${runtime.briefLink.path}.`
2009
+ : `${runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${runtime.briefLink.path} -> ${runtime.briefLink.target}.`,
1871
2010
  answers.writeOrchestratorBrief
1872
2011
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
1873
2012
  : "",
@@ -1887,11 +2026,12 @@ export async function setup(
1887
2026
  // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
1888
2027
  // partially applied setup unable to claim work.
1889
2028
  prepareConductor(project.name);
1890
- const created = await createMissingLabels(answers.trackerRepo, labels);
2029
+ const created = await apply.createLabels(answers.trackerRepo, labels);
1891
2030
  saveConfig(nextConfig);
1892
2031
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
1893
2032
  const runtimeFiles = writeHostRuntime(runtime);
1894
- const smoke = await runSetupSmoke(project.name);
2033
+ for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
2034
+ const smoke = await apply.smoke(project.name);
1895
2035
  let smokeLine =
1896
2036
  `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
1897
2037
  `stored status for ${smoke.status.project}`;
@@ -1911,6 +2051,12 @@ export async function setup(
1911
2051
  ].join("\n"),
1912
2052
  "warning",
1913
2053
  );
2054
+ // The early return must not swallow the graph offer. This host is the
2055
+ // live reproduction: the shared reindex units were already installed by
2056
+ // the first project, so only the clone+seed are outstanding — and nothing
2057
+ // above names them. Offer regardless of live workers: cloning seeds and
2058
+ // enables the timer, which does not touch the running daemon.
2059
+ await offerCodeGraph(project, ui, apply.graphInstall);
1914
2060
  ui.notify(formatHerdrHandoff(project, nextConfig), "info");
1915
2061
  return;
1916
2062
  }
@@ -1928,7 +2074,7 @@ export async function setup(
1928
2074
  "info",
1929
2075
  );
1930
2076
  } else {
1931
- const restarted = await restartDaemon({ project: project.name });
2077
+ const restarted = await apply.restart({ project: project.name });
1932
2078
  restartVia = restarted.via;
1933
2079
  smokeLine =
1934
2080
  `existing /healthz and stored status; restarted through ${restarted.via}; ` +
@@ -1940,7 +2086,7 @@ export async function setup(
1940
2086
  if (project.escalation.orchestrator === "external") {
1941
2087
  ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1942
2088
  try {
1943
- armLine = await ensureSetupArm(project.name);
2089
+ armLine = await apply.arm(project.name);
1944
2090
  } catch (err) {
1945
2091
  ui.notify(
1946
2092
  [
@@ -1955,7 +2101,7 @@ export async function setup(
1955
2101
  return;
1956
2102
  }
1957
2103
  }
1958
- setPaused(false, undefined, project.name);
2104
+ apply.resume(project.name);
1959
2105
 
1960
2106
  ui.notify(
1961
2107
  [
@@ -1964,9 +2110,9 @@ export async function setup(
1964
2110
  briefPath === undefined
1965
2111
  ? "Kept the existing orchestrator brief."
1966
2112
  : `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
1967
- runtimeFiles.length === 0
2113
+ runtimeFiles.wrote.length === 0
1968
2114
  ? "Host runtime files were already current."
1969
- : `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
2115
+ : `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
1970
2116
  `Smoke passed: ${smokeLine}.`,
1971
2117
  `Heartbeat: ${armLine}.`,
1972
2118
  "",
@@ -2004,24 +2150,11 @@ export async function setup(
2004
2150
  "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
2005
2151
  );
2006
2152
  if (install === true)
2007
- await runHostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2153
+ await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2008
2154
  else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
2009
2155
  }
2010
2156
 
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
- }
2157
+ await offerCodeGraph(project, ui, apply.graphInstall);
2025
2158
 
2026
2159
  // Always print: first install, amend, and add-a-project all need the operator
2027
2160
  // to create/verify the herdr pane. Added projects especially — the CLI wrote