omp-conductor 0.3.5 → 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,7 +11,7 @@
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 { dirname, isAbsolute } from "node:path";
14
+ import { isAbsolute } from "node:path";
15
15
  import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
16
16
  import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
17
17
  import {
@@ -25,24 +25,30 @@ import {
25
25
  } from "./daemon.ts";
26
26
  import { defaultGraphRoot } from "./graph.ts";
27
27
  import {
28
+ AMEND_AREAS,
28
29
  ORCHESTRATOR_BRIEF_NAME,
29
30
  REPORT_SCOPE_CHOICES,
30
31
  SETUP_DEFAULTS,
32
+ amendChoices,
33
+ answersFromProject,
31
34
  briefPathForProject,
32
35
  buildConfig,
33
36
  checkTokenScopes,
34
37
  createMissingLabels,
38
+ defaultAnswers,
35
39
  detectTelegram,
40
+ formatGates,
36
41
  orchestratorBriefPath,
37
42
  planLabels,
38
43
  renderBriefForProject,
44
+ summariseAmend,
39
45
  summarisePlan,
40
46
  writeOrchestratorBrief,
47
+ type AmendAreaId,
41
48
  type SetupAnswers,
42
49
  } from "./setup.ts";
43
50
  import {
44
51
  DEFAULT_CAPS,
45
- DEFAULT_REPORT_SCOPE,
46
52
  type Caps,
47
53
  type ConductorConfig,
48
54
  type OrchestratorMode,
@@ -102,7 +108,11 @@ interface PluginApi {
102
108
  }
103
109
 
104
110
  const SUBCOMMANDS: Completion[] = [
105
- { 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
+ },
106
116
  { value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
107
117
  { value: "pause", label: "pause", description: "stop claiming new work" },
108
118
  { value: "resume", label: "resume", description: "allow claiming again" },
@@ -114,7 +124,7 @@ const SUBCOMMANDS: Completion[] = [
114
124
  ];
115
125
 
116
126
  const USAGE = [
117
- "/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",
118
128
  "/conductor status [project] pause state, caps, active runs, today's usage",
119
129
  "/conductor pause stop claiming new work",
120
130
  "/conductor resume allow claiming again",
@@ -185,7 +195,8 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
185
195
 
186
196
  /**
187
197
  * Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
188
- * `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.
189
200
  *
190
201
  * ponytail: the ceiling is a command containing a comma or a literal " @ ",
191
202
  * which this would split wrongly. Rare in a lint or test invocation, and the
@@ -197,8 +208,11 @@ async function askGates(
197
208
  repoName: string,
198
209
  seed: { cmd: string; cwd: string }[],
199
210
  ): Promise<{ cmd: string; cwd: string }[]> {
200
- const shown = seed.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
201
- 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
+ );
202
216
 
203
217
  const gates: { cmd: string; cwd: string }[] = [];
204
218
  for (const chunk of raw.split(",")) {
@@ -359,40 +373,40 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
359
373
  }
360
374
 
361
375
  /**
362
- * The conversation. Reads only every answer is collected before anything is
363
- * 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.
364
383
  */
365
- async function collectAnswers(
366
- ctx: CommandContext,
367
- existing: ConductorConfig | undefined,
368
- projectArg: string | undefined,
369
- ): Promise<SetupAnswers> {
370
- const prior = priorProject(existing, projectArg);
371
-
372
- const projectName = await askValid(
373
- ctx,
374
- "Project name",
375
- projectArg ?? prior?.name ?? "",
376
- (v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
377
- );
384
+ type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>;
378
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) => {
379
393
  const trackerRepo = await askValid(
380
394
  ctx,
381
395
  "Tracker repo (owner/repo) — where ready issues live",
382
- prior?.tracker.repo ?? "",
396
+ a.trackerRepo,
383
397
  (v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
384
398
  );
385
399
 
386
400
  const queueLabel = await ask(
387
401
  ctx,
388
402
  "Queue label — the human sign-off that makes an issue claimable",
389
- prior?.queueLabel ?? SETUP_DEFAULTS.queueLabel,
403
+ a.queueLabel,
390
404
  );
391
405
 
392
406
  // One confirm instead of three prompts: the namespaced defaults are right for
393
407
  // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
394
408
  // its reputation.
395
- const stateLabels: SetupAnswers["stateLabels"] = { ...(prior?.stateLabels ?? SETUP_DEFAULTS.stateLabels) };
409
+ const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
396
410
  const customiseStates = await ctx.ui.confirm(
397
411
  "State labels",
398
412
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
@@ -407,13 +421,12 @@ async function collectAnswers(
407
421
  const routingLabelPrefix = await ask(
408
422
  ctx,
409
423
  "Routing label prefix — an issue picks its checkout with <prefix><repo>",
410
- prior?.routing.labelPrefix ?? SETUP_DEFAULTS.routingLabelPrefix,
424
+ a.routingLabelPrefix,
411
425
  );
412
426
 
413
427
  const targetRepos: SetupAnswers["targetRepos"] = [];
414
- const seeds = Object.values(prior?.routing.repos ?? {});
415
428
  for (let i = 0; ; i++) {
416
- const seed = seeds[i];
429
+ const seed = a.targetRepos[i];
417
430
  const name = await askValid(
418
431
  ctx,
419
432
  `Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
@@ -440,18 +453,53 @@ async function collectAnswers(
440
453
  if (!more) break;
441
454
  }
442
455
 
443
- // Straight after the repos, because it is a fact about them: one clone per
444
- // routed repo, under one root. Seeded from whichever prior repo already had
445
- // one — the wizard writes them as siblings, so any one of them names the root.
446
- const priorGraph = Object.values(prior?.routing.repos ?? {}).find((r) => r.graphProject !== undefined);
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) => {
447
484
  const graphRoot = await askGraphRoot(
448
485
  ctx,
449
- trackerRepo,
450
- targetRepos.map((r) => r.name),
451
- priorGraph?.graphProject === undefined ? undefined : dirname(priorGraph.graphProject),
486
+ a.trackerRepo,
487
+ a.targetRepos.map((r) => r.name),
488
+ a.graphRoot,
452
489
  );
453
490
 
454
- const caps: Partial<Caps> = { ...prior?.caps };
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 };
455
503
  const tuneCaps = await ctx.ui.confirm(
456
504
  "Caps",
457
505
  `Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
@@ -459,44 +507,48 @@ async function collectAnswers(
459
507
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
460
508
  `${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
461
509
  );
462
- if (tuneCaps) {
463
- // Spelled out rather than looped: adding a cap should fail to compile here,
464
- // not silently go unasked.
465
- caps.maxConcurrentWorkers = await askNumber(
466
- ctx,
467
- "Max concurrent workers",
468
- caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
469
- );
470
- caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
471
- caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
472
- caps.workerWallClockMs = await askNumber(
473
- ctx,
474
- "Wall-clock ceiling per worker (ms)",
475
- caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
476
- );
477
- caps.maxAttemptsPerIssue = await askNumber(
478
- ctx,
479
- "Attempts per issue before it escalates",
480
- caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
481
- );
482
- }
483
-
484
- // Straight after the caps, and for the same reason they sit together: these
485
- // are the two questions that decide what an unattended fleet may do without
486
- // asking anybody.
487
- const authority = await askAuthority(ctx, prior?.authority ?? SETUP_DEFAULTS.authority);
510
+ if (!tuneCaps) return { ...a, caps };
488
511
 
489
- // Outside the caps block: a model is not a ceiling, and an operator who left
490
- // the caps alone may still want workers on a cheaper model.
491
- 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(
492
515
  ctx,
493
- "Worker model pattern (blank = harness default)",
494
- prior?.workerModel ?? "",
516
+ "Max concurrent workers",
517
+ caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
495
518
  );
496
- const workerModel = answeredModel.trim().length > 0 ? answeredModel.trim() : undefined;
497
-
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) => {
498
550
  const telegram = detectTelegram();
499
- let telegramChatId = prior?.escalation.telegramChatId;
551
+ let telegramChatId = a.telegramChatId;
500
552
  if (telegram.available && telegram.hasToken) {
501
553
  if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
502
554
  const usePaired = await ctx.ui.confirm(
@@ -521,33 +573,128 @@ async function collectAnswers(
521
573
  "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
522
574
  );
523
575
 
524
- 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(
525
679
  ctx,
526
- 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."),
527
683
  );
528
684
 
529
- const reportScope = await askReportScope(ctx, prior?.reporting?.scope ?? DEFAULT_REPORT_SCOPE);
530
-
531
- const answers: SetupAnswers = {
532
- projectName,
533
- trackerRepo,
534
- queueLabel,
535
- stateLabels,
536
- routingLabelPrefix,
537
- targetRepos,
538
- caps,
539
- fallbackToIssueComment,
540
- authority,
541
- orchestratorMode,
542
- reportScope,
543
- // Asked last, and asked with the real path in the question — which needs the
544
- // rest of the answers to derive, so the decision is folded in below.
545
- writeOrchestratorBrief: false,
546
- };
547
- if (telegramChatId !== undefined) answers.telegramChatId = telegramChatId;
548
- if (workerModel !== undefined) answers.workerModel = workerModel;
549
- if (graphRoot !== undefined) answers.graphRoot = graphRoot;
550
- 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);
551
698
  }
552
699
 
553
700
  /** The dry run, rendered. Same routing code the loop uses, so this is what the
@@ -585,14 +732,50 @@ async function tryPreview(project: string): Promise<string[]> {
585
732
  }
586
733
  }
587
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
+
588
742
  /**
589
- * 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.
590
769
  *
591
770
  * The invariant that makes this safe to run against a live tracker: nothing is
592
771
  * written or created before the confirm below returns true. Reading the config,
593
772
  * asking questions, `checkTokenScopes`, `planLabels` and `previewQueue` are all
594
773
  * reads. The four mutations — `createMissingLabels`, `saveConfig`,
595
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.
596
779
  */
597
780
  async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
598
781
  const path = configPath();
@@ -604,14 +787,15 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
604
787
  ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
605
788
  }
606
789
 
607
- let answers: SetupAnswers;
790
+ let collected: CollectedSetup;
608
791
  try {
609
- answers = await collectAnswers(ctx, existing, projectArg);
792
+ collected = await collectSetup(ctx, existing, projectArg);
610
793
  } catch (err) {
611
794
  if (!(err instanceof Cancelled)) throw err;
612
795
  ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
613
796
  return;
614
797
  }
798
+ const { answers, amend } = collected;
615
799
 
616
800
  const scopes = await checkTokenScopes();
617
801
  const labels = await planLabels(answers.trackerRepo, answers);
@@ -619,6 +803,9 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
619
803
 
620
804
  ctx.ui.notify(
621
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)]),
622
809
  summarisePlan(answers, scopes, labels, telegram),
623
810
  "",
624
811
  existing === undefined
@@ -633,7 +820,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
633
820
 
634
821
  const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
635
822
  const go = await ctx.ui.confirm(
636
- "Apply this setup?",
823
+ amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
637
824
  [
638
825
  toCreate.length > 0
639
826
  ? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`