omp-conductor 0.3.4 → 0.3.6

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.
package/src/plugin.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  * worth protecting is on `setup()` below — nothing is written before the confirm.
12
12
  */
13
13
  import { existsSync, readFileSync } from "node:fs";
14
+ import { isAbsolute } from "node:path";
14
15
  import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
15
- import { configPath, findProject, loadConfig, saveConfig } from "./config.ts";
16
+ import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
16
17
  import {
17
18
  armConductor,
18
19
  formatStatus,
@@ -22,25 +23,32 @@ import {
22
23
  statusSnapshot,
23
24
  type QueuePreview,
24
25
  } from "./daemon.ts";
26
+ import { defaultGraphRoot } from "./graph.ts";
25
27
  import {
28
+ AMEND_AREAS,
26
29
  ORCHESTRATOR_BRIEF_NAME,
27
30
  REPORT_SCOPE_CHOICES,
28
31
  SETUP_DEFAULTS,
32
+ amendChoices,
33
+ answersFromProject,
29
34
  briefPathForProject,
30
35
  buildConfig,
31
36
  checkTokenScopes,
32
37
  createMissingLabels,
38
+ defaultAnswers,
33
39
  detectTelegram,
40
+ formatGates,
34
41
  orchestratorBriefPath,
35
42
  planLabels,
36
43
  renderBriefForProject,
44
+ summariseAmend,
37
45
  summarisePlan,
38
46
  writeOrchestratorBrief,
47
+ type AmendAreaId,
39
48
  type SetupAnswers,
40
49
  } from "./setup.ts";
41
50
  import {
42
51
  DEFAULT_CAPS,
43
- DEFAULT_REPORT_SCOPE,
44
52
  type Caps,
45
53
  type ConductorConfig,
46
54
  type OrchestratorMode,
@@ -100,7 +108,11 @@ interface PluginApi {
100
108
  }
101
109
 
102
110
  const SUBCOMMANDS: Completion[] = [
103
- { value: "setup", label: "setup", description: "onboarding wizard: config, labels, dry run, then arm" },
111
+ {
112
+ value: "setup",
113
+ label: "setup",
114
+ description: "wizard: config, labels, dry run, then arm — or amend one area of a configured project",
115
+ },
104
116
  { value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
105
117
  { value: "pause", label: "pause", description: "stop claiming new work" },
106
118
  { value: "resume", label: "resume", description: "allow claiming again" },
@@ -112,7 +124,7 @@ const SUBCOMMANDS: Completion[] = [
112
124
  ];
113
125
 
114
126
  const USAGE = [
115
- "/conductor setup [project] create or update a project, then arm after you confirm",
127
+ "/conductor setup [project] create a project, or amend one area of one you already have",
116
128
  "/conductor status [project] pause state, caps, active runs, today's usage",
117
129
  "/conductor pause stop claiming new work",
118
130
  "/conductor resume allow claiming again",
@@ -183,7 +195,8 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
183
195
 
184
196
  /**
185
197
  * Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
186
- * `bun run check, bun test @ server`.
198
+ * `bun run check, bun test @ server`. Shown through `formatGates`, the same
199
+ * spelling the amend menu reads a repo's current gates back with.
187
200
  *
188
201
  * ponytail: the ceiling is a command containing a comma or a literal " @ ",
189
202
  * which this would split wrongly. Rare in a lint or test invocation, and the
@@ -195,8 +208,11 @@ async function askGates(
195
208
  repoName: string,
196
209
  seed: { cmd: string; cwd: string }[],
197
210
  ): Promise<{ cmd: string; cwd: string }[]> {
198
- const shown = seed.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
199
- const raw = await ask(ctx, `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`, shown);
211
+ const raw = await ask(
212
+ ctx,
213
+ `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
214
+ formatGates(seed),
215
+ );
200
216
 
201
217
  const gates: { cmd: string; cwd: string }[] = [];
202
218
  for (const chunk of raw.split(",")) {
@@ -287,6 +303,44 @@ async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode)
287
303
  return external ? "external" : "embedded";
288
304
  }
289
305
 
306
+ /**
307
+ * Whether workers get a code graph, and where its clones live.
308
+ *
309
+ * One confirm and at most one prompt, asked after the repos are known because
310
+ * the answer is derived per repo. A declined answer leaves the field off every
311
+ * repo, which is what keeps an existing fleet's briefs byte-identical.
312
+ *
313
+ * The root is validated as absolute here rather than at load time so the
314
+ * operator learns immediately: a relative path would be resolved against
315
+ * whichever cwd happened to read the config, and never against the directory
316
+ * that was indexed.
317
+ */
318
+ async function askGraphRoot(
319
+ ctx: CommandContext,
320
+ trackerRepo: string,
321
+ repoNames: string[],
322
+ prior: string | undefined,
323
+ ): Promise<string | undefined> {
324
+ const wanted = await ctx.ui.confirm(
325
+ "Code-graph discovery",
326
+ "Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
327
+ 'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
328
+ "pinned to the default branch purely for indexing — never your own checkout" +
329
+ `${prior === undefined ? "" : `. Currently on, under ${prior}`}.`,
330
+ );
331
+ if (!wanted) return undefined;
332
+
333
+ return await askValid(
334
+ ctx,
335
+ `Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
336
+ prior ?? defaultGraphRoot(trackerRepo),
337
+ (v) =>
338
+ isAbsolute(expandHome(v))
339
+ ? undefined
340
+ : `"${v}" is not an absolute path — a worker reads this from its own worktree, so a relative one names the wrong directory.`,
341
+ );
342
+ }
343
+
290
344
  /**
291
345
  * Whether to render the operator's own brief, and — separately — whether an
292
346
  * existing one may be replaced. Two questions on purpose: that file is where a
@@ -319,40 +373,40 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
319
373
  }
320
374
 
321
375
  /**
322
- * The conversation. Reads only every answer is collected before anything is
323
- * checked against GitHub, and long before anything is written.
376
+ * One area's questions, over the answers everything else is carried through in.
377
+ *
378
+ * Every asker takes the whole answer set and returns the whole answer set with
379
+ * only its own fields replaced. That is what lets the full interview fold them in
380
+ * order while an amend applies exactly one, with no second spelling of either the
381
+ * prompts or the defaults they pre-fill from: the value shown is always the value
382
+ * that would otherwise be carried through.
324
383
  */
325
- async function collectAnswers(
326
- ctx: CommandContext,
327
- existing: ConductorConfig | undefined,
328
- projectArg: string | undefined,
329
- ): Promise<SetupAnswers> {
330
- const prior = priorProject(existing, projectArg);
331
-
332
- const projectName = await askValid(
333
- ctx,
334
- "Project name",
335
- projectArg ?? prior?.name ?? "",
336
- (v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
337
- );
384
+ type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>;
338
385
 
386
+ /**
387
+ * Where work comes from and where it lands: tracker, labels, routing prefix, and
388
+ * every repo an issue can be routed to, each with its gates. One area because it
389
+ * is one fact — the identity of the queue — and changing any part of it without
390
+ * seeing the rest is how a routing prefix stops matching its labels.
391
+ */
392
+ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
339
393
  const trackerRepo = await askValid(
340
394
  ctx,
341
395
  "Tracker repo (owner/repo) — where ready issues live",
342
- prior?.tracker.repo ?? "",
396
+ a.trackerRepo,
343
397
  (v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
344
398
  );
345
399
 
346
400
  const queueLabel = await ask(
347
401
  ctx,
348
402
  "Queue label — the human sign-off that makes an issue claimable",
349
- prior?.queueLabel ?? SETUP_DEFAULTS.queueLabel,
403
+ a.queueLabel,
350
404
  );
351
405
 
352
406
  // One confirm instead of three prompts: the namespaced defaults are right for
353
407
  // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
354
408
  // its reputation.
355
- const stateLabels: SetupAnswers["stateLabels"] = { ...(prior?.stateLabels ?? SETUP_DEFAULTS.stateLabels) };
409
+ const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
356
410
  const customiseStates = await ctx.ui.confirm(
357
411
  "State labels",
358
412
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
@@ -367,13 +421,12 @@ async function collectAnswers(
367
421
  const routingLabelPrefix = await ask(
368
422
  ctx,
369
423
  "Routing label prefix — an issue picks its checkout with <prefix><repo>",
370
- prior?.routing.labelPrefix ?? SETUP_DEFAULTS.routingLabelPrefix,
424
+ a.routingLabelPrefix,
371
425
  );
372
426
 
373
427
  const targetRepos: SetupAnswers["targetRepos"] = [];
374
- const seeds = Object.values(prior?.routing.repos ?? {});
375
428
  for (let i = 0; ; i++) {
376
- const seed = seeds[i];
429
+ const seed = a.targetRepos[i];
377
430
  const name = await askValid(
378
431
  ctx,
379
432
  `Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
@@ -400,7 +453,53 @@ async function collectAnswers(
400
453
  if (!more) break;
401
454
  }
402
455
 
403
- const caps: Partial<Caps> = { ...prior?.caps };
456
+ return { ...a, trackerRepo, queueLabel, stateLabels, routingLabelPrefix, targetRepos };
457
+ };
458
+
459
+ /**
460
+ * The gates alone, repo by repo, with nothing else asked.
461
+ *
462
+ * The area that earns amend mode: a CI command changes far more often than a
463
+ * clone URL does, and re-typing four repos to correct one lint invocation is the
464
+ * reason an operator edits config.json by hand instead.
465
+ */
466
+ const askGatesOnly: AreaAsker = async (ctx, a) => {
467
+ if (a.targetRepos.length === 0) {
468
+ ctx.ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
469
+ return a;
470
+ }
471
+
472
+ const targetRepos: SetupAnswers["targetRepos"] = [];
473
+ for (const r of a.targetRepos) {
474
+ targetRepos.push({ ...r, gates: await askGates(ctx, r.name, r.gates) });
475
+ }
476
+ return { ...a, targetRepos };
477
+ };
478
+
479
+ /**
480
+ * Whether workers get a code graph, and where its clones live. Asked after the
481
+ * repos in the full interview because the answer is derived per repo.
482
+ */
483
+ const askGraph: AreaAsker = async (ctx, a) => {
484
+ const graphRoot = await askGraphRoot(
485
+ ctx,
486
+ a.trackerRepo,
487
+ a.targetRepos.map((r) => r.name),
488
+ a.graphRoot,
489
+ );
490
+
491
+ const next: SetupAnswers = { ...a };
492
+ // Deleted rather than set to `undefined`: the absence of the key is what keeps
493
+ // a project that declines graphs identical to one written before they existed.
494
+ if (graphRoot === undefined) delete next.graphRoot;
495
+ else next.graphRoot = graphRoot;
496
+ return next;
497
+ };
498
+
499
+ /** The hard ceilings. One confirm first, because the shipped defaults are the
500
+ * answer for anyone who has not measured their own runners. */
501
+ const askCaps: AreaAsker = async (ctx, a) => {
502
+ const caps: Partial<Caps> = { ...a.caps };
404
503
  const tuneCaps = await ctx.ui.confirm(
405
504
  "Caps",
406
505
  `Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
@@ -408,44 +507,48 @@ async function collectAnswers(
408
507
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
409
508
  `${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
410
509
  );
411
- if (tuneCaps) {
412
- // Spelled out rather than looped: adding a cap should fail to compile here,
413
- // not silently go unasked.
414
- caps.maxConcurrentWorkers = await askNumber(
415
- ctx,
416
- "Max concurrent workers",
417
- caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
418
- );
419
- caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
420
- caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
421
- caps.workerWallClockMs = await askNumber(
422
- ctx,
423
- "Wall-clock ceiling per worker (ms)",
424
- caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
425
- );
426
- caps.maxAttemptsPerIssue = await askNumber(
427
- ctx,
428
- "Attempts per issue before it escalates",
429
- caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
430
- );
431
- }
510
+ if (!tuneCaps) return { ...a, caps };
432
511
 
433
- // Straight after the caps, and for the same reason they sit together: these
434
- // are the two questions that decide what an unattended fleet may do without
435
- // asking anybody.
436
- const authority = await askAuthority(ctx, prior?.authority ?? SETUP_DEFAULTS.authority);
437
-
438
- // Outside the caps block: a model is not a ceiling, and an operator who left
439
- // the caps alone may still want workers on a cheaper model.
440
- const answeredModel = await ask(
512
+ // Spelled out rather than looped: adding a cap should fail to compile here,
513
+ // not silently go unasked.
514
+ caps.maxConcurrentWorkers = await askNumber(
441
515
  ctx,
442
- "Worker model pattern (blank = harness default)",
443
- prior?.workerModel ?? "",
516
+ "Max concurrent workers",
517
+ caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
444
518
  );
445
- const workerModel = answeredModel.trim().length > 0 ? answeredModel.trim() : undefined;
446
-
519
+ caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
520
+ caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
521
+ caps.workerWallClockMs = await askNumber(
522
+ ctx,
523
+ "Wall-clock ceiling per worker (ms)",
524
+ caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
525
+ );
526
+ caps.maxAttemptsPerIssue = await askNumber(
527
+ ctx,
528
+ "Attempts per issue before it escalates",
529
+ caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
530
+ );
531
+ return { ...a, caps };
532
+ };
533
+
534
+ /** Outside the caps block: a model is not a ceiling, and an operator who left
535
+ * the caps alone may still want workers on a cheaper model. */
536
+ const askWorkerModel: AreaAsker = async (ctx, a) => {
537
+ const answered = await ask(ctx, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
538
+ const next: SetupAnswers = { ...a };
539
+ if (answered.trim().length === 0) delete next.workerModel;
540
+ else next.workerModel = answered.trim();
541
+ return next;
542
+ };
543
+
544
+ /** Both grants, asked together because they are the two questions that decide
545
+ * what an unattended fleet may do without asking anybody. */
546
+ const askAuthorityArea: AreaAsker = async (ctx, a) => ({ ...a, authority: await askAuthority(ctx, a.authority) });
547
+
548
+ /** How a stuck run reaches a human, and who triages it when it does. */
549
+ const askEscalation: AreaAsker = async (ctx, a) => {
447
550
  const telegram = detectTelegram();
448
- let telegramChatId = prior?.escalation.telegramChatId;
551
+ let telegramChatId = a.telegramChatId;
449
552
  if (telegram.available && telegram.hasToken) {
450
553
  if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
451
554
  const usePaired = await ctx.ui.confirm(
@@ -470,32 +573,128 @@ async function collectAnswers(
470
573
  "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
471
574
  );
472
575
 
473
- const orchestratorMode = await askOrchestratorMode(
576
+ const orchestratorMode = await askOrchestratorMode(ctx, a.orchestratorMode);
577
+
578
+ const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode };
579
+ if (telegramChatId === undefined) delete next.telegramChatId;
580
+ else next.telegramChatId = telegramChatId;
581
+ return next;
582
+ };
583
+
584
+ /** How loud the orchestrator is when nobody asked it anything. */
585
+ const askReporting: AreaAsker = async (ctx, a) => ({ ...a, reportScope: await askReportScope(ctx, a.reportScope) });
586
+
587
+ /** The operator's own brief. Asked last in the full interview, because the
588
+ * question quotes the path the rest of the answers derive. */
589
+ const askBrief: AreaAsker = async (ctx, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ctx, a) });
590
+
591
+ /**
592
+ * One dialog sequence per amend area, keyed so a new area cannot be added to
593
+ * {@link AMEND_AREA_IDS} without one.
594
+ */
595
+ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
596
+ tracker: askTrackerAndRepos,
597
+ gates: askGatesOnly,
598
+ // The two per-worker knobs the full interview separates with the authority
599
+ // grants; an amend has no reason to put anything between them.
600
+ caps: async (ctx, a) => await askWorkerModel(ctx, await askCaps(ctx, a)),
601
+ graph: askGraph,
602
+ authority: askAuthorityArea,
603
+ escalation: askEscalation,
604
+ reporting: askReporting,
605
+ brief: askBrief,
606
+ };
607
+
608
+ /** The two ways to answer the first question a configured project gets. Labels,
609
+ * because the harness's select resolves to the label it displayed. */
610
+ const AMEND_ONE = "Change one area";
611
+ const REINTERVIEW = "Walk every question again";
612
+
613
+ /**
614
+ * The first question a re-run asks, and the reason amend mode exists: adding one
615
+ * key should not cost twenty prompts.
616
+ *
617
+ * Returns the area to amend, or `undefined` for the full interview. Only asked
618
+ * when the named project is already configured — a first run, or a new project
619
+ * beside an old one, has nothing to amend and is never shown this.
620
+ */
621
+ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promise<AmendAreaId | undefined> {
622
+ const mode = await ctx.ui.select(
623
+ `"${prior.name}" is already configured — what would you like to do?`,
624
+ [
625
+ {
626
+ label: AMEND_ONE,
627
+ description: "asks one area's questions; every other answer is carried through from the saved config",
628
+ },
629
+ {
630
+ label: REINTERVIEW,
631
+ description: "the full interview, every prompt pre-filled with what is configured now",
632
+ },
633
+ ],
634
+ { initialIndex: 0 },
635
+ );
636
+ if (mode === undefined) throw new Cancelled();
637
+ if (mode !== AMEND_ONE) {
638
+ // Either the operator chose the full interview, or the dialog answered with
639
+ // a label we never offered. Both land on today's behaviour, which is the one
640
+ // that cannot silently skip a question.
641
+ if (mode !== REINTERVIEW) ctx.ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
642
+ return undefined;
643
+ }
644
+
645
+ const choices = amendChoices(prior);
646
+ const picked = await ctx.ui.select(
647
+ "Which area? Each row shows what it says now",
648
+ choices.map((c) => ({ label: c.label, description: c.description })),
649
+ { initialIndex: 0 },
650
+ );
651
+ if (picked === undefined) throw new Cancelled();
652
+
653
+ const chosen = choices.find((c) => c.label === picked);
654
+ if (chosen === undefined) {
655
+ // Guessing an area here would ask the wrong questions and carry the rest
656
+ // through as if they had been reviewed. Abandoning changes nothing.
657
+ ctx.ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
658
+ throw new Cancelled();
659
+ }
660
+ return chosen.id;
661
+ }
662
+
663
+ /**
664
+ * The conversation. Reads only — every answer is collected before anything is
665
+ * checked against GitHub, and long before anything is written.
666
+ *
667
+ * Seeded from one answers object rather than pre-filling each prompt from
668
+ * `prior?.field ?? default`: that is the same carry-through an amend relies on,
669
+ * so the two flows cannot disagree about what an unanswered field is.
670
+ */
671
+ async function collectAnswers(
672
+ ctx: CommandContext,
673
+ prior: ProjectConfig | undefined,
674
+ projectArg: string | undefined,
675
+ ): Promise<SetupAnswers> {
676
+ const seed = prior === undefined ? defaultAnswers(projectArg ?? "") : answersFromProject(prior);
677
+
678
+ const projectName = await askValid(
474
679
  ctx,
475
- prior?.escalation.orchestrator ?? SETUP_DEFAULTS.orchestratorMode,
680
+ "Project name",
681
+ projectArg ?? seed.projectName,
682
+ (v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
476
683
  );
477
684
 
478
- const reportScope = await askReportScope(ctx, prior?.reporting?.scope ?? DEFAULT_REPORT_SCOPE);
479
-
480
- const answers: SetupAnswers = {
481
- projectName,
482
- trackerRepo,
483
- queueLabel,
484
- stateLabels,
485
- routingLabelPrefix,
486
- targetRepos,
487
- caps,
488
- fallbackToIssueComment,
489
- authority,
490
- orchestratorMode,
491
- reportScope,
492
- // Asked last, and asked with the real path in the question — which needs the
493
- // rest of the answers to derive, so the decision is folded in below.
494
- writeOrchestratorBrief: false,
495
- };
496
- if (telegramChatId !== undefined) answers.telegramChatId = telegramChatId;
497
- if (workerModel !== undefined) answers.workerModel = workerModel;
498
- return { ...answers, writeOrchestratorBrief: await askOrchestratorBrief(ctx, answers) };
685
+ let a: SetupAnswers = { ...seed, projectName };
686
+ a = await askTrackerAndRepos(ctx, a);
687
+ // Straight after the repos, because it is a fact about them: one clone per
688
+ // routed repo, under one root.
689
+ a = await askGraph(ctx, a);
690
+ a = await askCaps(ctx, a);
691
+ a = await askAuthorityArea(ctx, a);
692
+ a = await askWorkerModel(ctx, a);
693
+ a = await askEscalation(ctx, a);
694
+ a = await askReporting(ctx, a);
695
+ // Asked last, and asked with the real path in the question — which needs the
696
+ // rest of the answers to derive.
697
+ return await askBrief(ctx, a);
499
698
  }
500
699
 
501
700
  /** The dry run, rendered. Same routing code the loop uses, so this is what the
@@ -533,14 +732,50 @@ async function tryPreview(project: string): Promise<string[]> {
533
732
  }
534
733
  }
535
734
 
735
+ /** What the whole conversation produced: the answers, and which area an amend
736
+ * narrowed it to. `amend` absent means every question was asked. */
737
+ export interface CollectedSetup {
738
+ answers: SetupAnswers;
739
+ amend?: { area: AmendAreaId; before: ProjectConfig };
740
+ }
741
+
536
742
  /**
537
- * The onboarding wizard.
743
+ * The whole conversation, from the amend question to the last prompt, and not one
744
+ * byte further: no `gh`, no dry run, nothing written.
745
+ *
746
+ * Exported at exactly that seam so a test can script the dialogs and pin what a
747
+ * first run asks and what an amend refuses to ask — the two properties amend mode
748
+ * is judged on — on a host with no `gh` and no config.
749
+ */
750
+ export async function collectSetup(
751
+ ctx: CommandContext,
752
+ existing: ConductorConfig | undefined,
753
+ projectArg: string | undefined,
754
+ ): Promise<CollectedSetup> {
755
+ // Only a project that is already configured can be amended. A first run, or a
756
+ // name this config has never seen, goes straight into the full interview with
757
+ // no extra question — which is what it was before amend mode existed.
758
+ const prior = priorProject(existing, projectArg);
759
+ if (prior === undefined) return { answers: await collectAnswers(ctx, undefined, projectArg) };
760
+
761
+ const area = await chooseAmendArea(ctx, prior);
762
+ if (area === undefined) return { answers: await collectAnswers(ctx, prior, projectArg) };
763
+
764
+ return { answers: await AREA_ASKERS[area](ctx, answersFromProject(prior)), amend: { area, before: prior } };
765
+ }
766
+
767
+ /**
768
+ * The onboarding wizard, and — for a project it already knows — the amend.
538
769
  *
539
770
  * The invariant that makes this safe to run against a live tracker: nothing is
540
771
  * written or created before the confirm below returns true. Reading the config,
541
772
  * asking questions, `checkTokenScopes`, `planLabels` and `previewQueue` are all
542
773
  * reads. The four mutations — `createMissingLabels`, `saveConfig`,
543
774
  * `writeOrchestratorBrief`, `armConductor` — all live after it. Keep it that way.
775
+ *
776
+ * An amend changes which questions are asked and what the summary leads with,
777
+ * and nothing else: the same answers, the same `buildConfig`, the same single
778
+ * confirm, the same dry run. One writer, one consent gate.
544
779
  */
545
780
  async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
546
781
  const path = configPath();
@@ -552,14 +787,15 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
552
787
  ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
553
788
  }
554
789
 
555
- let answers: SetupAnswers;
790
+ let collected: CollectedSetup;
556
791
  try {
557
- answers = await collectAnswers(ctx, existing, projectArg);
792
+ collected = await collectSetup(ctx, existing, projectArg);
558
793
  } catch (err) {
559
794
  if (!(err instanceof Cancelled)) throw err;
560
795
  ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
561
796
  return;
562
797
  }
798
+ const { answers, amend } = collected;
563
799
 
564
800
  const scopes = await checkTokenScopes();
565
801
  const labels = await planLabels(answers.trackerRepo, answers);
@@ -567,6 +803,9 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
567
803
 
568
804
  ctx.ui.notify(
569
805
  [
806
+ // The delta first when there is one, then the whole plan: the confirm has
807
+ // to name every mutation it authorises, and a delta names none of them.
808
+ ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
570
809
  summarisePlan(answers, scopes, labels, telegram),
571
810
  "",
572
811
  existing === undefined
@@ -581,7 +820,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
581
820
 
582
821
  const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
583
822
  const go = await ctx.ui.confirm(
584
- "Apply this setup?",
823
+ amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
585
824
  [
586
825
  toCreate.length > 0
587
826
  ? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`