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
@@ -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) => {
@@ -1216,7 +1274,12 @@ async function askTelegramTopicId(
1216
1274
  const options = [
1217
1275
  ...claimed.map((t) => ({
1218
1276
  label: `${t.name} — ${t.threadId}`,
1219
- description: `message_thread_id ${t.threadId}`,
1277
+ // Two panes under one state directory claim the same title, so the
1278
+ // herdr space is the only thing that tells the operator which is which.
1279
+ description:
1280
+ t.workspaceLabel === undefined || t.workspaceLabel === t.name
1281
+ ? `message_thread_id ${t.threadId}`
1282
+ : `herdr space ${t.workspaceLabel} · message_thread_id ${t.threadId}`,
1220
1283
  })),
1221
1284
  { label: manual, description: "type a numeric forum topic id" },
1222
1285
  { label: none, description: "send to the chat root, not a topic" },
@@ -1439,7 +1502,7 @@ type AmendChoice =
1439
1502
  * add-a-project path. Only asked when a project is already configured — a first
1440
1503
  * run has nothing to amend and is never shown this.
1441
1504
  */
1442
- async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
1505
+ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise<AmendChoice> {
1443
1506
  const mode = await ui.select(
1444
1507
  `"${prior.name}" is already configured — what would you like to do?`,
1445
1508
  [
@@ -1468,7 +1531,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<Amen
1468
1531
  return { kind: "reinterview" };
1469
1532
  }
1470
1533
 
1471
- const choices = amendChoices(prior);
1534
+ const choices = amendChoices(prior, defaults);
1472
1535
  const picked = await ui.select(
1473
1536
  "Which area? Each row shows what it says now",
1474
1537
  choices.map((c) => ({ label: c.label, description: c.description })),
@@ -1656,7 +1719,7 @@ export async function collectSetup(
1656
1719
  };
1657
1720
  }
1658
1721
 
1659
- const choice = await chooseAmendArea(ui, prior);
1722
+ const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS);
1660
1723
  if (choice.kind === "add-project") {
1661
1724
  // Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
1662
1725
  // here: the operator just chose to add, so the name comes from the prompt.
@@ -1738,11 +1801,82 @@ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig)
1738
1801
 
1739
1802
 
1740
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
+
1741
1874
  export async function setup(
1742
1875
  ui: WizardUi,
1743
1876
  projectArg: string | undefined,
1744
1877
  areaArg?: AmendAreaId,
1745
1878
  probes: SetupProbes = DEFAULT_PROBES,
1879
+ apply: SetupApplyDeps = DEFAULT_APPLY,
1746
1880
  ): Promise<void> {
1747
1881
  const path = configPath();
1748
1882
  // A config that exists but does not parse is a fault to report, never
@@ -1763,7 +1897,7 @@ export async function setup(
1763
1897
  }
1764
1898
  const { answers, amend, added } = collected;
1765
1899
 
1766
- const scopes = await checkTokenScopes();
1900
+ const scopes = await apply.scopes();
1767
1901
  if (!scopes.ok) {
1768
1902
  ui.notify(
1769
1903
  `Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
@@ -1778,7 +1912,7 @@ export async function setup(
1778
1912
  // files will contain. Each draft carries its own preview and confirm; declining
1779
1913
  // one keeps the shipped stub, so an operator who wants none reaches the same plan.
1780
1914
  const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
1781
- const labels = await planLabels(answers.trackerRepo, answers);
1915
+ const labels = await apply.labels(answers.trackerRepo, answers);
1782
1916
  const telegram = detectTelegram();
1783
1917
  let nextConfig;
1784
1918
  try {
@@ -1815,7 +1949,7 @@ export async function setup(
1815
1949
  );
1816
1950
  let queuePreview: string[];
1817
1951
  try {
1818
- queuePreview = formatPreview(await previewProject(project));
1952
+ queuePreview = formatPreview(await apply.preview(project));
1819
1953
  } catch (err) {
1820
1954
  const message = err instanceof Error ? err.message : String(err);
1821
1955
  ui.notify(
@@ -1829,8 +1963,8 @@ export async function setup(
1829
1963
  [
1830
1964
  // The delta first when there is one, then the whole plan: the confirm has
1831
1965
  // to name every mutation it authorises, and a delta names none of them.
1832
- ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
1833
- summarisePlan(answers, scopes, labels, telegram),
1966
+ ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
1967
+ summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
1834
1968
  "",
1835
1969
  formatHostRuntimePlan(runtime),
1836
1970
  "",
@@ -1858,11 +1992,21 @@ export async function setup(
1858
1992
  runtime.service.action === "keep"
1859
1993
  ? `Keeps the staged systemd unit at ${runtime.service.path}.`
1860
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).`,
1861
2000
  runtime.tick === undefined
1862
2001
  ? ""
1863
2002
  : runtime.tick.action === "keep"
1864
2003
  ? `Keeps the external heartbeat config at ${runtime.tick.path}.`
1865
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}.`,
1866
2010
  answers.writeOrchestratorBrief
1867
2011
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
1868
2012
  : "",
@@ -1882,11 +2026,12 @@ export async function setup(
1882
2026
  // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
1883
2027
  // partially applied setup unable to claim work.
1884
2028
  prepareConductor(project.name);
1885
- const created = await createMissingLabels(answers.trackerRepo, labels);
2029
+ const created = await apply.createLabels(answers.trackerRepo, labels);
1886
2030
  saveConfig(nextConfig);
1887
2031
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
1888
2032
  const runtimeFiles = writeHostRuntime(runtime);
1889
- 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);
1890
2035
  let smokeLine =
1891
2036
  `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
1892
2037
  `stored status for ${smoke.status.project}`;
@@ -1906,6 +2051,12 @@ export async function setup(
1906
2051
  ].join("\n"),
1907
2052
  "warning",
1908
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);
1909
2060
  ui.notify(formatHerdrHandoff(project, nextConfig), "info");
1910
2061
  return;
1911
2062
  }
@@ -1923,7 +2074,7 @@ export async function setup(
1923
2074
  "info",
1924
2075
  );
1925
2076
  } else {
1926
- const restarted = await restartDaemon({ project: project.name });
2077
+ const restarted = await apply.restart({ project: project.name });
1927
2078
  restartVia = restarted.via;
1928
2079
  smokeLine =
1929
2080
  `existing /healthz and stored status; restarted through ${restarted.via}; ` +
@@ -1935,7 +2086,7 @@ export async function setup(
1935
2086
  if (project.escalation.orchestrator === "external") {
1936
2087
  ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1937
2088
  try {
1938
- armLine = await ensureSetupArm(project.name);
2089
+ armLine = await apply.arm(project.name);
1939
2090
  } catch (err) {
1940
2091
  ui.notify(
1941
2092
  [
@@ -1950,7 +2101,7 @@ export async function setup(
1950
2101
  return;
1951
2102
  }
1952
2103
  }
1953
- setPaused(false, undefined, project.name);
2104
+ apply.resume(project.name);
1954
2105
 
1955
2106
  ui.notify(
1956
2107
  [
@@ -1959,9 +2110,9 @@ export async function setup(
1959
2110
  briefPath === undefined
1960
2111
  ? "Kept the existing orchestrator brief."
1961
2112
  : `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
1962
- runtimeFiles.length === 0
2113
+ runtimeFiles.wrote.length === 0
1963
2114
  ? "Host runtime files were already current."
1964
- : `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
2115
+ : `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
1965
2116
  `Smoke passed: ${smokeLine}.`,
1966
2117
  `Heartbeat: ${armLine}.`,
1967
2118
  "",
@@ -1999,24 +2150,11 @@ export async function setup(
1999
2150
  "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
2000
2151
  );
2001
2152
  if (install === true)
2002
- await runHostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2153
+ await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2003
2154
  else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
2004
2155
  }
2005
2156
 
2006
- if (graphRepos(project).length > 0) {
2007
- const wantsGraph = await ui.confirm(
2008
- "Install the code-graph indexes now?",
2009
- "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
2010
- "indexing run so the first fetch happens while you watch. Minutes per repo. " +
2011
- "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
2012
- );
2013
- if (wantsGraph === true) await runGraphInstall(project, ui);
2014
- else
2015
- ui.notify(
2016
- "Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
2017
- "info",
2018
- );
2019
- }
2157
+ await offerCodeGraph(project, ui, apply.graphInstall);
2020
2158
 
2021
2159
  // Always print: first install, amend, and add-a-project all need the operator
2022
2160
  // to create/verify the herdr pane. Added projects especially — the CLI wrote