omp-conductor 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,52 +1,46 @@
1
1
  /**
2
- * The omp plugin surface: one `/conductor` command with four subcommands.
2
+ * The setup wizard: prompts, per-area amend, and the one-writer apply sequence.
3
3
  *
4
- * `status`, `pause` and `resume` are argument parsing plus printing over
5
- * ./daemon.ts, so the plugin and the `omp-conductor` CLI can never disagree
6
- * about what a cap means or where the state lives.
7
- *
8
- * `setup` is the exception, and only in volume: it owns the dialogs and nothing
9
- * else. Every decision it makes lives in ./setup.ts, which is headless and
10
- * tested; this file turns answers into questions and back again. The invariant
11
- * worth protecting is on `setup()` below — nothing is written before the confirm.
4
+ * Extracted (#305) so the interview lived behind the `WizardUi` interface rather
5
+ * than inside the omp plugin's dialog surface; #309 then deleted that surface, and
6
+ * the terminal `omp-conductor setup` verb is now its only caller. It is
7
+ * presentation only — it turns a `WizardUi` into questions and, behind one consent
8
+ * gate,
9
+ * applies the answers through ./setup.ts which is headless and tested. Every
10
+ * prompt's shape and default is a decision worth keeping, so none of them were
11
+ * reworded in the move.
12
12
  */
13
13
  import { existsSync, readFileSync } from "node:fs";
14
- import { dirname, isAbsolute } from "node:path";
14
+ import { platform } from "node:os";
15
+ import { dirname, isAbsolute, join } from "node:path";
15
16
  import {
16
- formatBriefReport,
17
- formatMigrateResult,
18
- inspectBriefLayout,
19
- migrateToPolicy,
20
- repairPolicyBannerCrumbs,
21
- } from "./brief-upgrade.ts";
22
- import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
23
- import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
17
+ configPath,
18
+ expandHome,
19
+ findProject,
20
+ loadConfig,
21
+ resolveCaps,
22
+ saveConfig,
23
+ } from "./config.ts";
24
+ import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
24
25
  import {
25
- isPaused,
26
26
  prepareConductor,
27
27
  previewProject,
28
28
  setPaused,
29
29
  type QueuePreview,
30
30
  } from "./daemon.ts";
31
- import {
32
- armTicks,
33
- clearPaneHalt,
34
- disarmTicks,
35
- halt,
36
- haltWithPane,
37
- hold,
38
- releaseHold,
39
- renderStatus,
40
- } from "./fleet.ts";
31
+ import { armTicks, telegramStateDir } from "./fleet.ts";
41
32
  import { restartDaemon } from "./lifecycle.ts";
42
-
43
- import { defaultGraphRoot } from "./graph.ts";
33
+ import { defaultGraphRoot, graphRepos } from "./graph.ts";
44
34
  import {
45
35
  formatHostRuntimePlan,
46
36
  planHostRuntime,
37
+ totalConfiguredWorkers,
47
38
  runSetupSmoke,
39
+ SYSTEMD_UNIT_DIR,
48
40
  writeHostRuntime,
49
41
  } from "./setup-host.ts";
