omp-conductor 0.16.2 → 0.17.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.
@@ -107,6 +107,7 @@ import {
107
107
  type ReportScopeChoice,
108
108
  type ResolvedGrants,
109
109
  } from "./types.ts";
110
+ import { withProgress } from "./ui/progress.ts";
110
111
  import type { WizardUi } from "./wizard-ui.ts";
111
112
 
112
113
  /**
@@ -132,8 +133,8 @@ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
132
133
  * takes it, because the harness has no pre-filled input dialog — so "Enter
133
134
  * accepts what you see" is the contract the whole wizard is built on.
134
135
  */
135
- async function ask(ui: WizardUi, title: string, fallback: string): Promise<string> {
136
- const raw = await ui.input(title, fallback.length > 0 ? fallback : undefined);
136
+ async function ask(ui: WizardUi, key: string, title: string, fallback: string): Promise<string> {
137
+ const raw = await ui.input(title, fallback.length > 0 ? fallback : undefined, { key });
137
138
  if (raw === undefined) throw new Cancelled();
138
139
  const trimmed = raw.trim();
139
140
  return trimmed.length > 0 ? trimmed : fallback;
@@ -147,8 +148,8 @@ async function ask(ui: WizardUi, title: string, fallback: string): Promise<strin
147
148
  * recorded a silent no and walked on to the next question, which is not what
148
149
  * abandoning a run means. `undefined` is the surface saying the operator left.
149
150
  */
150
- async function askYesNo(ui: WizardUi, title: string, message: string): Promise<boolean> {
151
- const answer = await ui.confirm(title, message);
151
+ async function askYesNo(ui: WizardUi, key: string, title: string, message: string): Promise<boolean> {
152
+ const answer = await ui.confirm(title, message, { key });
152
153
  if (answer === undefined) throw new Cancelled();
153
154
  return answer;
154
155
  }
@@ -160,12 +161,13 @@ async function askYesNo(ui: WizardUi, title: string, message: string): Promise<b
160
161
  */
161
162
  async function askValid(
162
163
  ui: WizardUi,
164
+ key: string,
163
165
  title: string,
164
166
  fallback: string,
165
167
  check: (value: string) => string | undefined,
166
168
  ): Promise<string> {
167
169
  for (let attempt = 0; attempt < 3; attempt++) {
168
- const value = await ask(ui, title, fallback);
170
+ const value = await ask(ui, key, title, fallback);
169
171
  const problem = check(value);
170
172
  if (problem === undefined) return value;
171
173
  ui.notify(problem, "warning");
@@ -176,8 +178,8 @@ async function askValid(
176
178
 
177
179
  /** A cap. Unparseable input keeps the current value rather than writing a NaN
178
180
  * the validator would later reject — the operator sees why, immediately. */
179
- async function askNumber(ui: WizardUi, title: string, fallback: number): Promise<number> {
180
- const raw = await ask(ui, title, String(fallback));
181
+ async function askNumber(ui: WizardUi, key: string, title: string, fallback: number): Promise<number> {
182
+ const raw = await ask(ui, key, title, String(fallback));
181
183
  const value = Number(raw);
182
184
  if (!Number.isFinite(value) || value < 0) {
183
185
  ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
@@ -193,11 +195,12 @@ async function askNumber(ui: WizardUi, title: string, fallback: number): Promise
193
195
  */
194
196
  async function askSpendCap(
195
197
  ui: WizardUi,
198
+ key: string,
196
199
  title: string,
197
200
  fallback: number | null,
198
201
  ): Promise<number | null> {
199
202
  const seed = fallback === null ? "" : String(fallback);
200
- const raw = (await ask(ui, title, seed)).trim().toLowerCase();
203
+ const raw = (await ask(ui, key, title, seed)).trim().toLowerCase();
201
204
  if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
202
205
  const value = Number(raw);
203
206
  if (!Number.isFinite(value) || value < 0) {
@@ -232,6 +235,7 @@ async function askGates(
232
235
  // found, so it is on screen either way.
233
236
  const raw = await ask(
234
237
  ui,
238
+ `pre-push-gates.${repoName}`,
235
239
  `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
236
240
  formatGates(seed.length > 0 ? seed : probed),
237
241
  );
@@ -263,6 +267,7 @@ async function askReportScope(ui: WizardUi, current: ReportScopeChoice): Promise
263
267
  const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
264
268
  const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
265
269
  const picked = await ui.select("What should the orchestrator report unprompted?", options, {
270
+ key: "report-scope",
266
271
  initialIndex: at === -1 ? 0 : at,
267
272
  });
268
273
  if (picked === undefined) throw new Cancelled();
@@ -292,6 +297,7 @@ async function askReportScope(ui: WizardUi, current: ReportScopeChoice): Promise
292
297
  */
293
298
  async function askLiteral<T extends string>(
294
299
  ui: WizardUi,
300
+ key: string,
295
301
  title: string,
296
302
  values: readonly T[],
297
303
  described: { readonly [K in T]: string },
@@ -301,7 +307,7 @@ async function askLiteral<T extends string>(
301
307
  const picked = await ui.select(
302
308
  title,
303
309
  values.map((v) => ({ label: v, description: described[v] })),
304
- { initialIndex: at === -1 ? 0 : at },
310
+ { key, initialIndex: at === -1 ? 0 : at },
305
311
  );
306
312
  if (picked === undefined) throw new Cancelled();
307
313
 
@@ -336,8 +342,13 @@ function parseNameList(answer: string): string[] {
336
342
 
337
343
  /** Check names, artefacts, environments: open-ended lists this package cannot
338
344
  * enumerate, so the only validation is the shape. */
339
- async function askNameList(ui: WizardUi, title: string, seed: readonly string[]): Promise<string[]> {
340
- return parseNameList(await ask(ui, title, formatNameList(seed)));
345
+ async function askNameList(
346
+ ui: WizardUi,
347
+ key: string,
348
+ title: string,
349
+ seed: readonly string[],
350
+ ): Promise<string[]> {
351
+ return parseNameList(await ask(ui, key, title, formatNameList(seed)));
341
352
  }
342
353
 
343
354
  /**
@@ -362,6 +373,7 @@ async function askReleaseRequirements(
362
373
  );
363
374
  const answered = await askValid(
364
375
  ui,
376
+ "release-requirements",
365
377
  `Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
366
378
  formatNameList(prior),
367
379
  (value) => {
@@ -398,11 +410,13 @@ async function askPolicyPreconditions(
398
410
  const merge = {
399
411
  requiredChecks: await askNameList(
400
412
  ui,
413
+ "merge-required-checks",
401
414
  `Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
402
415
  prior.merge.requiredChecks,
403
416
  ),
404
417
  baseFreshness: await askLiteral(
405
418
  ui,
419
+ "merge-base-freshness",
406
420
  "Merge — must the PR be level with its base?",
407
421
  BASE_FRESHNESS,
408
422
  BASE_FRESHNESS_CHOICES,
@@ -410,6 +424,7 @@ async function askPolicyPreconditions(
410
424
  ),
411
425
  drafts: await askLiteral(
412
426
  ui,
427
+ "merge-drafts",
413
428
  "Merge — draft pull requests",
414
429
  DRAFT_POLICIES,
415
430
  DRAFT_POLICY_CHOICES,
@@ -417,6 +432,7 @@ async function askPolicyPreconditions(
417
432
  ),
418
433
  whenBehindBase: await askLiteral(
419
434
  ui,
435
+ "merge-behind-base",
420
436
  "Merge — a green PR that fell behind its base",
421
437
  BEHIND_BASE_ACTIONS,
422
438
  BEHIND_BASE_CHOICES,
@@ -430,16 +446,19 @@ async function askPolicyPreconditions(
430
446
  requires: await askReleaseRequirements(ui, prior.release.requires),
431
447
  requiredChecks: await askNameList(
432
448
  ui,
449
+ "release-required-checks",
433
450
  `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
434
451
  prior.release.requiredChecks,
435
452
  ),
436
453
  artefacts: await askNameList(
437
454
  ui,
455
+ "release-artefacts",
438
456
  `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
439
457
  prior.release.artefacts,
440
458
  ),
441
459
  environments: await askNameList(
442
460
  ui,
461
+ "release-environments",
443
462
  `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
444
463
  prior.release.environments,
445
464
  ),
@@ -487,6 +506,7 @@ async function askAuthority(
487
506
  ): Promise<ProjectConfig["authority"]> {
488
507
  const merge = await askYesNo(
489
508
  ui,
509
+ "merge-authority",
490
510
  "Merge authority",
491
511
  "Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
492
512
  "re-checked against the base branch first. Default: humans merge" +
@@ -496,6 +516,7 @@ async function askAuthority(
496
516
  const unlockCount = unlock.gates.length + unlock.policy.length;
497
517
  const release = await askYesNo(
498
518
  ui,
519
+ "release-authority",
499
520
  "Release authority",
500
521
  "Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
501
522
  "procedure you write into its brief — and its brief forbids cutting one before you have. " +
@@ -506,6 +527,7 @@ async function askAuthority(
506
527
  );
507
528
  const promotion = await askYesNo(
508
529
  ui,
530
+ "promotion-authority",
509
531
  "Promotion authority",
510
532
  "Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " +
511
533
  "issue — the sign-off that lets a worker claim it, and the act that starts spend. " +
@@ -576,13 +598,14 @@ async function askJudgment(
576
598
  const picked = await ui.select(
577
599
  "Roadmap candidates found in GitHub",
578
600
  [...roadmapChoices.map((label) => ({ label })), { label: other }],
579
- { initialIndex: 0 },
601
+ { key: "roadmap-candidate", initialIndex: 0 },
580
602
  );
581
603
  if (picked === undefined) throw new Cancelled();
582
604
  roadmapSeed = picked === other ? "" : picked;
583
605
  }
584
606
  const roadmap = await ask(
585
607
  ui,
608
+ "roadmap",
586
609
  "Where does the roadmap live, and what is the current priority?",
587
610
  roadmapSeed,
588
611
  );
@@ -622,26 +645,40 @@ async function askJudgment(
622
645
  // so an operator has something to calibrate against.
623
646
  judgment.boundary = await ask(
624
647
  ui,
648
+ "release-boundary",
625
649
  "Where does the orchestrator's leg END? One sentence " +
626
650
  '(a real answer: "at the merged version pin — deploying it is operator territory")',
627
651
  prior.boundary ?? "",
628
652
  );
629
653
 
630
654
  // All five, because the brief needs each and a missing one is a hole.
631
- judgment.releaseWhat = await ask(ui, "Release — WHAT may be released, and from which branch?", prior.releaseWhat ?? "");
655
+ judgment.releaseWhat = await ask(
656
+ ui,
657
+ "release-what",
658
+ "Release — WHAT may be released, and from which branch?",
659
+ prior.releaseWhat ?? "",
660
+ );
632
661
  judgment.releaseWhen = await ask(
633
662
  ui,
663
+ "release-when",
634
664
  "Release — WHEN: batched how, after which named checks are green?",
635
665
  prior.releaseWhen ?? "",
636
666
  );
637
667
  judgment.releaseProof = await ask(
638
668
  ui,
669
+ "release-proof",
639
670
  "Release — WHAT PROOF must be held first (results actually read, not an impression)?",
640
671
  prior.releaseProof ?? "",
641
672
  );
642
- judgment.releaseAsk = await ask(ui, "Release — what must still be ASKED, every time?", prior.releaseAsk ?? "");
673
+ judgment.releaseAsk = await ask(
674
+ ui,
675
+ "release-ask",
676
+ "Release — what must still be ASKED, every time?",
677
+ prior.releaseAsk ?? "",
678
+ );
643
679
  judgment.releaseForbidden = await ask(
644
680
  ui,
681
+ "release-forbidden",
645
682
  "Release — what stays permanently FORBIDDEN?",
646
683
  prior.releaseForbidden ?? "force-push, secrets, production data",
647
684
  );
@@ -652,17 +689,19 @@ async function askJudgment(
652
689
  // `RELEASE_REQUIREMENTS`, which are mechanical preconditions, not a unit.
653
690
  judgment.worthCutting = await ask(
654
691
  ui,
692
+ "release-worth-cutting",
655
693
  "What is a release worth cutting? (a sprint, an epic's children all closed, N merged issues, urgency)",
656
694
  prior.worthCutting ?? "",
657
695
  );
658
696
 
659
- judgment.rollbackOwner = await ask(ui, "Who owns the rollback?", prior.rollbackOwner ?? "");
697
+ judgment.rollbackOwner = await ask(ui, "rollback-owner", "Who owns the rollback?", prior.rollbackOwner ?? "");
660
698
  if (namesAPerson(judgment.rollbackOwner)) {
661
699
  // Honour the consequence rather than recording a contradiction: if a person
662
700
  // rolls it back, that person owns the release, and the boundary belongs
663
701
  // before the irreversible step whatever the authority answer sounded like.
664
702
  judgment.rollbackMovesBoundary = await askYesNo(
665
703
  ui,
704
+ "rollback-moves-boundary",
666
705
  "Rollback owner is a person",
667
706
  `You named "${judgment.rollbackOwner}" as the rollback owner. That person already owns the release, ` +
668
707
  "so the honest configuration puts the orchestrator's boundary *before* the irreversible step — " +
@@ -671,6 +710,7 @@ async function askJudgment(
671
710
  if (judgment.rollbackMovesBoundary) {
672
711
  judgment.boundary = await ask(
673
712
  ui,
713
+ "release-boundary",
674
714
  "Restate the boundary, ending before the irreversible step",
675
715
  judgment.boundary ?? "",
676
716
  );
@@ -713,6 +753,7 @@ async function askReleaseGrants(
713
753
  for (const shape of RELEASE_SHAPES) {
714
754
  const open = await askYesNo(
715
755
  ui,
756
+ `release-tool-gate.${shape}`,
716
757
  `Release tool gate — ${shape}`,
717
758
  `Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
718
759
  "operator brief carries the procedure it must follow. A worker session is refused this " +
@@ -733,6 +774,7 @@ async function askReleaseGrants(
733
774
  async function askOrchestratorMode(ui: WizardUi, prior: OrchestratorMode): Promise<OrchestratorMode> {
734
775
  const external = await askYesNo(
735
776
  ui,
777
+ "orchestrator-mode",
736
778
  "Orchestrator session",
737
779
  "Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
738
780
  "Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
@@ -762,6 +804,7 @@ async function askGraphRoot(
762
804
  ): Promise<string | undefined> {
763
805
  const wanted = await askYesNo(
764
806
  ui,
807
+ "code-graph-enabled",
765
808
  "Code-graph discovery",
766
809
  "Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
767
810
  'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
@@ -772,6 +815,7 @@ async function askGraphRoot(
772
815
 
773
816
  return await askValid(
774
817
  ui,
818
+ "code-graph-root",
775
819
  `Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
776
820
  prior ?? defaultGraphRoot(trackerRepo),
777
821
  (v) =>
@@ -791,6 +835,7 @@ async function askOrchestratorBrief(ui: WizardUi, a: SetupAnswers): Promise<bool
791
835
  const path = orchestratorBriefPath(a);
792
836
  const wanted = await askYesNo(
793
837
  ui,
838
+ "write-operator-brief",
794
839
  `Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
795
840
  `Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
796
841
  `(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` +
@@ -801,6 +846,7 @@ async function askOrchestratorBrief(ui: WizardUi, a: SetupAnswers): Promise<bool
801
846
 
802
847
  return await askYesNo(
803
848
  ui,
849
+ "overwrite-operator-brief",
804
850
  `Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
805
851
  `${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
806
852
  );
@@ -990,6 +1036,7 @@ async function proseFromRepos(ui: WizardUi, a: SetupAnswers): Promise<ProbedPros
990
1036
  const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
991
1037
  const trackerRepo = await askValid(
992
1038
  ui,
1039
+ "tracker-repo",
993
1040
  "Tracker repo (owner/repo) — where ready issues live",
994
1041
  a.trackerRepo,
995
1042
  (v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
@@ -997,6 +1044,7 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
997
1044
 
998
1045
  const queueLabel = await ask(
999
1046
  ui,
1047
+ "queue-label",
1000
1048
  "Queue label — the human sign-off that makes an issue claimable",
1001
1049
  a.queueLabel,
1002
1050
  );
@@ -1007,18 +1055,20 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
1007
1055
  const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
1008
1056
  const customiseStates = await askYesNo(
1009
1057
  ui,
1058
+ "customise-state-labels",
1010
1059
  "State labels",
1011
1060
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
1012
1061
  `"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
1013
1062
  );
1014
1063
  if (customiseStates) {
1015
- stateLabels.inProgress = await ask(ui, "Label for a run in progress", stateLabels.inProgress);
1016
- stateLabels.blocked = await ask(ui, "Label for a run parked on a human", stateLabels.blocked);
1017
- stateLabels.failed = await ask(ui, "Label for a run that gave up", stateLabels.failed);
1064
+ stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress);
1065
+ stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked);
1066
+ stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed);
1018
1067
  }
1019
1068
 
1020
1069
  const routingLabelPrefix = await ask(
1021
1070
  ui,
1071
+ "routing-label-prefix",
1022
1072
  "Routing label prefix — an issue picks its checkout with <prefix><repo>",
1023
1073
  a.routingLabelPrefix,
1024
1074
  );
@@ -1028,18 +1078,21 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
1028
1078
  const seed = a.targetRepos[i];
1029
1079
  const name = await askValid(
1030
1080
  ui,
1081
+ `routing-key.${i + 1}`,
1031
1082
  `Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
1032
1083
  seed?.name ?? "",
1033
1084
  (v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."),
1034
1085
  );
1035
1086
  const cloneUrl = await askValid(
1036
1087
  ui,
1088
+ `clone-url.${name}`,
1037
1089
  `Clone URL for ${routingLabelPrefix}${name}`,
1038
1090
  seed?.cloneUrl ?? "",
1039
1091
  (v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."),
1040
1092
  );
1041
1093
  const defaultBranch = await ask(
1042
1094
  ui,
1095
+ `default-branch.${name}`,
1043
1096
  `Default branch for ${name} — worktrees are cut from it and PRs target it`,
1044
1097
  seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
1045
1098
  );
@@ -1051,6 +1104,7 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
1051
1104
 
1052
1105
  const more = await askYesNo(
1053
1106
  ui,
1107
+ `another-repo.${i + 1}`,
1054
1108
  "Another repo?",
1055
1109
  `${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`,
1056
1110
  );
@@ -1121,6 +1175,7 @@ const askCaps: AreaAsker = async (ui, a) => {
1121
1175
  : "";
1122
1176
  const tuneCaps = await askYesNo(
1123
1177
  ui,
1178
+ "tune-caps",
1124
1179
  "Caps",
1125
1180
  `Defaults: ${workersDefault} workers, ` +
1126
1181
  `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
@@ -1143,16 +1198,19 @@ const askCaps: AreaAsker = async (ui, a) => {
1143
1198
  // not silently go unasked.
1144
1199
  caps.maxConcurrentWorkers = await askNumber(
1145
1200
  ui,
1201
+ "max-concurrent-workers",
1146
1202
  "Max concurrent workers",
1147
1203
  workersDefault,
1148
1204
  );
1149
1205
  caps.dailySpendUsd = await askSpendCap(
1150
1206
  ui,
1207
+ "daily-spend-usd",
1151
1208
  "Spend ceiling per rolling day (USD) — blank = no spend cap",
1152
1209
  caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
1153
1210
  );
1154
1211
  const workerMaxTurns = await askNumber(
1155
1212
  ui,
1213
+ "worker-max-turns",
1156
1214
  "Turn ceiling per worker",
1157
1215
  caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
1158
1216
  );
@@ -1164,6 +1222,7 @@ const askCaps: AreaAsker = async (ui, a) => {
1164
1222
  );
1165
1223
  const workerMaxTurnsCeiling = await askNumber(
1166
1224
  ui,
1225
+ "worker-max-turns-ceiling",
1167
1226
  "Maximum turn ceiling for one issue",
1168
1227
  turnCeilingFallback,
1169
1228
  );
@@ -1179,16 +1238,19 @@ const askCaps: AreaAsker = async (ui, a) => {
1179
1238
  }
1180
1239
  caps.workerWallClockMs = await askNumber(
1181
1240
  ui,
1241
+ "worker-wall-clock-ms",
1182
1242
  "Wall-clock ceiling per worker (ms)",
1183
1243
  caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
1184
1244
  );
1185
1245
  caps.maxAttemptsPerIssue = await askNumber(
1186
1246
  ui,
1247
+ "max-attempts-per-issue",
1187
1248
  "Failed implementation attempts per issue before escalation",
1188
1249
  caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
1189
1250
  );
1190
1251
  caps.maxContinuationsPerIssue = await askNumber(
1191
1252
  ui,
1253
+ "max-continuations-per-issue",
1192
1254
  "Operational continuations per issue before escalation",
1193
1255
  caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
1194
1256
  );
@@ -1198,7 +1260,12 @@ const askCaps: AreaAsker = async (ui, a) => {
1198
1260
  /** Outside the caps block: a model is not a ceiling, and an operator who left
1199
1261
  * the caps alone may still want workers on a cheaper model. */
1200
1262
  const askWorkerModel: AreaAsker = async (ui, a) => {
1201
- const answered = await ask(ui, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
1263
+ const answered = await ask(
1264
+ ui,
1265
+ "worker-model",
1266
+ "Worker model pattern (blank = harness default)",
1267
+ a.workerModel ?? "",
1268
+ );
1202
1269
  const next: SetupAnswers = { ...a };
1203
1270
  if (answered.trim().length === 0) delete next.workerModel;
1204
1271
  else next.workerModel = answered.trim();
@@ -1216,6 +1283,7 @@ const askOmpSettings: AreaAsker = async (ui, a) => {
1216
1283
  const seed = a.ompSettings === undefined ? "" : JSON.stringify(a.ompSettings);
1217
1284
  const answered = await askValid(
1218
1285
  ui,
1286
+ "omp-settings",
1219
1287
  "Omp settings overlay for workers (YAML, blank = none — omp's schema, not conductor's)",
1220
1288
  seed,
1221
1289
  (value) => {
@@ -1280,12 +1348,18 @@ const askEscalation: AreaAsker = async (ui, a) => {
1280
1348
  if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
1281
1349
  const usePaired = await askYesNo(
1282
1350
  ui,
1351
+ "tier2-use-paired-chat",
1283
1352
  "Tier-2 escalations",
1284
1353
  `omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`,
1285
1354
  );
1286
1355
  if (usePaired) telegramChatId = telegram.pairedOwnerId;
1287
1356
  } else {
1288
- const answered = await ask(ui, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
1357
+ const answered = await ask(
1358
+ ui,
1359
+ "tier2-chat-id",
1360
+ "Telegram chat id for tier-2 escalations (blank for none)",
1361
+ telegramChatId ?? "",
1362
+ );
1289
1363
  telegramChatId = answered.length > 0 ? answered : undefined;
1290
1364
  }
1291
1365
  } else {
@@ -1303,6 +1377,7 @@ const askEscalation: AreaAsker = async (ui, a) => {
1303
1377
 
1304
1378
  const fallbackToIssueComment = await askYesNo(
1305
1379
  ui,
1380
+ "escalation-fallback",
1306
1381
  "Escalation fallback",
1307
1382
  "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
1308
1383
  );
@@ -1346,6 +1421,7 @@ async function askTelegramTopicId(
1346
1421
  ];
1347
1422
  const priorIdx = prior === undefined ? -1 : claimed.findIndex((t) => t.threadId === prior);
1348
1423
  const picked = await ui.select("Telegram forum topic for tier-2 pages", options, {
1424
+ key: "tier2-topic",
1349
1425
  initialIndex: priorIdx >= 0 ? priorIdx : claimed.length + 1,
1350
1426
  });
1351
1427
  if (picked === undefined) throw new Cancelled();
@@ -1358,6 +1434,7 @@ async function askTelegramTopicId(
1358
1434
 
1359
1435
  const answered = await ask(
1360
1436
  ui,
1437
+ "tier2-topic-id",
1361
1438
  "Telegram forum topic id (blank for flat chat)",
1362
1439
  prior !== undefined ? String(prior) : "",
1363
1440
  );
@@ -1388,7 +1465,7 @@ const askReporting: AreaAsker = async (ui, a) => {
1388
1465
  description: "hold non-bypass interruptions outside selected local working hours",
1389
1466
  },
1390
1467
  ],
1391
- { initialIndex: a.availability === undefined ? 0 : 1 },
1468
+ { key: "operator-availability", initialIndex: a.availability === undefined ? 0 : 1 },
1392
1469
  );
1393
1470
  if (mode === undefined) throw new Cancelled();
1394
1471
 
@@ -1420,6 +1497,7 @@ const askReporting: AreaAsker = async (ui, a) => {
1420
1497
  : a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback);
1421
1498
  const schedule = await askValid(
1422
1499
  ui,
1500
+ "digest-schedule",
1423
1501
  'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)',
1424
1502
  shown,
1425
1503
  (value) =>
@@ -1444,6 +1522,7 @@ const askReporting: AreaAsker = async (ui, a) => {
1444
1522
  a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
1445
1523
  const reportingTimezone = await askValid(
1446
1524
  ui,
1525
+ "reporting-timezone",
1447
1526
  "Operator timezone (IANA, for example Europe/London)",
1448
1527
  defaultZone,
1449
1528
  (value) => {
@@ -1457,6 +1536,7 @@ const askReporting: AreaAsker = async (ui, a) => {
1457
1536
  );
1458
1537
  const daysText = await askValid(
1459
1538
  ui,
1539
+ "working-weekdays",
1460
1540
  "Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)",
1461
1541
  a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri",
1462
1542
  (value) => {
@@ -1470,12 +1550,14 @@ const askReporting: AreaAsker = async (ui, a) => {
1470
1550
  const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday);
1471
1551
  const start = await askValid(
1472
1552
  ui,
1553
+ "availability-start",
1473
1554
  "Availability starts (24h HH:MM)",
1474
1555
  a.availability?.start ?? "09:00",
1475
1556
  (value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."),
1476
1557
  );
1477
1558
  const end = await askValid(
1478
1559
  ui,
1560
+ "availability-end",
1479
1561
  "Availability ends (24h HH:MM)",
1480
1562
  a.availability?.end ?? "17:00",
1481
1563
  (value) =>
@@ -1487,6 +1569,7 @@ const askReporting: AreaAsker = async (ui, a) => {
1487
1569
  );
1488
1570
  const bypassText = await askValid(
1489
1571
  ui,
1572
+ "quiet-hours-bypass",
1490
1573
  `Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`,
1491
1574
  a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none",
1492
1575
  (value) => {
@@ -1634,7 +1717,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Cap
1634
1717
  description: "full interview for a new project; existing projects stay as they are",
1635
1718
  },
1636
1719
  ],
1637
- { initialIndex: 0 },
1720
+ { key: `existing-project-action.${prior.name}`, initialIndex: 0 },
1638
1721
  );
1639
1722
  if (mode === undefined) throw new Cancelled();
1640
1723
  if (mode === ADD_PROJECT) return { kind: "add-project" };
@@ -1650,7 +1733,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Cap
1650
1733
  const picked = await ui.select(
1651
1734
  "Which area? Each row shows what it says now",
1652
1735
  choices.map((c) => ({ label: c.label, description: c.description })),
1653
- { initialIndex: 0 },
1736
+ { key: `amend-area.${prior.name}`, initialIndex: 0 },
1654
1737
  );
1655
1738
  if (picked === undefined) throw new Cancelled();
1656
1739
 
@@ -1693,6 +1776,7 @@ async function collectAnswers(
1693
1776
 
1694
1777
  const projectName = await askValid(
1695
1778
  ui,
1779
+ "project-name",
1696
1780
  "Project name",
1697
1781
  projectArg ?? seed.projectName,
1698
1782
  (v) => (v.length > 0 ? undefined : "A name is required — it is how `omp-conductor status --project <name>` finds this project."),
@@ -1821,7 +1905,7 @@ export async function collectSetup(
1821
1905
  description: "overwrites this project's config entry on apply; other projects untouched",
1822
1906
  },
1823
1907
  ],
1824
- { initialIndex: 0 },
1908
+ { key: `duplicate-project-action.${answers.projectName}`, initialIndex: 0 },
1825
1909
  );
1826
1910
  if (mode === undefined) throw new Cancelled();
1827
1911
  if (mode.startsWith("Amend")) {
@@ -1866,7 +1950,7 @@ export async function collectSetup(
1866
1950
  description: "overwrites this project's config entry on apply; other projects untouched",
1867
1951
  },
1868
1952
  ],
1869
- { initialIndex: 0 },
1953
+ { key: `duplicate-project-action.${answers.projectName}`, initialIndex: 0 },
1870
1954
  );
1871
1955
  if (mode === undefined) throw new Cancelled();
1872
1956
  if (mode.startsWith("Amend")) {
@@ -1884,6 +1968,7 @@ export async function collectSetup(
1884
1968
  "Walks every question again and overwrites this project's config entry on apply. " +
1885
1969
  "Other projects are left alone. Cancel and pick \"Change one area\" to amend without a full replace, " +
1886
1970
  "or \"Add another project\" to create a neighbour.",
1971
+ { key: `replace-project.${prior.name}` },
1887
1972
  );
1888
1973
  if (replace !== true) throw new Cancelled();
1889
1974
  return { answers: await collectAnswers(ui, prior, projectArg, probes) };
@@ -1990,6 +2075,7 @@ async function offerCodeGraph(
1990
2075
  "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
1991
2076
  "indexing run so the first fetch happens while you watch. Minutes per repo. " +
1992
2077
  "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
2078
+ { key: `offer-code-graph.${project.name}` },
1993
2079
  );
1994
2080
  if (wantsGraph === true) await graphInstall(project, ui);
1995
2081
  else
@@ -2202,7 +2288,7 @@ export async function setup(
2202
2288
  amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
2203
2289
  REVIEW_CHOICES,
2204
2290
  // Bare Enter lands on Review — the one row with no effect.
2205
- { initialIndex: 2 },
2291
+ { key: "setup-review", initialIndex: 2 },
2206
2292
  );
2207
2293
  if (choice === undefined) {
2208
2294
  ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info");
@@ -2229,7 +2315,7 @@ export async function setup(
2229
2315
  const pickedArea = await ui.select(
2230
2316
  "Edit which area?",
2231
2317
  INTERVIEW_AREAS.map((area) => ({ label: area.label })),
2232
- { initialIndex: 0 },
2318
+ { key: "setup-edit-area", initialIndex: 0 },
2233
2319
  );
2234
2320
  if (pickedArea === undefined) {
2235
2321
  ui.notify("Editing cancelled — the answers stand as they were.", "info");
@@ -2270,12 +2356,16 @@ export async function setup(
2270
2356
  // last re-derivation: the loop above can only break here on a final "Apply",
2271
2357
  // so this one-writer sequence runs exactly once per setup run.
2272
2358
  prepareConductor(plan.project.name);
2273
- const created = await apply.createLabels(answers.trackerRepo, plan.labels);
2359
+ const created = await withProgress("Creating tracker labels", "Tracker labels ready", () =>
2360
+ apply.createLabels(answers.trackerRepo, plan.labels),
2361
+ );
2274
2362
  saveConfig(plan.nextConfig);
2275
2363
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
2276
2364
  const runtimeFiles = writeHostRuntime(plan.runtime);
2277
2365
  for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
2278
- const smoke = await apply.smoke(plan.project.name);
2366
+ const smoke = await withProgress("Running setup smoke", "Setup smoke passed", () =>
2367
+ apply.smoke(plan.project.name),
2368
+ );
2279
2369
  let smokeLine =
2280
2370
  `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
2281
2371
  `stored status for ${smoke.status.project}`;
@@ -2392,6 +2482,7 @@ export async function setup(
2392
2482
  `${plan.runtime.installedAction === "create" ? "Installs" : "Updates"} ${plan.runtime.installedPath} from ` +
2393
2483
  `${plan.runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
2394
2484
  "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
2485
+ { key: `install-daemon.${plan.project.name}` },
2395
2486
  );
2396
2487
  if (install === true)
2397
2488
  await apply.hostInstall(