42
+ import { runGraphInstall, runHostInstall } from "./setup-install.ts";
43
+ import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
50
44
  import {
51
45
  AMEND_AREAS,
52
46
  BASE_FRESHNESS_CHOICES,
@@ -61,7 +55,6 @@ import {
61
55
  answersFromProject,
62
56
  briefPathForProject,
63
57
  policyPathForProject,
64
- renderFloorForProject,
65
58
  buildConfig,
66
59
  checkTokenScopes,
67
60
  createMissingLabels,
@@ -70,12 +63,13 @@ import {
70
63
  formatGates,
71
64
  orchestratorBriefPath,
72
65
  planLabels,
73
- renderBriefForProject,
74
66
  summariseAmend,
75
67
  summarisePlan,
76
68
  writeOrchestratorBrief,
77
69
  type AmendAreaId,
70
+ type OperatorJudgment,
78
71
  type SetupAnswers,
72
+ type ProbedProse,
79
73
  } from "./setup.ts";
80
74
  import {
81
75
  BASE_FRESHNESS,
@@ -94,95 +88,10 @@ import {
94
88
  type ProjectConfig,
95
89
  type ProjectPolicy,
96
90
  type ReleaseRequirement,
97
- type ReportScope,
98
91
  type ReportScopeChoice,
99
92
  type ResolvedGrants,
100
93
  } from "./types.ts";
101
-
102
- /**
103
- * The slice of the omp extension API this plugin actually touches, mirroring
104
- * `RegisteredCommand` / `ExtensionUIContext` from `@oh-my-pi/pi-coding-agent`.
105
- *
106
- * Declared here rather than imported because the harness is a peer dependency:
107
- * the package has to type-check without it installed. Structural typing means
108
- * the real API object satisfies this on the way in, and narrowing the surface
109
- * to the five members the wizard uses keeps the coupling visible.
110
- */
111
- interface Completion {
112
- value: string;
113
- label: string;
114
- description?: string;
115
- }
116
-
117
- interface CommandContext {
118
- ui: {
119
- notify(message: string, type?: "info" | "warning" | "error"): void;
120
- confirm(title: string, message: string): Promise<boolean>;
121
- /**
122
- * Single-line text prompt. Resolves `undefined` when the operator dismisses
123
- * the dialog, which the wizard treats as "abandon, change nothing".
124
- *
125
- * The harness has no pre-filled variant, so `placeholder` carries the
126
- * default and submitting an empty line accepts it.
127
- */
128
- input(title: string, placeholder?: string): Promise<string | undefined>;
129
- /**
130
- * Single-choice list. Resolves the chosen option's **label**, or
131
- * `undefined` when the operator dismisses it — so callers map labels back to
132
- * their own values rather than trusting the index.
133
- */
134
- select(
135
- title: string,
136
- options: { label: string; description?: string }[],
137
- dialogOptions?: { initialIndex?: number },
138
- ): Promise<string | undefined>;
139
- };
140
- }
141
-
142
- interface PluginApi {
143
- registerCommand(
144
- name: string,
145
- options: {
146
- description?: string;
147
- getArgumentCompletions?: (argumentPrefix: string) => Completion[] | null;
148
- handler: (args: string, ctx: CommandContext) => Promise<void>;
149
- },
150
- ): void;
151
- }
152
-
153
- const SUBCOMMANDS: Completion[] = [
154
- {
155
- value: "setup",
156
- label: "setup",
157
- description: "wizard: config, labels, dry run, then arm — or amend one area of a configured project",
158
- },
159
- { value: "status", label: "status", description: "layered fleet report: dispatch, ticks, pane, herdr, daemon" },
160
- { value: "hold", label: "hold", description: "soft stop: pause claiming AND disarm ticks" },
161
- { value: "halt", label: "halt", description: "hold + stop dispatch daemon; pass --pane to pin recovery off" },
162
- { value: "arm", label: "arm", description: "proof-gated: inbound Telegram round-trip, then write arm marker" },
163
- { value: "disarm", label: "disarm", description: "remove arm marker so ticks skip" },
164
- { value: "release-pane", label: "release-pane", description: "clear halt --pane recovery pin" },
165
- { value: "pause", label: "pause", description: "stop claiming only (ticks keep firing if armed); prefer hold" },
166
- { value: "resume", label: "resume", description: "clear pause only — does not re-arm" },
167
- {
168
- value: "brief-upgrade",
169
- label: "brief-upgrade",
170
- description: "check ORCHESTRATOR.md against the brief this version ships",
171
- },
172
- ];
173
-
174
- const USAGE = [
175
- "/conductor setup [project] create a project, or amend one area of one you already have",
176
- "/conductor status [project] layered fleet report (dispatch, ticks, pane, herdr, daemon)",
177
- "/conductor hold [project] soft stop: pause claiming AND disarm ticks",
178
- "/conductor halt [--pane] [project] hold + stop daemon; --pane pins conductor recovery off only",
179
- "/conductor arm [project] proof-gated inbound Telegram round-trip, then arm ticks",
180
- "/conductor disarm [project] remove arm marker (ticks skip)",
181
- "/conductor release-pane [project] clear halt --pane recovery pin",
182
- "/conductor pause stop claiming only (prefer hold)",
183
- "/conductor resume clear pause only — does not re-arm",
184
- "/conductor brief-upgrade [project] check ORCHESTRATOR.md against the shipped brief",
185
- ].join("\n");
94
+ import type { WizardUi } from "./wizard-ui.ts";
186
95
 
187
96
  /**
188
97
  * Dismissing any dialog abandons the whole wizard.
@@ -207,29 +116,43 @@ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
207
116
  * takes it, because the harness has no pre-filled input dialog — so "Enter
208
117
  * accepts what you see" is the contract the whole wizard is built on.
209
118
  */
210
- async function ask(ctx: CommandContext, title: string, fallback: string): Promise<string> {
211
- const raw = await ctx.ui.input(title, fallback.length > 0 ? fallback : undefined);
119
+ async function ask(ui: WizardUi, title: string, fallback: string): Promise<string> {
120
+ const raw = await ui.input(title, fallback.length > 0 ? fallback : undefined);
212
121
  if (raw === undefined) throw new Cancelled();
213
122
  const trimmed = raw.trim();
214
123
  return trimmed.length > 0 ? trimmed : fallback;
215
124
  }
216
125
 
126
+ /**
127
+ * One yes/no answer, with a dismissal treated as leaving rather than as "no".
128
+ *
129
+ * Every confirm in this wizard defaults to no, so "no" and "dismissed" look
130
+ * identical if the surface collapses them — and then Ctrl-C at a yes/no prompt
131
+ * recorded a silent no and walked on to the next question, which is not what
132
+ * abandoning a run means. `undefined` is the surface saying the operator left.
133
+ */
134
+ async function askYesNo(ui: WizardUi, title: string, message: string): Promise<boolean> {
135
+ const answer = await ui.confirm(title, message);
136
+ if (answer === undefined) throw new Cancelled();
137
+ return answer;
138
+ }
139
+
217
140
  /**
218
141
  * Re-asks until the answer passes `check`, which returns the complaint or
219
142
  * `undefined`. Bounded at three tries: a dialog that cannot be escaped is worse
220
143
  * than one that gives up and leaves the config alone.
221
144
  */
222
145
  async function askValid(
223
- ctx: CommandContext,
146
+ ui: WizardUi,
224
147
  title: string,
225
148
  fallback: string,
226
149
  check: (value: string) => string | undefined,
227
150
  ): Promise<string> {
228
151
  for (let attempt = 0; attempt < 3; attempt++) {
229
- const value = await ask(ctx, title, fallback);
152
+ const value = await ask(ui, title, fallback);
230
153
  const problem = check(value);
231
154
  if (problem === undefined) return value;
232
- ctx.ui.notify(problem, "warning");
155
+ ui.notify(problem, "warning");
233
156
  }
234
157
  throw new Cancelled();
235
158
  }
@@ -237,11 +160,11 @@ async function askValid(
237
160
 
238
161
  /** A cap. Unparseable input keeps the current value rather than writing a NaN
239
162
  * the validator would later reject — the operator sees why, immediately. */
240
- async function askNumber(ctx: CommandContext, title: string, fallback: number): Promise<number> {
241
- const raw = await ask(ctx, title, String(fallback));
163
+ async function askNumber(ui: WizardUi, title: string, fallback: number): Promise<number> {
164
+ const raw = await ask(ui, title, String(fallback));
242
165
  const value = Number(raw);
243
166
  if (!Number.isFinite(value) || value < 0) {
244
- ctx.ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
167
+ ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
245
168
  return fallback;
246
169
  }
247
170
  return value;
@@ -253,16 +176,16 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
253
176
  * can turn the money brake off without writing a magic 0 (which is a hard stop).
254
177
  */
255
178
  async function askSpendCap(
256
- ctx: CommandContext,
179
+ ui: WizardUi,
257
180
  title: string,
258
181
  fallback: number | null,
259
182
  ): Promise<number | null> {
260
183
  const seed = fallback === null ? "" : String(fallback);
261
- const raw = (await ask(ctx, title, seed)).trim().toLowerCase();
184
+ const raw = (await ask(ui, title, seed)).trim().toLowerCase();
262
185
  if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
263
186
  const value = Number(raw);
264
187
  if (!Number.isFinite(value) || value < 0) {
265
- ctx.ui.notify(
188
+ ui.notify(
266
189
  `"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`,
267
190
  "warning",
268
191
  );
@@ -278,18 +201,23 @@ async function askSpendCap(
278
201
  *
279
202
  * ponytail: the ceiling is a command containing a comma or a literal " @ ",
280
203
  * which this would split wrongly. Rare in a lint or test invocation, and the
281
- * config file is hand-editable. Upgrade path is `ctx.ui.editor()`, a real
204
+ * config file is hand-editable. Upgrade path is `ui.editor()`, a real
282
205
  * multi-line buffer, once someone hits it.
283
206
  */
284
207
  async function askGates(
285
- ctx: CommandContext,
208
+ ui: WizardUi,
286
209
  repoName: string,
287
210
  seed: { cmd: string; cwd: string }[],
211
+ probed: { cmd: string; cwd: string }[] = [],
288
212
  ): Promise<{ cmd: string; cwd: string }[]> {
213
+ // A proposal fills the prompt only where the operator has not already decided.
214
+ // Overwriting gates they typed with a model's reading would be the one place a
215
+ // probe stopped being a proposal; `probeGates` has already shown them what it
216
+ // found, so it is on screen either way.
289
217
  const raw = await ask(
290
- ctx,
218
+ ui,
291
219
  `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
292
- formatGates(seed),
220
+ formatGates(seed.length > 0 ? seed : probed),
293
221
  );
294
222
 
295
223
  const gates: { cmd: string; cwd: string }[] = [];
@@ -304,7 +232,7 @@ async function askGates(
304
232
  if (gates.length === 0) {
305
233
  // Loud, because an unattended push with no gate is how a lint failure
306
234
  // reaches the runners at 03:00 with nobody watching.
307
- ctx.ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning");
235
+ ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning");
308
236
  }
309
237
  return gates;
310
238
  }
@@ -315,10 +243,10 @@ async function askGates(
315
243
  * means silence. The cursor starts on the current setting so Enter re-affirms
316
244
  * it, the same contract every other prompt here has.
317
245
  */
318
- async function askReportScope(ctx: CommandContext, current: ReportScopeChoice): Promise<ReportScopeChoice> {
246
+ async function askReportScope(ui: WizardUi, current: ReportScopeChoice): Promise<ReportScopeChoice> {
319
247
  const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
320
248
  const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
321
- const picked = await ctx.ui.select("What should the orchestrator report unprompted?", options, {
249
+ const picked = await ui.select("What should the orchestrator report unprompted?", options, {
322
250
  initialIndex: at === -1 ? 0 : at,
323
251
  });
324
252
  if (picked === undefined) throw new Cancelled();
@@ -328,7 +256,7 @@ async function askReportScope(ctx: CommandContext, current: ReportScopeChoice):
328
256
  // The harness answers with a label we did not offer only if the dialog
329
257
  // contract changed under us; keeping the current scope is the answer that
330
258
  // changes nothing, and it is said out loud rather than assumed.
331
- ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
259
+ ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
332
260
  return current;
333
261
  }
334
262
  return choice.scope;
@@ -347,14 +275,14 @@ async function askReportScope(ctx: CommandContext, current: ReportScopeChoice):
347
275
  * lookup table that could disagree with the vocabulary it was built from.
348
276
  */
349
277
  async function askLiteral<T extends string>(
350
- ctx: CommandContext,
278
+ ui: WizardUi,
351
279
  title: string,
352
280
  values: readonly T[],
353
281
  described: { readonly [K in T]: string },
354
282
  current: T,
355
283
  ): Promise<T> {
356
284
  const at = values.findIndex((v) => v === current);
357
- const picked = await ctx.ui.select(
285
+ const picked = await ui.select(
358
286
  title,
359
287
  values.map((v) => ({ label: v, description: described[v] })),
360
288
  { initialIndex: at === -1 ? 0 : at },
@@ -366,7 +294,7 @@ async function askLiteral<T extends string>(
366
294
  // The harness answered with a label we never offered, which only happens if
367
295
  // the dialog contract changed under us. Keeping the current value is the
368
296
  // answer that changes nothing, and it is said out loud rather than assumed.
369
- ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
297
+ ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
370
298
  return current;
371
299
  }
372
300
  return hit;
@@ -392,8 +320,8 @@ function parseNameList(answer: string): string[] {
392
320
 
393
321
  /** Check names, artefacts, environments: open-ended lists this package cannot
394
322
  * enumerate, so the only validation is the shape. */
395
- async function askNameList(ctx: CommandContext, title: string, seed: readonly string[]): Promise<string[]> {
396
- return parseNameList(await ask(ctx, title, formatNameList(seed)));
323
+ async function askNameList(ui: WizardUi, title: string, seed: readonly string[]): Promise<string[]> {
324
+ return parseNameList(await ask(ui, title, formatNameList(seed)));
397
325
  }
398
326
 
399
327
  /**
@@ -405,19 +333,19 @@ async function askNameList(ctx: CommandContext, title: string, seed: readonly st
405
333
  * that went ahead without it.
406
334
  */
407
335
  async function askReleaseRequirements(
408
- ctx: CommandContext,
336
+ ui: WizardUi,
409
337
  prior: readonly ReleaseRequirement[],
410
338
  ): Promise<ReleaseRequirement[]> {
411
339
  const accepted = RELEASE_REQUIREMENTS.join(", ");
412
340
  // The vocabulary, spelled out where it is being asked for. Built from the same
413
341
  // data the validator reads, so a fifth requirement is offered here the moment
414
342
  // it exists rather than staying invisible to everyone who did not read #129.
415
- ctx.ui.notify(
343
+ ui.notify(
416
344
  RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"),
417
345
  "info",
418
346
  );
419
347
  const answered = await askValid(
420
- ctx,
348
+ ui,
421
349
  `Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
422
350
  formatNameList(prior),
423
351
  (value) => {
@@ -440,29 +368,29 @@ async function askReleaseRequirements(
440
368
  * only reach by opening `config.json` is one that stays at its default while
441
369
  * their prose says something else, which is the drift this key ended.
442
370
  */
443
- async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy): Promise<ProjectPolicy> {
371
+ async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promise<ProjectPolicy> {
444
372
  const merge = {
445
373
  requiredChecks: await askNameList(
446
- ctx,
374
+ ui,
447
375
  `Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
448
376
  prior.merge.requiredChecks,
449
377
  ),
450
378
  baseFreshness: await askLiteral(
451
- ctx,
379
+ ui,
452
380
  "Merge — must the PR be level with its base?",
453
381
  BASE_FRESHNESS,
454
382
  BASE_FRESHNESS_CHOICES,
455
383
  prior.merge.baseFreshness,
456
384
  ),
457
385
  drafts: await askLiteral(
458
- ctx,
386
+ ui,
459
387
  "Merge — draft pull requests",
460
388
  DRAFT_POLICIES,
461
389
  DRAFT_POLICY_CHOICES,
462
390
  prior.merge.drafts,
463
391
  ),
464
392
  whenBehindBase: await askLiteral(
465
- ctx,
393
+ ui,
466
394
  "Merge — a green PR that fell behind its base",
467
395
  BEHIND_BASE_ACTIONS,
468
396
  BEHIND_BASE_CHOICES,
@@ -471,19 +399,19 @@ async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy)
471
399
  };
472
400
 
473
401
  const release = {
474
- requires: await askReleaseRequirements(ctx, prior.release.requires),
402
+ requires: await askReleaseRequirements(ui, prior.release.requires),
475
403
  requiredChecks: await askNameList(
476
- ctx,
404
+ ui,
477
405
  `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
478
406
  prior.release.requiredChecks,
479
407
  ),
480
408
  artefacts: await askNameList(
481
- ctx,
409
+ ui,
482
410
  `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
483
411
  prior.release.artefacts,
484
412
  ),
485
413
  environments: await askNameList(
486
- ctx,
414
+ ui,
487
415
  `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
488
416
  prior.release.environments,
489
417
  ),
@@ -503,16 +431,18 @@ async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy)
503
431
  * current grant is named in the question so the revoke is never a surprise.
504
432
  */
505
433
  async function askAuthority(
506
- ctx: CommandContext,
434
+ ui: WizardUi,
507
435
  prior: ProjectConfig["authority"],
508
436
  ): Promise<ProjectConfig["authority"]> {
509
- const merge = await ctx.ui.confirm(
437
+ const merge = await askYesNo(
438
+ ui,
510
439
  "Merge authority",
511
440
  "Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
512
441
  "re-checked against the base branch first. Default: humans merge" +
513
442
  `${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
514
443
  );
515
- const release = await ctx.ui.confirm(
444
+ const release = await askYesNo(
445
+ ui,
516
446
  "Release authority",
517
447
  "Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
518
448
  "procedure you write into its brief — and its brief forbids cutting one before you have. " +
@@ -541,6 +471,139 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
541
471
  "This is the one grant that mutates a live environment rather than producing an artifact",
542
472
  };
543
473
 
474
+ /**
475
+ * The questions no probe can answer, because the answers are not in the repo.
476
+ *
477
+ * Ported from the onboarding skill, which is explicit about why each one has to
478
+ * be asked. Today's wizard asks none of them: `askAuthority` and
479
+ * `askReleaseGrants` record *who holds* each release shape and never the prose
480
+ * that makes a delegated release safe — and `src/briefs/policy.md` ships stubs
481
+ * sitting empty because of it.
482
+ *
483
+ * Asked before any prose probe runs, so intent always precedes machinery: a
484
+ * probe's job is to turn these words into the repo's own steps, never to invent
485
+ * what the operator wants.
486
+ */
487
+ async function askJudgment(
488
+ ui: WizardUi,
489
+ grants: ResolvedGrants,
490
+ prior: OperatorJudgment,
491
+ ): Promise<OperatorJudgment> {
492
+ // Always asked. A tracker shows what is open, never what matters, and an
493
+ // orchestrator that cannot rank work grooms by recency — which is how a stale
494
+ // issue outranks the thing being shipped this month.
495
+ const roadmap = await ask(
496
+ ui,
497
+ "Where does the roadmap live, and what is the current priority?",
498
+ prior.roadmap ?? "",
499
+ );
500
+ const judgment: OperatorJudgment = { ...prior, ...(roadmap.length === 0 ? {} : { roadmap }) };
501
+
502
+ const delegated = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
503
+ if (delegated.length === 0) {
504
+ // Humans release: there is no boundary to draw and no procedure to write, so
505
+ // asking would invite prose the config does not grant.
506
+ return judgment;
507
+ }
508
+
509
+ if (delegated.length === RELEASE_SHAPES.length) {
510
+ // Full release. Push back once, concretely, then record what they decide —
511
+ // it is their fleet. Shown once, never per shape.
512
+ ui.notify(
513
+ [
514
+ "You have granted the orchestrator every release shape. Two things to weigh:",
515
+ "",
516
+ " Credentials. Full release means the session holds publish tokens, registry",
517
+ " credentials or deploy keys. Those sit in the environment of a session that runs",
518
+ " unattended for weeks — including a turn that went wrong.",
519
+ "",
520
+ " Rollback. An agent that can release owns the 03:00 rollback too, and a rollback",
521
+ " is a judgement call under time pressure with partial information: the exact",
522
+ " thing agents are worst at.",
523
+ "",
524
+ "Answer the next questions anyway if that is the fleet you want.",
525
+ ].join("\n"),
526
+ "warning",
527
+ );
528
+ }
529
+
530
+ // Not "can it release" — *where does its leg stop*. A boundary that cannot be
531
+ // said in one sentence is not a boundary, and a vague release mandate is what
532
+ // eventually publishes something at 03:00. The worked example is the skill's,
533
+ // so an operator has something to calibrate against.
534
+ judgment.boundary = await ask(
535
+ ui,
536
+ "Where does the orchestrator's leg END? One sentence " +
537
+ '(a real answer: "at the merged version pin — deploying it is operator territory")',
538
+ prior.boundary ?? "",
539
+ );
540
+
541
+ // All five, because the brief needs each and a missing one is a hole.
542
+ judgment.releaseWhat = await ask(ui, "Release — WHAT may be released, and from which branch?", prior.releaseWhat ?? "");
543
+ judgment.releaseWhen = await ask(
544
+ ui,
545
+ "Release — WHEN: batched how, after which named checks are green?",
546
+ prior.releaseWhen ?? "",
547
+ );
548
+ judgment.releaseProof = await ask(
549
+ ui,
550
+ "Release — WHAT PROOF must be held first (results actually read, not an impression)?",
551
+ prior.releaseProof ?? "",
552
+ );
553
+ judgment.releaseAsk = await ask(ui, "Release — what must still be ASKED, every time?", prior.releaseAsk ?? "");
554
+ judgment.releaseForbidden = await ask(
555
+ ui,
556
+ "Release — what stays permanently FORBIDDEN?",
557
+ prior.releaseForbidden ?? "force-push, secrets, production data",
558
+ );
559
+
560
+ // The batching unit, in the operator's own vocabulary. Without it the
561
+ // orchestrator either releases per merge — a stream of meaningless versions
562
+ // burning shared runners — or never releases at all. Distinct from
563
+ // `RELEASE_REQUIREMENTS`, which are mechanical preconditions, not a unit.
564
+ judgment.worthCutting = await ask(
565
+ ui,
566
+ "What is a release worth cutting? (a sprint, an epic's children all closed, N merged issues, urgency)",
567
+ prior.worthCutting ?? "",
568
+ );
569
+
570
+ judgment.rollbackOwner = await ask(ui, "Who owns the rollback?", prior.rollbackOwner ?? "");
571
+ if (namesAPerson(judgment.rollbackOwner)) {
572
+ // Honour the consequence rather than recording a contradiction: if a person
573
+ // rolls it back, that person owns the release, and the boundary belongs
574
+ // before the irreversible step whatever the authority answer sounded like.
575
+ judgment.rollbackMovesBoundary = await askYesNo(
576
+ ui,
577
+ "Rollback owner is a person",
578
+ `You named "${judgment.rollbackOwner}" as the rollback owner. That person already owns the release, ` +
579
+ "so the honest configuration puts the orchestrator's boundary *before* the irreversible step — " +
580
+ "it prepares and verifies, a human performs it. Record the boundary that way?",
581
+ );
582
+ if (judgment.rollbackMovesBoundary) {
583
+ judgment.boundary = await ask(
584
+ ui,
585
+ "Restate the boundary, ending before the irreversible step",
586
+ judgment.boundary ?? "",
587
+ );
588
+ }
589
+ }
590
+ return judgment;
591
+ }
592
+
593
+ /**
594
+ * Whether a rollback answer names a human rather than a mechanism.
595
+ *
596
+ * Deliberately crude: the point is to *raise* the consequence for the operator to
597
+ * accept or decline, and a false positive costs one extra question while a false
598
+ * negative silently records the contradiction the skill warns about.
599
+ */
600
+ function namesAPerson(answer: string): boolean {
601
+ const mechanical = ["ci", "workflow", "pipeline", "automation", "the agent", "orchestrator", "nobody", "none", "n/a"];
602
+ const trimmed = answer.trim().toLowerCase();
603
+ if (trimmed.length === 0) return false;
604
+ return !mechanical.some((m) => trimmed === m || trimmed.startsWith(`${m} `));
605
+ }
606
+
544
607
  /**
545
608
  * The mechanical tool gate, one confirm per shape.
546
609
  *
@@ -554,12 +617,13 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
554
617
  * that revoke is never a surprise.
555
618
  */
556
619
  async function askReleaseGrants(
557
- ctx: CommandContext,
620
+ ui: WizardUi,
558
621
  prior: ResolvedGrants,
559
622
  ): Promise<ResolvedGrants> {
560
623
  const grants = { ...prior };
561
624
  for (const shape of RELEASE_SHAPES) {
562
- const open = await ctx.ui.confirm(
625
+ const open = await askYesNo(
626
+ ui,
563
627
  `Release tool gate — ${shape}`,
564
628
  `Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
565
629
  "operator brief carries the procedure it must follow. A worker session is refused this " +
@@ -577,8 +641,9 @@ async function askReleaseGrants(
577
641
  * no such session exists leaves tier-1 escalations sitting in issue comments
578
642
  * that nobody drains.
579
643
  */
580
- async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode): Promise<OrchestratorMode> {
581
- const external = await ctx.ui.confirm(
644
+ async function askOrchestratorMode(ui: WizardUi, prior: OrchestratorMode): Promise<OrchestratorMode> {
645
+ const external = await askYesNo(
646
+ ui,
582
647
  "Orchestrator session",
583
648
  "Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
584
649
  "Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
@@ -601,12 +666,13 @@ async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode)
601
666
  * that was indexed.
602
667
  */
603
668
  async function askGraphRoot(
604
- ctx: CommandContext,
669
+ ui: WizardUi,
605
670
  trackerRepo: string,
606
671
  repoNames: string[],
607
672
  prior: string | undefined,
608
673
  ): Promise<string | undefined> {
609
- const wanted = await ctx.ui.confirm(
674
+ const wanted = await askYesNo(
675
+ ui,
610
676
  "Code-graph discovery",
611
677
  "Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
612
678
  'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
@@ -616,7 +682,7 @@ async function askGraphRoot(
616
682
  if (!wanted) return undefined;
617
683
 
618
684
  return await askValid(
619
- ctx,
685
+ ui,
620
686
  `Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
621
687
  prior ?? defaultGraphRoot(trackerRepo),
622
688
  (v) =>
@@ -632,9 +698,10 @@ async function askGraphRoot(
632
698
  * fleet's release and reporting policy ends up, so it is never overwritten by
633
699
  * an operator who only meant to re-run setup.
634
700
  */
635
- async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promise<boolean> {
701
+ async function askOrchestratorBrief(ui: WizardUi, a: SetupAnswers): Promise<boolean> {
636
702
  const path = orchestratorBriefPath(a);
637
- const wanted = await ctx.ui.confirm(
703
+ const wanted = await askYesNo(
704
+ ui,
638
705
  `Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
639
706
  `Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
640
707
  `(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` +
@@ -643,7 +710,8 @@ async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promi
643
710
  if (!wanted) return false;
644
711
  if (!existsSync(path)) return true;
645
712
 
646
- return await ctx.ui.confirm(
713
+ return await askYesNo(
714
+ ui,
647
715
  `Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
648
716
  `${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
649
717
  );
@@ -666,7 +734,91 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
666
734
  * prompts or the defaults they pre-fill from: the value shown is always the value
667
735
  * that would otherwise be carried through.
668
736
  */
669
- type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>;
737
+ type AreaAsker = (ui: WizardUi, a: SetupAnswers, probes: SetupProbes) => Promise<SetupAnswers>;
738
+
739
+ /**
740
+ * The repo-reading half of onboarding, injected rather than called directly.
741
+ *
742
+ * One seam, for three reasons: `--no-ai` becomes a value instead of a branch at
743
+ * every call site; the wizard's own tests keep running with no model and no clone;
744
+ * and a probe stays visibly optional — every asker below still works when it
745
+ * returns nothing, which is the contract that lets setup survive an unreachable
746
+ * peer, a private repo, or a model that answered in prose.
747
+ */
748
+ export interface SetupProbes {
749
+ /** Proposes a repo's pre-push gates by reading its CI. `[]` seeds nothing. */
750
+ gates(ui: WizardUi, target: ProbeTarget): Promise<ProbedGate[]>;
751
+ /**
752
+ * Drafts the brief's prose halves, each previewed and confirmed before it is
753
+ * kept. `undefined` — declined, unavailable, `--no-ai` — keeps the shipped stub.
754
+ */
755
+ prose(ui: WizardUi, a: SetupAnswers): Promise<ProbedProse>;
756
+ }
757
+
758
+ /** Reads each repo to propose answers. */
759
+ export const DEFAULT_PROBES: SetupProbes = {
760
+ gates: (ui, target) => probeGates(ui, target),
761
+ prose: (ui, a) => proseFromRepos(ui, a),
762
+ };
763
+
764
+ /** `--no-ai`, and every wizard test: nothing is read, nothing is proposed. */
765
+ export const NO_PROBES: SetupProbes = { gates: async () => [], prose: async () => ({}) };
766
+
767
+ /**
768
+ * The brief's two prose halves, drafted against **every** configured repo.
769
+ *
770
+ * All of them, in one workspace, because both answers are cross-repo by nature and
771
+ * the skill this ports said so: "read, per routing repo" for the context, "per repo
772
+ * that can be released" for the procedure. A draft from one repo could not say
773
+ * which repo owns which concern, which repos ship together, or where the release
774
+ * machinery actually lives — it would have to guess, and a guess in a file the
775
+ * orchestrator re-reads every tick is an instruction it follows all week.
776
+ *
777
+ * One probe per section rather than per repo: `POLICY.md` has one Project context
778
+ * and one Releases procedure, and N drafts would have to be merged by hand.
779
+ */
780
+ async function proseFromRepos(ui: WizardUi, a: SetupAnswers): Promise<ProbedProse> {
781
+ if (a.targetRepos.length === 0) return {};
782
+ const targets: ProbeTarget[] = a.targetRepos.map((r) => ({
783
+ name: r.name,
784
+ cloneUrl: r.cloneUrl,
785
+ defaultBranch: r.defaultBranch,
786
+ }));
787
+ const j = a.judgment ?? {};
788
+
789
+ const context = await probeProse(ui, "project-context", "the project context", targets, {
790
+ REPOS: probeRepoMap(targets, a.routingLabelPrefix),
791
+ ROADMAP: j.roadmap ?? "",
792
+ });
793
+
794
+ // Only when the operator actually delegated a release: with no stated intent
795
+ // there is nothing for the probe to express, and a procedure drafted from a
796
+ // repo's machinery alone is the invention the template forbids.
797
+ const delegated = [j.releaseWhat, j.releaseWhen, j.boundary].some(
798
+ (v) => v !== undefined && v.trim().length > 0,
799
+ );
800
+ const procedure = delegated
801
+ ? await probeProse(ui, "release-procedure", "the release procedure", targets, {
802
+ // Without this the probe cannot follow its own instruction to name repos by
803
+ // routing key: the checkouts are generated `repo-N` directories, and an
804
+ // arbitrary key is not recoverable from one.
805
+ REPOS: probeRepoMap(targets, a.routingLabelPrefix),
806
+ BOUNDARY: j.boundary ?? "",
807
+ WHAT: j.releaseWhat ?? "",
808
+ WHEN: j.releaseWhen ?? "",
809
+ PROOF: j.releaseProof ?? "",
810
+ ASK: j.releaseAsk ?? "",
811
+ FORBIDDEN: j.releaseForbidden ?? "",
812
+ WORTH_CUTTING: j.worthCutting ?? "",
813
+ ROLLBACK_OWNER: j.rollbackOwner ?? "",
814
+ })
815
+ : undefined;
816
+
817
+ return {
818
+ ...(context === undefined ? {} : { projectContext: context }),
819
+ ...(procedure === undefined ? {} : { releaseProcedure: procedure }),
820
+ };
821
+ }
670
822
 
671
823
  /**
672
824
  * Where work comes from and where it lands: tracker, labels, routing prefix, and
@@ -674,16 +826,16 @@ type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>
674
826
  * is one fact — the identity of the queue — and changing any part of it without
675
827
  * seeing the rest is how a routing prefix stops matching its labels.
676
828
  */
677
- const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
829
+ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
678
830
  const trackerRepo = await askValid(
679
- ctx,
831
+ ui,
680
832
  "Tracker repo (owner/repo) — where ready issues live",
681
833
  a.trackerRepo,
682
834
  (v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
683
835
  );
684
836
 
685
837
  const queueLabel = await ask(
686
- ctx,
838
+ ui,
687
839
  "Queue label — the human sign-off that makes an issue claimable",
688
840
  a.queueLabel,
689
841
  );
@@ -692,19 +844,20 @@ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
692
844
  // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
693
845
  // its reputation.
694
846
  const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
695
- const customiseStates = await ctx.ui.confirm(
847
+ const customiseStates = await askYesNo(
848
+ ui,
696
849
  "State labels",
697
850
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
698
851
  `"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
699
852
  );
700
853
  if (customiseStates) {
701
- stateLabels.inProgress = await ask(ctx, "Label for a run in progress", stateLabels.inProgress);
702
- stateLabels.blocked = await ask(ctx, "Label for a run parked on a human", stateLabels.blocked);
703
- stateLabels.failed = await ask(ctx, "Label for a run that gave up", stateLabels.failed);
854
+ stateLabels.inProgress = await ask(ui, "Label for a run in progress", stateLabels.inProgress);
855
+ stateLabels.blocked = await ask(ui, "Label for a run parked on a human", stateLabels.blocked);
856
+ stateLabels.failed = await ask(ui, "Label for a run that gave up", stateLabels.failed);
704
857
  }
705
858
 
706
859
  const routingLabelPrefix = await ask(
707
- ctx,
860
+ ui,
708
861
  "Routing label prefix — an issue picks its checkout with <prefix><repo>",
709
862
  a.routingLabelPrefix,
710
863
  );
@@ -713,25 +866,30 @@ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
713
866
  for (let i = 0; ; i++) {
714
867
  const seed = a.targetRepos[i];
715
868
  const name = await askValid(
716
- ctx,
869
+ ui,
717
870
  `Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
718
871
  seed?.name ?? "",
719
872
  (v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."),
720
873
  );
721
874
  const cloneUrl = await askValid(
722
- ctx,
875
+ ui,
723
876
  `Clone URL for ${routingLabelPrefix}${name}`,
724
877
  seed?.cloneUrl ?? "",
725
878
  (v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."),
726
879
  );
727
880
  const defaultBranch = await ask(
728
- ctx,
881
+ ui,
729
882
  `Default branch for ${name} — worktrees are cut from it and PRs target it`,
730
883
  seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
731
884
  );
732
- targetRepos.push({ name, cloneUrl, defaultBranch, gates: await askGates(ctx, name, seed?.gates ?? []) });
733
-
734
- const more = await ctx.ui.confirm(
885
+ // Read before the prompt, so what the probe found is on screen while the
886
+ // operator reads the default. Skipped when this repo already has gates.
887
+ const existingGates = seed?.gates ?? [];
888
+ const probed = existingGates.length > 0 ? [] : await probes.gates(ui, { name, cloneUrl, defaultBranch });
889
+ targetRepos.push({ name, cloneUrl, defaultBranch, gates: await askGates(ui, name, existingGates, probed) });
890
+
891
+ const more = await askYesNo(
892
+ ui,
735
893
  "Another repo?",
736
894
  `${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`,
737
895
  );
@@ -748,15 +906,19 @@ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
748
906
  * clone URL does, and re-typing four repos to correct one lint invocation is the
749
907
  * reason an operator edits config.json by hand instead.
750
908
  */
751
- const askGatesOnly: AreaAsker = async (ctx, a) => {
909
+ const askGatesOnly: AreaAsker = async (ui, a, probes) => {
752
910
  if (a.targetRepos.length === 0) {
753
- ctx.ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
911
+ ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
754
912
  return a;
755
913
  }
756
914
 
757
915
  const targetRepos: SetupAnswers["targetRepos"] = [];
758
916
  for (const r of a.targetRepos) {
759
- targetRepos.push({ ...r, gates: await askGates(ctx, r.name, r.gates) });
917
+ // Re-read even though gates exist: this is the area an operator opens *because*
918
+ // CI changed, so a fresh proposal is the whole reason they came. Their current
919
+ // gates stay the default; the proposal is shown beside it.
920
+ const probed = await probes.gates(ui, { name: r.name, cloneUrl: r.cloneUrl, defaultBranch: r.defaultBranch });
921
+ targetRepos.push({ ...r, gates: await askGates(ui, r.name, r.gates, probed) });
760
922
  }
761
923
  return { ...a, targetRepos };
762
924
  };
@@ -765,9 +927,9 @@ const askGatesOnly: AreaAsker = async (ctx, a) => {
765
927
  * Whether workers get a code graph, and where its clones live. Asked after the
766
928
  * repos in the full interview because the answer is derived per repo.
767
929
  */
768
- const askGraph: AreaAsker = async (ctx, a) => {
930
+ const askGraph: AreaAsker = async (ui, a) => {
769
931
  const graphRoot = await askGraphRoot(
770
- ctx,
932
+ ui,
771
933
  a.trackerRepo,
772
934
  a.targetRepos.map((r) => r.name),
773
935
  a.graphRoot,
@@ -783,7 +945,7 @@ const askGraph: AreaAsker = async (ctx, a) => {
783
945
 
784
946
  /** The hard ceilings. One confirm first, because the shipped defaults are the
785
947
  * answer for anyone who has not measured their own runners. */
786
- const askCaps: AreaAsker = async (ctx, a) => {
948
+ const askCaps: AreaAsker = async (ui, a) => {
787
949
  const caps: Partial<Caps> = { ...a.caps };
788
950
  const spendLabel =
789
951
  DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
@@ -796,7 +958,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
796
958
  workersDefault < DEFAULT_CAPS.maxConcurrentWorkers
797
959
  ? ` This host looks under 16 GiB RAM, so the worker default is ${workersDefault} instead of ${DEFAULT_CAPS.maxConcurrentWorkers}.`
798
960
  : "";
799
- const tuneCaps = await ctx.ui.confirm(
961
+ const tuneCaps = await askYesNo(
962
+ ui,
800
963
  "Caps",
801
964
  `Defaults: ${workersDefault} workers, ` +
802
965
  `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
@@ -818,17 +981,17 @@ const askCaps: AreaAsker = async (ctx, a) => {
818
981
  // Spelled out rather than looped: adding a cap should fail to compile here,
819
982
  // not silently go unasked.
820
983
  caps.maxConcurrentWorkers = await askNumber(
821
- ctx,
984
+ ui,
822
985
  "Max concurrent workers",
823
986
  workersDefault,
824
987
  );
825
988
  caps.dailySpendUsd = await askSpendCap(
826
- ctx,
989
+ ui,
827
990
  "Spend ceiling per rolling day (USD) — blank = no spend cap",
828
991
  caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
829
992
  );
830
993
  const workerMaxTurns = await askNumber(
831
- ctx,
994
+ ui,
832
995
  "Turn ceiling per worker",
833
996
  caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
834
997
  );
@@ -839,12 +1002,12 @@ const askCaps: AreaAsker = async (ctx, a) => {
839
1002
  Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
840
1003
  );
841
1004
  const workerMaxTurnsCeiling = await askNumber(
842
- ctx,
1005
+ ui,
843
1006
  "Maximum turn ceiling for one issue",
844
1007
  turnCeilingFallback,
845
1008
  );
846
1009
  if (workerMaxTurnsCeiling < workerMaxTurns) {
847
- ctx.ui.notify(
1010
+ ui.notify(
848
1011
  `Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
849
1012
  `${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
850
1013
  "warning",
@@ -854,17 +1017,17 @@ const askCaps: AreaAsker = async (ctx, a) => {
854
1017
  caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
855
1018
  }
856
1019
  caps.workerWallClockMs = await askNumber(
857
- ctx,
1020
+ ui,
858
1021
  "Wall-clock ceiling per worker (ms)",
859
1022
  caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
860
1023
  );
861
1024
  caps.maxAttemptsPerIssue = await askNumber(
862
- ctx,
1025
+ ui,
863
1026
  "Failed implementation attempts per issue before escalation",
864
1027
  caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
865
1028
  );
866
1029
  caps.maxContinuationsPerIssue = await askNumber(
867
- ctx,
1030
+ ui,
868
1031
  "Operational continuations per issue before escalation",
869
1032
  caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
870
1033
  );
@@ -873,8 +1036,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
873
1036
 
874
1037
  /** Outside the caps block: a model is not a ceiling, and an operator who left
875
1038
  * the caps alone may still want workers on a cheaper model. */
876
- const askWorkerModel: AreaAsker = async (ctx, a) => {
877
- const answered = await ask(ctx, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
1039
+ const askWorkerModel: AreaAsker = async (ui, a) => {
1040
+ const answered = await ask(ui, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
878
1041
  const next: SetupAnswers = { ...a };
879
1042
  if (answered.trim().length === 0) delete next.workerModel;
880
1043
  else next.workerModel = answered.trim();
@@ -883,58 +1046,144 @@ const askWorkerModel: AreaAsker = async (ctx, a) => {
883
1046
 
884
1047
  /** The two ownership questions, then the mechanical gate one shape at a time:
885
1048
  * together they are what decides what an unattended fleet may do unasked. */
886
- const askAuthorityArea: AreaAsker = async (ctx, a) => ({
887
- ...a,
888
- authority: await askAuthority(ctx, a.authority),
889
- releaseGrants: await askReleaseGrants(ctx, a.releaseGrants),
890
- });
1049
+ const askAuthorityArea: AreaAsker = async (ui, a) => {
1050
+ const authority = await askAuthority(ui, a.authority);
1051
+ const releaseGrants = await askReleaseGrants(ui, a.releaseGrants);
1052
+ // Asked here because it is the same decision one layer down: the grants say
1053
+ // who may act, and these say where that permission stops. Amending authority
1054
+ // therefore re-asks the boundary, which is the point — a grant widened without
1055
+ // restating the boundary is how a delegated release loses its end.
1056
+ const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {});
1057
+ return { ...a, authority, releaseGrants, judgment };
1058
+ };
891
1059
 
892
1060
  /** What a merge and a release must satisfy. Asked straight after the grants:
893
1061
  * who may act, then under what conditions (#129). */
894
- const askPolicy: AreaAsker = async (ctx, a) => ({ ...a, policy: await askPolicyPreconditions(ctx, a.policy) });
1062
+ const askPolicy: AreaAsker = async (ui, a) => ({ ...a, policy: await askPolicyPreconditions(ui, a.policy) });
895
1063
 
896
1064
  /** How a stuck run reaches a human, and who triages it when it does. */
897
- const askEscalation: AreaAsker = async (ctx, a) => {
1065
+ const askEscalation: AreaAsker = async (ui, a) => {
898
1066
  const telegram = detectTelegram();
899
1067
  let telegramChatId = a.telegramChatId;
900
1068
  if (telegram.available && telegram.hasToken) {
901
1069
  if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
902
- const usePaired = await ctx.ui.confirm(
1070
+ const usePaired = await askYesNo(
1071
+ ui,
903
1072
  "Tier-2 escalations",
904
1073
  `omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`,
905
1074
  );
906
1075
  if (usePaired) telegramChatId = telegram.pairedOwnerId;
907
1076
  } else {
908
- const answered = await ask(ctx, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
1077
+ const answered = await ask(ui, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
909
1078
  telegramChatId = answered.length > 0 ? answered : undefined;
910
1079
  }
911
1080
  } else {
912
1081
  // Not an error: tier 2 degrades to a comment, which is the documented fallback.
913
- ctx.ui.notify(
1082
+ ui.notify(
914
1083
  `No usable omp-telegram install at ${telegram.stateDir} — tier-2 escalations will comment on the issue.`,
915
1084
  "info",
916
1085
  );
917
1086
  }
918
1087
 
919
- const fallbackToIssueComment = await ctx.ui.confirm(
1088
+ const telegramTopicId =
1089
+ telegramChatId === undefined
1090
+ ? undefined
1091
+ : await askTelegramTopicId(ui, telegram.stateDir, a.telegramTopicId);
1092
+
1093
+ const fallbackToIssueComment = await askYesNo(
1094
+ ui,
920
1095
  "Escalation fallback",
921
1096
  "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
922
1097
  );
923
1098
 
924
- const orchestratorMode = await askOrchestratorMode(ctx, a.orchestratorMode);
1099
+ const orchestratorMode = await askOrchestratorMode(ui, a.orchestratorMode);
925
1100
 
926
1101
  const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode };
927
1102
  if (telegramChatId === undefined) delete next.telegramChatId;
928
1103
  else next.telegramChatId = telegramChatId;
1104
+ if (telegramTopicId === undefined) delete next.telegramTopicId;
1105
+ else next.telegramTopicId = telegramTopicId;
929
1106
  return next;
930
1107
  };
931
1108
 
1109
+ /**
1110
+ * Forum topic for tier-2 pages. Claimed threads from omp-telegram's
1111
+ * `threads.json` are offered when readable; a missing file is silent and the
1112
+ * operator can still type an id or keep flat chat (#318).
1113
+ */
1114
+ async function askTelegramTopicId(
1115
+ ui: WizardUi,
1116
+ stateDir: string,
1117
+ prior: number | undefined,
1118
+ ): Promise<number | undefined> {
1119
+ const claimed = readClaimedTelegramTopics(stateDir);
1120
+ const manual = "Enter thread id manually";
1121
+ const none = "None — flat chat (0.13 behaviour)";
1122
+ if (claimed.length > 0) {
1123
+ const options = [
1124
+ ...claimed.map((t) => ({
1125
+ label: `${t.name} — ${t.threadId}`,
1126
+ description: `message_thread_id ${t.threadId}`,
1127
+ })),
1128
+ { label: manual, description: "type a numeric forum topic id" },
1129
+ { label: none, description: "send to the chat root, not a topic" },
1130
+ ];
1131
+ const priorIdx = prior === undefined ? -1 : claimed.findIndex((t) => t.threadId === prior);
1132
+ const picked = await ui.select("Telegram forum topic for tier-2 pages", options, {
1133
+ initialIndex: priorIdx >= 0 ? priorIdx : claimed.length + 1,
1134
+ });
1135
+ if (picked === undefined) throw new Cancelled();
1136
+ if (picked === none) return undefined;
1137
+ if (picked !== manual) {
1138
+ const hit = claimed.find((t) => `${t.name} — ${t.threadId}` === picked);
1139
+ if (hit !== undefined) return hit.threadId;
1140
+ }
1141
+ }
1142
+
1143
+ const answered = await ask(
1144
+ ui,
1145
+ "Telegram forum topic id (blank for flat chat)",
1146
+ prior !== undefined ? String(prior) : "",
1147
+ );
1148
+ if (answered.length === 0) return undefined;
1149
+ const n = Number(answered);
1150
+ if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
1151
+ ui.notify(`"${answered}" is not an integer topic id — using flat chat.`, "warning");
1152
+ return undefined;
1153
+ }
1154
+ return n;
1155
+ }
1156
+
1157
+ function readClaimedTelegramTopics(stateDir: string): Array<{ threadId: number; name: string }> {
1158
+ try {
1159
+ const raw: unknown = JSON.parse(readFileSync(join(stateDir, "threads.json"), "utf8"));
1160
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
1161
+ if (!("threads" in raw)) return [];
1162
+ const threads = raw.threads;
1163
+ if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
1164
+ const out: Array<{ threadId: number; name: string }> = [];
1165
+ for (const [id, entry] of Object.entries(threads)) {
1166
+ const threadId = Number(id);
1167
+ if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
1168
+ let name = id;
1169
+ if (typeof entry === "object" && entry !== null && "name" in entry) {
1170
+ const candidate = entry.name;
1171
+ if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
1172
+ }
1173
+ out.push({ threadId, name });
1174
+ }
1175
+ return out;
1176
+ } catch {
1177
+ return [];
1178
+ }
1179
+ }
1180
+
932
1181
  /** How loud the orchestrator is and when the operator permits interruptions. */
933
- const askReporting: AreaAsker = async (ctx, a) => {
934
- const reportScope = await askReportScope(ctx, a.reportScope);
1182
+ const askReporting: AreaAsker = async (ui, a) => {
1183
+ const reportScope = await askReportScope(ui, a.reportScope);
935
1184
  const continuous = "Continuous (24-hour interrupts)";
936
1185
  const weekly = "Weekly availability window";
937
- const mode = await ctx.ui.select(
1186
+ const mode = await ui.select(
938
1187
  "Operator availability",
939
1188
  [
940
1189
  {
@@ -977,7 +1226,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
977
1226
  ? "off"
978
1227
  : a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback);
979
1228
  const schedule = await askValid(
980
- ctx,
1229
+ ui,
981
1230
  'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)',
982
1231
  shown,
983
1232
  (value) =>
@@ -992,7 +1241,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
992
1241
 
993
1242
  if (mode !== weekly) {
994
1243
  if (mode !== continuous) {
995
- ctx.ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning");
1244
+ ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning");
996
1245
  }
997
1246
  if (reportScope !== "quiet") return next;
998
1247
  return await askDigestSchedule("model-timed");
@@ -1001,7 +1250,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
1001
1250
  const defaultZone =
1002
1251
  a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
1003
1252
  const reportingTimezone = await askValid(
1004
- ctx,
1253
+ ui,
1005
1254
  "Operator timezone (IANA, for example Europe/London)",
1006
1255
  defaultZone,
1007
1256
  (value) => {
@@ -1014,7 +1263,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
1014
1263
  },
1015
1264
  );
1016
1265
  const daysText = await askValid(
1017
- ctx,
1266
+ ui,
1018
1267
  "Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)",
1019
1268
  a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri",
1020
1269
  (value) => {
@@ -1027,13 +1276,13 @@ const askReporting: AreaAsker = async (ctx, a) => {
1027
1276
  );
1028
1277
  const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday);
1029
1278
  const start = await askValid(
1030
- ctx,
1279
+ ui,
1031
1280
  "Availability starts (24h HH:MM)",
1032
1281
  a.availability?.start ?? "09:00",
1033
1282
  (value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."),
1034
1283
  );
1035
1284
  const end = await askValid(
1036
- ctx,
1285
+ ui,
1037
1286
  "Availability ends (24h HH:MM)",
1038
1287
  a.availability?.end ?? "17:00",
1039
1288
  (value) =>
@@ -1044,7 +1293,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
1044
1293
  : undefined,
1045
1294
  );
1046
1295
  const bypassText = await askValid(
1047
- ctx,
1296
+ ui,
1048
1297
  `Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`,
1049
1298
  a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none",
1050
1299
  (value) => {
@@ -1070,7 +1319,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
1070
1319
 
1071
1320
  /** The operator's own brief. Asked last in the full interview, because the
1072
1321
  * question quotes the path the rest of the answers derive. */
1073
- const askBrief: AreaAsker = async (ctx, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ctx, a) });
1322
+ const askBrief: AreaAsker = async (ui, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ui, a) });
1074
1323
 
1075
1324
  /**
1076
1325
  * One dialog sequence per amend area, keyed so a new area cannot be added to
@@ -1081,30 +1330,47 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
1081
1330
  gates: askGatesOnly,
1082
1331
  // The two per-worker knobs the full interview separates with the authority
1083
1332
  // grants; an amend has no reason to put anything between them.
1084
- caps: async (ctx, a) => await askWorkerModel(ctx, await askCaps(ctx, a)),
1085
- graph: askGraph,
1333
+ caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes),
1334
+ "code-graph": askGraph,
1086
1335
  authority: askAuthorityArea,
1087
1336
  policy: askPolicy,
1088
1337
  escalation: askEscalation,
1089
1338
  reporting: askReporting,
1090
- brief: askBrief,
1339
+ // `askBrief` alone would be a trap. The floor brief and the unfilled stub both
1340
+ // send an operator here to *fill in* Releases and Project context, and judgment
1341
+ // is deliberately not persisted — so an area that asked only "overwrite it?"
1342
+ // would rewrite POLICY.md from an empty judgment and replace a filled file with
1343
+ // the very stubs they came to remove. The grants are already on disk, so the
1344
+ // boundary questions are asked against the authority this project really has.
1345
+ brief: async (ui, a, probes) =>
1346
+ await askBrief(ui, { ...a, judgment: await askJudgment(ui, a.releaseGrants, a.judgment ?? {}) }, probes),
1091
1347
  };
1092
1348
 
1093
1349
  /** The two ways to answer the first question a configured project gets. Labels,
1094
1350
  * because the harness's select resolves to the label it displayed. */
1095
1351
  const AMEND_ONE = "Change one area";
1096
1352
  const REINTERVIEW = "Walk every question again";
1353
+ /** Third chooser row when a config already exists: start a full interview for a
1354
+ * brand-new neighbour rather than amending the one that is already there. */
1355
+ const ADD_PROJECT = "Add another project";
1356
+
1357
+ /** Outcome of the first chooser on a re-run. */
1358
+ type AmendChoice =
1359
+ | { kind: "area"; area: AmendAreaId }
1360
+ | { kind: "reinterview" }
1361
+ | { kind: "add-project" };
1097
1362
 
1098
1363
  /**
1099
1364
  * The first question a re-run asks, and the reason amend mode exists: adding one
1100
- * key should not cost twenty prompts.
1365
+ * key should not cost twenty prompts. Also the entry for adding a neighbour
1366
+ * project without leaving the wizard (#319).
1101
1367
  *
1102
- * Returns the area to amend, or `undefined` for the full interview. Only asked
1103
- * when the named project is already configured — a first run, or a new project
1104
- * beside an old one, has nothing to amend and is never shown this.
1368
+ * Returns the area to amend, a full re-interview of the named project, or the
1369
+ * add-a-project path. Only asked when a project is already configured — a first
1370
+ * run has nothing to amend and is never shown this.
1105
1371
  */
1106
- async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promise<AmendAreaId | undefined> {
1107
- const mode = await ctx.ui.select(
1372
+ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
1373
+ const mode = await ui.select(
1108
1374
  `"${prior.name}" is already configured — what would you like to do?`,
1109
1375
  [
1110
1376
  {
@@ -1115,20 +1381,25 @@ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promi
1115
1381
  label: REINTERVIEW,
1116
1382
  description: "the full interview, every prompt pre-filled with what is configured now",
1117
1383
  },
1384
+ {
1385
+ label: ADD_PROJECT,
1386
+ description: "full interview for a new project; existing projects stay as they are",
1387
+ },
1118
1388
  ],
1119
1389
  { initialIndex: 0 },
1120
1390
  );
1121
1391
  if (mode === undefined) throw new Cancelled();
1392
+ if (mode === ADD_PROJECT) return { kind: "add-project" };
1122
1393
  if (mode !== AMEND_ONE) {
1123
1394
  // Either the operator chose the full interview, or the dialog answered with
1124
1395
  // a label we never offered. Both land on today's behaviour, which is the one
1125
1396
  // that cannot silently skip a question.
1126
- if (mode !== REINTERVIEW) ctx.ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
1127
- return undefined;
1397
+ if (mode !== REINTERVIEW) ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
1398
+ return { kind: "reinterview" };
1128
1399
  }
1129
1400
 
1130
1401
  const choices = amendChoices(prior);
1131
- const picked = await ctx.ui.select(
1402
+ const picked = await ui.select(
1132
1403
  "Which area? Each row shows what it says now",
1133
1404
  choices.map((c) => ({ label: c.label, description: c.description })),
1134
1405
  { initialIndex: 0 },
@@ -1139,10 +1410,10 @@ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promi
1139
1410
  if (chosen === undefined) {
1140
1411
  // Guessing an area here would ask the wrong questions and carry the rest
1141
1412
  // through as if they had been reviewed. Abandoning changes nothing.
1142
- ctx.ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
1413
+ ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
1143
1414
  throw new Cancelled();
1144
1415
  }
1145
- return chosen.id;
1416
+ return { kind: "area", area: chosen.id };
1146
1417
  }
1147
1418
 
1148
1419
  /**
@@ -1154,33 +1425,50 @@ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promi
1154
1425
  * so the two flows cannot disagree about what an unanswered field is.
1155
1426
  */
1156
1427
  async function collectAnswers(
1157
- ctx: CommandContext,
1428
+ ui: WizardUi,
1158
1429
  prior: ProjectConfig | undefined,
1159
1430
  projectArg: string | undefined,
1431
+ probes: SetupProbes,
1432
+ opts: { added?: boolean } = {},
1160
1433
  ): Promise<SetupAnswers> {
1161
- const seed = prior === undefined ? defaultAnswers(projectArg ?? "") : answersFromProject(prior);
1434
+ // Added projects seed under projects/<name>/; a first install and a re-interview
1435
+ // of an existing project keep the flat (or already-on-disk) roots.
1436
+ const seed =
1437
+ prior === undefined
1438
+ ? defaultAnswers(projectArg ?? "", { added: opts.added === true })
1439
+ : answersFromProject(prior);
1162
1440
 
1163
1441
  const projectName = await askValid(
1164
- ctx,
1442
+ ui,
1165
1443
  "Project name",
1166
1444
  projectArg ?? seed.projectName,
1167
- (v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
1445
+ (v) => (v.length > 0 ? undefined : "A name is required — it is how `omp-conductor status --project <name>` finds this project."),
1168
1446
  );
1169
1447
 
1448
+ // When the operator renames mid-interview on an add, re-seed the scoped roots
1449
+ // so they track the final name rather than the empty/CLI placeholder.
1170
1450
  let a: SetupAnswers = { ...seed, projectName };
1171
- a = await askTrackerAndRepos(ctx, a);
1451
+ if (opts.added === true && prior === undefined) {
1452
+ const scoped = defaultAnswers(projectName, { added: true });
1453
+ a = {
1454
+ ...a,
1455
+ workspaceRoot: scoped.workspaceRoot,
1456
+ mirrorRoot: scoped.mirrorRoot,
1457
+ };
1458
+ }
1459
+ a = await askTrackerAndRepos(ui, a, probes);
1172
1460
  // Straight after the repos, because it is a fact about them: one clone per
1173
1461
  // routed repo, under one root.
1174
- a = await askGraph(ctx, a);
1175
- a = await askCaps(ctx, a);
1176
- a = await askAuthorityArea(ctx, a);
1177
- a = await askPolicy(ctx, a);
1178
- a = await askWorkerModel(ctx, a);
1179
- a = await askEscalation(ctx, a);
1180
- a = await askReporting(ctx, a);
1462
+ a = await askGraph(ui, a, probes);
1463
+ a = await askCaps(ui, a, probes);
1464
+ a = await askAuthorityArea(ui, a, probes);
1465
+ a = await askPolicy(ui, a, probes);
1466
+ a = await askWorkerModel(ui, a, probes);
1467
+ a = await askEscalation(ui, a, probes);
1468
+ a = await askReporting(ui, a, probes);
1181
1469
  // Asked last, and asked with the real path in the question — which needs the
1182
1470
  // rest of the answers to derive.
1183
- return await askBrief(ctx, a);
1471
+ return await askBrief(ui, a, probes);
1184
1472
  }
1185
1473
 
1186
1474
  /** The dry run, rendered. Same routing code the loop uses, so this is what the
@@ -1209,6 +1497,8 @@ function formatPreview(p: QueuePreview): string[] {
1209
1497
  export interface CollectedSetup {
1210
1498
  answers: SetupAnswers;
1211
1499
  amend?: { area: AmendAreaId; before: ProjectConfig };
1500
+ /** True when this run is adding a neighbour project (or first-run-for-name). */
1501
+ added?: boolean;
1212
1502
  }
1213
1503
 
1214
1504
 
@@ -1231,74 +1521,206 @@ export async function ensureSetupArm(
1231
1521
  * is judged on — on a host with no `gh` and no config.
1232
1522
  */
1233
1523
  export async function collectSetup(
1234
- ctx: CommandContext,
1524
+ ui: WizardUi,
1235
1525
  existing: ConductorConfig | undefined,
1236
1526
  projectArg: string | undefined,
1527
+ areaArg: AmendAreaId | undefined,
1528
+ probes: SetupProbes,
1237
1529
  ): Promise<CollectedSetup> {
1238
1530
  // Only a project that is already configured can be amended. A first run, or a
1239
1531
  // name this config has never seen, goes straight into the full interview with
1240
1532
  // no extra question — which is what it was before amend mode existed.
1533
+ //
1534
+ // When the config already has at least one project and the named (or sole)
1535
+ // target is unknown, this is an add: seed under projects/<name>/ so the new
1536
+ // fleet never shares the first project's flat worktrees/mirrors (#319).
1241
1537
  const prior = priorProject(existing, projectArg);
1242
- if (prior === undefined) return { answers: await collectAnswers(ctx, undefined, projectArg) };
1538
+ if (prior === undefined) {
1539
+ const adding = (existing?.projects.length ?? 0) > 0;
1540
+ const answers = await collectAnswers(ui, undefined, projectArg, probes, { added: adding });
1541
+ // An add that typed an already-configured name would silently replace via
1542
+ // buildConfig. Ask first — amend (carry-through full interview of that
1543
+ // project) or replace (answers as collected, roots as answered).
1544
+ const named = existing?.projects.find((p) => p.name === answers.projectName);
1545
+ if (named !== undefined) {
1546
+ const mode = await ui.select(
1547
+ `"${answers.projectName}" is already configured — amend or replace?`,
1548
+ [
1549
+ {
1550
+ label: "Amend (keep existing settings, re-ask everything pre-filled)",
1551
+ description: "full interview seeded from the saved project; other projects untouched",
1552
+ },
1553
+ {
1554
+ label: "Replace with these answers",
1555
+ description: "overwrites this project's config entry on apply; other projects untouched",
1556
+ },
1557
+ ],
1558
+ { initialIndex: 0 },
1559
+ );
1560
+ if (mode === undefined) throw new Cancelled();
1561
+ if (mode.startsWith("Amend")) {
1562
+ return { answers: await collectAnswers(ui, named, answers.projectName, probes) };
1563
+ }
1564
+ // Replace: keep the answers just collected (including any scoped roots the
1565
+ // operator saw). buildConfig replaces by name.
1566
+ return { answers };
1567
+ }
1568
+ return {
1569
+ answers,
1570
+ added: adding || undefined,
1571
+ };
1572
+ }
1573
+
1574
+ // A positional CLI area skips the chooser: the operator already named which
1575
+ // area they came to amend, so the first dialog is that area's first question
1576
+ // rather than a menu of every area.
1577
+ if (areaArg !== undefined) {
1578
+ return {
1579
+ answers: await AREA_ASKERS[areaArg](ui, answersFromProject(prior), probes),
1580
+ amend: { area: areaArg, before: prior },
1581
+ };
1582
+ }
1243
1583
 
1244
- const area = await chooseAmendArea(ctx, prior);
1245
- if (area === undefined) return { answers: await collectAnswers(ctx, prior, projectArg) };
1584
+ const choice = await chooseAmendArea(ui, prior);
1585
+ if (choice.kind === "add-project") {
1586
+ // Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
1587
+ // here: the operator just chose to add, so the name comes from the prompt.
1588
+ const answers = await collectAnswers(ui, undefined, undefined, probes, { added: true });
1589
+ const named = existing?.projects.find((p) => p.name === answers.projectName);
1590
+ if (named !== undefined) {
1591
+ const mode = await ui.select(
1592
+ `"${answers.projectName}" is already configured — amend or replace?`,
1593
+ [
1594
+ {
1595
+ label: "Amend (keep existing settings, re-ask everything pre-filled)",
1596
+ description: "full interview seeded from the saved project; other projects untouched",
1597
+ },
1598
+ {
1599
+ label: "Replace with these answers",
1600
+ description: "overwrites this project's config entry on apply; other projects untouched",
1601
+ },
1602
+ ],
1603
+ { initialIndex: 0 },
1604
+ );
1605
+ if (mode === undefined) throw new Cancelled();
1606
+ if (mode.startsWith("Amend")) {
1607
+ return { answers: await collectAnswers(ui, named, answers.projectName, probes) };
1608
+ }
1609
+ return { answers };
1610
+ }
1611
+ return { answers, added: true };
1612
+ }
1613
+ if (choice.kind === "reinterview") {
1614
+ // Full interview of the *existing* name is a replace of that project. Ask
1615
+ // once so a silent overwrite cannot happen from muscle-memory Enter.
1616
+ const replace = await ui.confirm(
1617
+ `Replace existing project "${prior.name}"?`,
1618
+ "Walks every question again and overwrites this project's config entry on apply. " +
1619
+ "Other projects are left alone. Cancel and pick \"Change one area\" to amend without a full replace, " +
1620
+ "or \"Add another project\" to create a neighbour.",
1621
+ );
1622
+ if (replace !== true) throw new Cancelled();
1623
+ return { answers: await collectAnswers(ui, prior, projectArg, probes) };
1624
+ }
1246
1625
 
1247
- return { answers: await AREA_ASKERS[area](ctx, answersFromProject(prior)), amend: { area, before: prior } };
1626
+ return {
1627
+ answers: await AREA_ASKERS[choice.area](ui, answersFromProject(prior), probes),
1628
+ amend: { area: choice.area, before: prior },
1629
+ };
1248
1630
  }
1249
1631
 
1250
1632
  /**
1251
- * The onboarding wizard, and — for a project it already knows — the amend.
1252
- *
1253
- * The invariant that makes this safe against a live tracker: no mutation occurs
1254
- * before the consent below. Config, tracker, and host-runtime planning are
1255
- * read-only. The paused state, labels, config, brief, runtime files, smoke, and
1256
- * arm proof all follow the same consent gate.
1257
- *
1258
- * An amend changes which questions are asked and what the summary leads with,
1259
- * and nothing else: the same answers, the same `buildConfig`, the same single
1260
- * confirm, the same dry run. One writer, one consent gate.
1633
+ * Operator steps herdr requires for a new fleet pane. Verified against
1634
+ * `herdr workspace create` / `herdr agent start` help and recover.sh bootstrap:
1635
+ * create a workspace at the project's cwd, then start omp *into* that pane with
1636
+ * `--kind omp --pane <id>`. Do **not** invent `agent start --session/--cwd` —
1637
+ * `agent start` requires an existing pane at a shell prompt, and starting into a
1638
+ * live orchestrator pane is refused or worse.
1261
1639
  */
1262
- async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
1640
+ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig): string {
1641
+ const session = process.env["HERDR_SESSION"] ?? "conductor";
1642
+ const cwds = cfg.projects.map((p) => p.workspaceRoot).join(":");
1643
+ return [
1644
+ "herdr handoff (CLI cannot do these — exact herdr argv):",
1645
+ ` 1. In session "${session}", create a workspace for project "${project.name}" at its workspaceRoot:`,
1646
+ ` herdr --session ${session} workspace create --cwd ${project.workspaceRoot} --label ${project.name} --no-focus`,
1647
+ " Read root_pane.pane_id from the JSON reply (jq -r '((.result // .).root_pane.pane_id) // empty').",
1648
+ ` 2. Start omp in that empty pane. The agent NAME must match tick config agentName ("${project.name}"):`,
1649
+ ` herdr --session ${session} agent start ${project.name} --kind omp --pane <pane-id>`,
1650
+ " The pane must be at a shell prompt with no agent on it. Never agent-start into a live orchestrator pane.",
1651
+ ` 3. Point herdr-conductor recovery at every fleet cwd (colon-separated; see #320):`,
1652
+ ` FLEET_CWDS=${cwds}`,
1653
+ ` Legacy single-fleet hosts can keep FLEET_CWD=${project.workspaceRoot} until multi-fleet recovery lands.`,
1654
+ " 4. Reload the daemon so it serves every configured project:",
1655
+ " omp-conductor restart --now",
1656
+ " (printed, not auto-run, when workers are live or a neighbour was just added)",
1657
+ ].join("\n");
1658
+ }
1659
+
1660
+
1661
+
1662
+ export async function setup(
1663
+ ui: WizardUi,
1664
+ projectArg: string | undefined,
1665
+ areaArg?: AmendAreaId,
1666
+ probes: SetupProbes = DEFAULT_PROBES,
1667
+ ): Promise<void> {
1263
1668
  const path = configPath();
1264
1669
  // A config that exists but does not parse is a fault to report, never
1265
1670
  // something to quietly replace: overwriting it would delete every project it
1266
1671
  // describes. Absence, by contrast, is just the first run.
1267
1672
  const existing = existsSync(path) ? loadConfig() : undefined;
1268
1673
  if (existing === undefined) {
1269
- ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
1674
+ ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
1270
1675
  }
1271
1676
 
1272
1677
  let collected: CollectedSetup;
1273
1678
  try {
1274
- collected = await collectSetup(ctx, existing, projectArg);
1679
+ collected = await collectSetup(ui, existing, projectArg, areaArg, probes);
1275
1680
  } catch (err) {
1276
1681
  if (!(err instanceof Cancelled)) throw err;
1277
- ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
1682
+ ui.notify("Setup cancelled — nothing was changed.", "info");
1278
1683
  return;
1279
1684
  }
1280
- const { answers, amend } = collected;
1685
+ const { answers, amend, added } = collected;
1281
1686
 
1282
1687
  const scopes = await checkTokenScopes();
1283
1688
  if (!scopes.ok) {
1284
- ctx.ui.notify(
1689
+ ui.notify(
1285
1690
  `Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
1286
1691
  `Run \`gh auth refresh -s repo,project\`, then run setup again.`,
1287
1692
  "error",
1288
1693
  );
1289
1694
  return;
1290
1695
  }
1696
+
1697
+ // Drafted here — after the interview, before the plan — because the plan's single
1698
+ // consent gate says "Writes ORCHESTRATOR.md + POLICY.md", and this is what those
1699
+ // files will contain. Each draft carries its own preview and confirm; declining
1700
+ // one keeps the shipped stub, so an operator who wants none reaches the same plan.
1701
+ const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
1291
1702
  const labels = await planLabels(answers.trackerRepo, answers);
1292
1703
  const telegram = detectTelegram();
1293
- const nextConfig = buildConfig(answers, existing);
1704
+ let nextConfig;
1705
+ try {
1706
+ nextConfig = buildConfig(answers, existing);
1707
+ } catch (err) {
1708
+ ui.notify(
1709
+ `Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`,
1710
+ "error",
1711
+ );
1712
+ return;
1713
+ }
1294
1714
  const project = findProject(nextConfig, answers.projectName);
1715
+ const totalWorkers = totalConfiguredWorkers(nextConfig);
1716
+ const overcommit = workerOvercommit(totalWorkers);
1295
1717
  // The same project as it is configured right now, so a moved `workspaceRoot`
1296
1718
  if (
1297
1719
  project.escalation.orchestrator === "external" &&
1298
1720
  !answers.writeOrchestratorBrief &&
1299
1721
  (!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
1300
1722
  ) {
1301
- ctx.ui.notify(
1723
+ ui.notify(
1302
1724
  `Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
1303
1725
  `Run setup again and approve the brief write.`,
1304
1726
  "error",
@@ -1309,20 +1731,22 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1309
1731
  project,
1310
1732
  resolveCaps(project, nextConfig.defaults),
1311
1733
  telegram.stateDir,
1734
+ undefined,
1735
+ totalWorkers,
1312
1736
  );
1313
1737
  let queuePreview: string[];
1314
1738
  try {
1315
1739
  queuePreview = formatPreview(await previewProject(project));
1316
1740
  } catch (err) {
1317
1741
  const message = err instanceof Error ? err.message : String(err);
1318
- ctx.ui.notify(
1742
+ ui.notify(
1319
1743
  `Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
1320
1744
  "error",
1321
1745
  );
1322
1746
  return;
1323
1747
  }
1324
1748
 
1325
- ctx.ui.notify(
1749
+ ui.notify(
1326
1750
  [
1327
1751
  // The delta first when there is one, then the whole plan: the confirm has
1328
1752
  // to name every mutation it authorises, and a delta names none of them.
@@ -1331,6 +1755,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1331
1755
  "",
1332
1756
  formatHostRuntimePlan(runtime),
1333
1757
  "",
1758
+ ...(overcommit === undefined ? [] : [`WARNING: ${overcommit}`, ""]),
1334
1759
  "Dry run against the PROPOSED config:",
1335
1760
  ...queuePreview,
1336
1761
  "",
@@ -1340,7 +1765,11 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1340
1765
  );
1341
1766
 
1342
1767
  const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
1343
- const go = await ctx.ui.confirm(
1768
+ // Deliberately `ui.confirm` rather than `askYesNo`: this is the one confirm
1769
+ // after the interview, and dismissing it means the same thing as answering no
1770
+ // — do not apply — so it returns through the existing "left untouched" path
1771
+ // instead of raising a `Cancelled` nothing outside `collectSetup` catches.
1772
+ const go = await ui.confirm(
1344
1773
  amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
1345
1774
  [
1346
1775
  toCreate.length > 0
@@ -1367,16 +1796,16 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1367
1796
  .join(" "),
1368
1797
  );
1369
1798
  if (!go) {
1370
- ctx.ui.notify("Left untouched — no labels created, no config written, nothing armed.", "info");
1799
+ ui.notify("Left untouched — no labels created, no config written, nothing armed.", "info");
1371
1800
  return;
1372
1801
  }
1373
1802
 
1374
1803
  // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
1375
1804
  // partially applied setup unable to claim work.
1376
- prepareConductor();
1805
+ prepareConductor(project.name);
1377
1806
  const created = await createMissingLabels(answers.trackerRepo, labels);
1378
1807
  saveConfig(nextConfig);
1379
- const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
1808
+ const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
1380
1809
  const runtimeFiles = writeHostRuntime(runtime);
1381
1810
  const smoke = await runSetupSmoke(project.name);
1382
1811
  let smokeLine =
@@ -1385,33 +1814,51 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1385
1814
  let restartVia: "systemctl" | "cli" | undefined;
1386
1815
  if (smoke.mode === "existing") {
1387
1816
  if (smoke.status.liveWorkers > 0) {
1388
- ctx.ui.notify(
1817
+ // Runs are live: never yank the daemon. Print the reload command; the new
1818
+ // project only joins the multi-tenant loop after restart (#319).
1819
+ ui.notify(
1389
1820
  [
1390
1821
  `Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
1391
1822
  "Dispatch remains paused. Let those workers finish.",
1392
- `Then run \`omp-conductor restart --project ${project.name}\`.`,
1823
+ "Then run `omp-conductor restart --now`.",
1393
1824
  project.escalation.orchestrator === "external"
1394
- ? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume\`.`
1395
- : "Then run `omp-conductor resume`.",
1825
+ ? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${project.name}\`.`
1826
+ : `Then run \`omp-conductor resume --project ${project.name}\`.`,
1396
1827
  ].join("\n"),
1397
1828
  "warning",
1398
1829
  );
1830
+ ui.notify(formatHerdrHandoff(project, nextConfig), "info");
1399
1831
  return;
1400
1832
  }
1401
- const restarted = await restartDaemon({ project: project.name });
1402
- restartVia = restarted.via;
1403
- smokeLine =
1404
- `existing /healthz and stored status; restarted through ${restarted.via}; ` +
1405
- `new /healthz on :${restarted.record.port}`;
1833
+ // No live workers: still prefer an explicit reload when this run *added* a
1834
+ // neighbour — auto-restart would bounce every other project's heartbeat for
1835
+ // a config change they did not ask for. Amends of the only/same project keep
1836
+ // the old auto-restart so a first-time install still comes up alone.
1837
+ if (added === true) {
1838
+ ui.notify(
1839
+ [
1840
+ "Setup files are updated. The running daemon does not serve the new project until it reloads.",
1841
+ "No live workers — safe to reload now:",
1842
+ " omp-conductor restart --now",
1843
+ ].join("\n"),
1844
+ "info",
1845
+ );
1846
+ } else {
1847
+ const restarted = await restartDaemon({ project: project.name });
1848
+ restartVia = restarted.via;
1849
+ smokeLine =
1850
+ `existing /healthz and stored status; restarted through ${restarted.via}; ` +
1851
+ `new /healthz on :${restarted.record.port}`;
1852
+ }
1406
1853
  }
1407
1854
 
1408
1855
  let armLine = "embedded orchestrator — no heartbeat arm marker";
1409
1856
  if (project.escalation.orchestrator === "external") {
1410
- ctx.ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1857
+ ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1411
1858
  try {
1412
1859
  armLine = await ensureSetupArm(project.name);
1413
1860
  } catch (err) {
1414
- ctx.ui.notify(
1861
+ ui.notify(
1415
1862
  [
1416
1863
  "Setup files passed the paused daemon smoke, but the fleet remains held.",
1417
1864
  err instanceof Error ? err.message : String(err),
@@ -1420,12 +1867,13 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1420
1867
  ].join("\n"),
1421
1868
  "warning",
1422
1869
  );
1870
+ ui.notify(formatHerdrHandoff(project, nextConfig), "info");
1423
1871
  return;
1424
1872
  }
1425
1873
  }
1426
- setPaused(false);
1874
+ setPaused(false, undefined, project.name);
1427
1875
 
1428
- ctx.ui.notify(
1876
+ ui.notify(
1429
1877
  [
1430
1878
  created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.",
1431
1879
  `Wrote ${path}; dispatch is ready.`,
@@ -1438,184 +1886,61 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1438
1886
  `Smoke passed: ${smokeLine}.`,
1439
1887
  `Heartbeat: ${armLine}.`,
1440
1888
  "",
1441
- "On a systemd host, install and start the supervised daemon:",
1442
- ...(restartVia === "cli" ? [" omp-conductor stop"] : []),
1443
- ...runtime.installCommands.map((command) => ` ${command}`),
1444
- "",
1445
- "Without systemd, run `omp-conductor start`.",
1446
1889
  "Use the documented toy-issue drill to prove one complete worker path.",
1447
1890
  ].join("\n"),
1448
1891
  "info",
1449
1892
  );
1450
- }
1451
1893
 
1452
- export default function conductorPlugin(pi: PluginApi): void {
1453
- pi.registerCommand("conductor", {
1454
- description: "Dispatch ready issues to omp coding sessions",
1455
- getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((s) => s.value.startsWith(prefix.trim())),
1894
+ // The two installs setup used to only print for the operator to retype.
1895
+ // Offered rather than assumed: each needs root, and an operator who wants to
1896
+ // read the unit before it is enabled must be able to decline and run the verb
1897
+ // later. `undefined` — a dismissed prompt — is a skip, not a cancel: the config
1898
+ // is already written by this point, so there is nothing left to abandon.
1899
+ //
1900
+ // Gated on `installedAction`, never on `service.action`. The staged copy is
1901
+ // written by this very run, so it is always current by now — every fleet
1902
+ // configured before the install was executed would have been told the unit
1903
+ // matched and never offered the install, which is precisely the population that
1904
+ // has never installed one.
1905
+ if (runtime.installedAction === "keep") {
1906
+ ui.notify(`The unit systemd reads (${runtime.installedPath}) already matches this config.`, "info");
1907
+ } else if (platform() !== "linux") {
1908
+ // No systemctl to run. The staged file is real and is what an operator copies
1909
+ // to the box that will run it, so say where it is rather than offer an install
1910
+ // that could only refuse.
1911
+ ui.notify(
1912
+ `Staged the unit at ${runtime.service.path}. systemd install is Linux-only — copy it to the fleet host and run \`omp-conductor setup host\` there.`,
1913
+ "info",
1914
+ );
1915
+ } else {
1916
+ const install = await ui.confirm(
1917
+ "Install and start the supervised daemon now?",
1918
+ `${runtime.installedAction === "create" ? "Installs" : "Updates"} ${runtime.installedPath} from ` +
1919
+ `${runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
1920
+ "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
1921
+ );
1922
+ if (install === true)
1923
+ await runHostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
1924
+ else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
1925
+ }
1456
1926
 
1457
- handler: async (args, ctx) => {
1458
- // findProject() throws when the config holds several projects and none is
1459
- // named, so the project name rides along as an optional second word.
1460
- const tokens = args.trim().split(/\s+/).filter(Boolean);
1461
- const sub = tokens[0];
1462
- const withPane = tokens.includes("--pane");
1463
- const project = tokens.find((t, i) => i > 0 && t !== "--pane");
1927
+ if (graphRepos(project).length > 0) {
1928
+ const wantsGraph = await ui.confirm(
1929
+ "Install the code-graph indexes now?",
1930
+ "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
1931
+ "indexing run so the first fetch happens while you watch. Minutes per repo. " +
1932
+ "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
1933
+ );
1934
+ if (wantsGraph === true) await runGraphInstall(project, ui);
1935
+ else
1936
+ ui.notify(
1937
+ "Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
1938
+ "info",
1939
+ );
1940
+ }
1464
1941
 
1465
- try {
1466
- switch (sub) {
1467
- case "setup":
1468
- await setup(ctx, project);
1469
- break;
1470
-
1471
- case "status":
1472
- ctx.ui.notify(await renderStatus(project), "info");
1473
- break;
1474
-
1475
- case "hold": {
1476
- const r = hold(project);
1477
- ctx.ui.notify(
1478
- `Held — claiming paused; ticks disarmed at ${r.disarmed.path}. Daemon and pane left running.`,
1479
- "info",
1480
- );
1481
- break;
1482
- }
1483
-
1484
- case "halt": {
1485
- if (withPane) {
1486
- const r = await haltWithPane(project);
1487
- const stop =
1488
- r.stop.kind === "not-running"
1489
- ? "daemon was not running"
1490
- : `daemon stopped (pid ${r.stop.pid})`;
1491
- ctx.ui.notify(
1492
- `Halted — ${stop}. Pane: ${r.pane.stopped} (${r.pane.detail}); recovery pinned at ${r.pane.pinPath}.`,
1493
- "info",
1494
- );
1495
- } else {
1496
- const r = await halt(project);
1497
- const stop =
1498
- r.stop.kind === "not-running"
1499
- ? "daemon was not running"
1500
- : `daemon stopped (pid ${r.stop.pid})`;
1501
- ctx.ui.notify(`Halted — ${stop}. Pane left running.`, "info");
1502
- }
1503
- break;
1504
- }
1505
-
1506
- case "arm": {
1507
- ctx.ui.notify("Arm: sending inbound Telegram challenge — reply in the bot DM…", "info");
1508
- const r = await armTicks(project);
1509
- ctx.ui.notify(`ARMED — owner ${r.owner}; marker ${r.path}`, "info");
1510
- break;
1511
- }
1512
-
1513
- case "disarm": {
1514
- const r = disarmTicks(project);
1515
- ctx.ui.notify(`Disarmed — ${r.path}`, "info");
1516
- break;
1517
- }
1518
-
1519
- case "release-pane": {
1520
- const r = clearPaneHalt(project);
1521
- ctx.ui.notify(
1522
- r.wasHalted ? `Pane recovery pin cleared (${r.path}).` : `No pane recovery pin at ${r.path}.`,
1523
- "info",
1524
- );
1525
- break;
1526
- }
1527
-
1528
- case "pause":
1529
- setPaused(true, { source: "pause", reason: "via /conductor pause" });
1530
- ctx.ui.notify("Paused claiming only — ticks keep firing if armed. Prefer /conductor hold.", "info");
1531
- break;
1532
-
1533
- case "resume":
1534
- releaseHold();
1535
- ctx.ui.notify("Resumed claiming — did NOT re-arm. Run /conductor arm for ticks.", "info");
1536
- break;
1537
-
1538
- case "brief-upgrade": {
1539
- const p = findProject(loadConfig(), project);
1540
- const path = briefPathForProject(p);
1541
- const rendered = renderBriefForProject(p);
1542
- const layout = inspectBriefLayout(p.workspaceRoot, rendered);
1543
- if (layout.kind === "missing") {
1544
- ctx.ui.notify(
1545
- `No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME}.`,
1546
- "warning",
1547
- );
1548
- break;
1549
- }
1550
- if (layout.kind === "overlay") {
1551
- ctx.ui.notify(
1552
- formatBriefReport(path, layout, []),
1553
- "info",
1554
- );
1555
- const repair = await ctx.ui.confirm(
1556
- "Repair POLICY.md banner crumbs and recompose?",
1557
- "Strips any leading HTML-comment leftovers from a pre-fix migrate, then recomposes ORCHESTRATOR.md from the package floor + POLICY.md.",
1558
- );
1559
- if (repair) {
1560
- const repaired = repairPolicyBannerCrumbs({
1561
- orchestratorPath: layout.orchestratorPath,
1562
- policyPath: layout.policyPath,
1563
- floor: renderFloorForProject(p),
1564
- });
1565
- ctx.ui.notify(
1566
- repaired === undefined
1567
- ? "Recomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip."
1568
- : formatMigrateResult(repaired),
1569
- "info",
1570
- );
1571
- }
1572
- break;
1573
- }
1574
- if (layout.kind === "legacy-bannered") {
1575
- ctx.ui.notify(
1576
- [
1577
- `Legacy bannered brief at ${layout.orchestratorPath}.`,
1578
- "Migrate the owned half into POLICY.md so the package floor refreshes each tick.",
1579
- ].join("\n"),
1580
- "warning",
1581
- );
1582
- const migrate = await ctx.ui.confirm(
1583
- "Migrate to POLICY.md overlay?",
1584
- "Writes POLICY.md from everything below YOURS TO EDIT, recomposes ORCHESTRATOR.md from the package floor + that policy, and keeps backups.",
1585
- );
1586
- if (migrate) {
1587
- const result = migrateToPolicy({
1588
- orchestratorPath: layout.orchestratorPath,
1589
- policyPath: policyPathForProject(p),
1590
- floor: renderFloorForProject(p),
1591
- owned: layout.owned,
1592
- });
1593
- ctx.ui.notify(formatMigrateResult(result), "info");
1594
- }
1595
- break;
1596
- }
1597
- // Hand-written: the plugin no longer merges single-file briefs
1598
- // (#131). There is no banner, so nothing here can tell which lines
1599
- // are the operator's — a retrofit has to name the cut first.
1600
- ctx.ui.notify(
1601
- `Hand-written brief at ${path} — run: omp-conductor brief-upgrade --retrofit (then --migrate). The plugin no longer merges single-file briefs.`,
1602
- "warning",
1603
- );
1604
- break;
1605
- }
1606
-
1607
- default:
1608
- ctx.ui.notify(
1609
- `${sub ? `Unknown subcommand "${sub}".` : "Pick a subcommand."}\n\n${USAGE}` +
1610
- (isPaused() ? "\n\nThe conductor is currently paused." : ""),
1611
- sub ? "warning" : "info",
1612
- );
1613
- }
1614
- } catch (err) {
1615
- // Config problems arrive as a single readable message listing every
1616
- // fault, which is more use to the operator than a stack.
1617
- ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
1618
- }
1619
- },
1620
- });
1942
+ // Always print: first install, amend, and add-a-project all need the operator
1943
+ // to create/verify the herdr pane. Added projects especially — the CLI wrote
1944
+ // tick + config but cannot start a herdr agent (#319).
1945
+ ui.notify(formatHerdrHandoff(project, nextConfig), "info");
1621
1946
  }