omp-conductor 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -30,12 +30,17 @@ import {
30
30
  type UpgradeScope,
31
31
  } from "./upgrade.ts";
32
32
  import {
33
+ graphConflictMessage,
33
34
  graphRepos,
34
35
  formatGraphSetup,
36
+ legacyReindexFiles,
37
+ legacyReindexNote,
35
38
  mcpEntry,
39
+ planGraphSetup,
36
40
  reindexScriptPath,
41
+ reindexUnitName,
37
42
  resolvePrereqs,
38
- REINDEX_UNIT,
43
+ stagingConflict,
39
44
  unitPaths,
40
45
  writeGraphSetup,
41
46
  type GraphPrereqs,
@@ -47,6 +52,8 @@ import {
47
52
  agentRenameVerdict,
48
53
  checkEscalation,
49
54
  DEFAULT_AGENT_RENAME_DEPS,
55
+ defaultIdentityProbes,
56
+ defaultServiceRuntime,
50
57
  planHostRuntime,
51
58
  totalConfiguredWorkers,
52
59
  writeHostRuntime,
@@ -56,6 +63,7 @@ import {
56
63
  type AgentRenameDeps,
57
64
  type EscalationDeps,
58
65
  type ServiceRuntime,
66
+ type WorkerIdentityProbes,
59
67
  } from "./setup-host.ts";
60
68
  import type { WizardUi } from "./wizard-ui.ts";
61
69
  import type { Caps, ProjectConfig, Store } from "./types.ts";
@@ -76,6 +84,14 @@ export interface InstallDeps {
76
84
  escalation?: EscalationDeps;
77
85
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
78
86
  platform?: () => string;
87
+ /**
88
+ * The worker identity plan's probes (#798). `undefined` means the real
89
+ * host's read-only facts — production: every `setup host` installs and
90
+ * grants the dedicated worker account. `null` disables the identity plan
91
+ * entirely (the historical step list, for tests pinning it), and an object
92
+ * is used as given (tests pinning a specific host state).
93
+ */
94
+ workerIdentityProbes?: WorkerIdentityProbes | null;
79
95
  unitDir?: string;
80
96
  /**
81
97
  * Where the recovery playbook installs. Injectable so the idempotency gate
@@ -131,16 +147,18 @@ function linuxOnly(deps: InstallDeps): string | undefined {
131
147
  }
132
148
 
133
149
  /**
134
- * Whether two files hold identical bytes. The "keep" side of setup-host.ts's
135
- * `actionFor`: a unit systemd already reads that matches what would be written
136
- * needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
137
- * read error is a mismatch, never silently "matches", because the whole point
138
- * is to make the plan only claim a unit install is redundant when it can see
139
- * the installed copy.
150
+ * Whether a file holds exactly the rendered bytes. The "keep" side of
151
+ * setup-host.ts's `actionFor`: a unit systemd already reads that matches what
152
+ * would be written needs no reinstall. Reads can fail (permissions, vanished
153
+ * mid-flight) — a read error is a mismatch, never silently "matches", because
154
+ * the whole point is to make the plan only claim a unit install is redundant
155
+ * when it can see the installed copy. Comparing against the rendered content
156
+ * rather than the staged file keeps the comparison read-only: staging now
157
+ * happens only after consent (#720), so nothing may be written to compare.
140
158
  */
141
- function sameFile(a: string, b: string): boolean {
159
+ function sameContent(path: string, content: string): boolean {
142
160
  try {
143
- return readFileSync(a, "utf8") === readFileSync(b, "utf8");
161
+ return readFileSync(path, "utf8") === content;
144
162
  } catch {
145
163
  return false;
146
164
  }
@@ -181,6 +199,13 @@ export async function runHostInstall(
181
199
  // happens only after consent (#510), so the prompt's "Nothing has been run
182
200
  // yet" is true of the staged tree when it is printed, and a declined run
183
201
  // leaves every staged file byte-identical.
202
+ // The worker identity plan rides the same consent (#798): production probes
203
+ // the real host (account present? grants current?); a test may pass its own
204
+ // probes, or `null` to plan the historical step list only.
205
+ const identityProbes: WorkerIdentityProbes | null =
206
+ deps.workerIdentityProbes === null
207
+ ? null
208
+ : deps.workerIdentityProbes ?? defaultIdentityProbes(deps.runtime ?? defaultServiceRuntime(telegramStateDir));
184
209
  const plan = planHostRuntime(
185
210
  project,
186
211
  caps,
@@ -190,6 +215,7 @@ export async function runHostInstall(
190
215
  unitDir,
191
216
  deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
192
217
  deps.multiProject ?? hostMultiProject(),
218
+ identityProbes,
193
219
  );
194
220
  // No pane-shell key is planned (unusable login shell, unparseable config):
195
221
  // the operator hears why before the consent prompt, not after an install
@@ -291,6 +317,15 @@ export async function runHostInstall(
291
317
  ]),
292
318
  ]),
293
319
  "The unit runs as the account that staged it; nothing here changes that.",
320
+ ...(plan.workerIdentity === undefined
321
+ ? []
322
+ : [
323
+ `Establishes the dedicated worker identity ${plan.workerIdentity.account}: every worker session runs under it, ` +
324
+ "never under the daemon's account, and it is granted search/read access to exactly the fleet paths a worker needs.",
325
+ ...(plan.workerIdentity.current
326
+ ? ["The worker identity is already in place — nothing to change."]
327
+ : plan.workerIdentity.pending),
328
+ ]),
294
329
  "",
295
330
  liveWorkers === 0
296
331
  ? "No workers are live — nothing to drain before the restart."
@@ -450,9 +485,10 @@ export interface GraphInstallOptions extends InstallDeps {
450
485
  * Staging and enabling alone installs a service that fails on every run: the
451
486
  * generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
452
487
  * its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
453
- * than a graph. That is why `writeGraphSetup` already refused to call the old
454
- * install a finished job. So: prerequisites, then clones, then install, then
455
- * seed and verify — one preview, one confirm.
488
+ * than a graph. That is why staging was never allowed to call the install a
489
+ * finished job. So: prerequisites, then a conflict check, then clones, then
490
+ * install, then seed and verify — one preview, one confirm, and nothing
491
+ * staged before the confirm (#720).
456
492
  */
457
493
  export async function runGraphInstall(
458
494
  project: ProjectConfig,
@@ -494,7 +530,38 @@ export async function runGraphInstall(
494
530
  return { kind: "refused", reason: missing };
495
531
  }
496
532
 
497
- const staged = writeGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR);
533
+ const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR;
534
+ // The plan is computed eagerly — `planGraphSetup` renders every byte, so the
535
+ // prompt can print exactly what would be written and the unitsCurrent gate
536
+ // below can decide a re-run installs nothing. But rendering is read-only: no
537
+ // file is written here. Staging happens only after consent (#720), so the
538
+ // prompt's "stages once you confirm" is true of the staged tree when it is
539
+ // printed, and a declined run leaves every staged file byte-identical.
540
+ const plan = planGraphSetup(project, unitDir);
541
+ // Never overwrite another project's staged artefact: the stems derive from
542
+ // the project name, and two names can fold to one stem ("My Project" vs
543
+ // "my_project"), so an existing file's generated-for marker is checked
544
+ // before the consent prompt — a collision is refused before anything is
545
+ // asked, let alone written (#720).
546
+ const conflict = stagingConflict(project, [
547
+ plan.script.path,
548
+ plan.service.path,
549
+ plan.timer.path,
550
+ ...Object.values(unitPaths(project, unitDir)),
551
+ ]);
552
+ if (conflict !== undefined) {
553
+ const message = graphConflictMessage(conflict);
554
+ ui.notify(message, "error");
555
+ return { kind: "refused", reason: message };
556
+ }
557
+ // A host that ran the pre-#720 project-less version keeps its old files:
558
+ // their timer refreshes whichever project generated it last, forever. Name
559
+ // the remediation before the consent prompt — deleting files outside this
560
+ // command's own names is exactly how the overwrite happened in the first
561
+ // place, so it is host action, not install action.
562
+ const legacy = legacyReindexFiles();
563
+ if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning");
564
+
498
565
  const absent = repos.filter((r) => !existsSync(r.graphProject));
499
566
 
500
567
  // 2. Missing clones, as the operator. A root-owned index-only clone under the
@@ -507,17 +574,18 @@ export async function runGraphInstall(
507
574
  }));
508
575
 
509
576
  const blocked = linuxOnly(options);
510
- const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
511
- const from = unitPaths(stateDir());
512
- // The units systemd already reads, compared to what staging just wrote — the
577
+ const stem = reindexUnitName(project);
578
+ const { service, timer } = unitPaths(project, unitDir);
579
+ // The units systemd already reads, compared to the rendered content — the
513
580
  // same distinction setup-host.ts draws between `installedAction` and
514
581
  // `service.action` (read the installed file, compare to the fresh content,
515
582
  // "keep" vs "update"). "Installed" is not enough: a green timer serving a
516
583
  // stale unit is the older bug, so when they differ the install runs. Only
517
584
  // when both units already match does the plan omit them — then just the
518
- // outstanding clone and seed remain.
585
+ // outstanding clone and seed remain. Nothing is written to compare: staging
586
+ // is post-consent now, so the comparison is against the rendered bytes.
519
587
  const unitsCurrent =
520
- blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
588
+ blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content);
521
589
  // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
522
590
  // one preview and one confirm, and a second confirm here also invented a
523
591
  // third outcome — a declined seed — that neither the caller nor the
@@ -525,32 +593,52 @@ export async function runGraphInstall(
525
593
  const seed: PrivilegedStep[] =
526
594
  options.noSeed === true
527
595
  ? []
528
- : [{ title: `seed the indexes (runs ${REINDEX_UNIT}.service once, minutes per repo)`, argv: ["systemctl", "start", `${REINDEX_UNIT}.service`] }];
596
+ : [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }];
529
597
  const install: PrivilegedStep[] =
530
598
  blocked === undefined
531
599
  ? [
532
600
  ...(unitsCurrent
533
601
  ? []
534
602
  : [
535
- { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
603
+ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] },
536
604
  { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
537
- { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
605
+ { title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] },
538
606
  ]),
539
607
  ...seed,
540
608
  ]
541
609
  : [];
542
610
 
543
611
  if (blocked !== undefined) {
612
+ // A non-Linux host cannot run systemd, so staging IS the deliverable: the
613
+ // rendered files are written for the operator to copy to the box that will
614
+ // run them, and only the systemctl half is refused. There is no consent
615
+ // prompt on this path — nothing privileged to approve — so the write lands
616
+ // here, not in the (never reached) post-consent hook (#720, as #510).
617
+ const staged = writeGraphSetup(project, unitDir);
544
618
  ui.notify(`${blocked} ${service} and ${timer}`, "warning");
545
619
  return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
546
620
  }
547
621
 
622
+ // What the post-consent staging actually wrote, surfaced by the outcomes
623
+ // that report it. Empty on a decline — the hook never ran.
624
+ let wrote: readonly string[] = [];
625
+ const beforeRun = async (): Promise<void> => {
626
+ // Consent has been given; this project's files may now land. Staging here —
627
+ // post-confirm, pre-step, under the same confirm that authorised the batch
628
+ // — is what makes the prompt's plan true of the staged tree when it is
629
+ // printed: a declined prompt never reaches this hook, so nothing is staged
630
+ // and a re-run still plans an install (#720). `writeGraphSetup`'s ownership
631
+ // backstop throws here with nothing written if a collision raced the
632
+ // pre-consent check.
633
+ wrote = writeGraphSetup(project, unitDir).written;
634
+ };
635
+
548
636
  const outcome = await runPrivileged([...clones, ...install], ui, {
549
637
  ...(options.privileged === undefined ? {} : { deps: options.privileged }),
550
638
  title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
551
639
  answerKey: `install-code-graph.${project.name}`,
552
640
  preamble: [
553
- `Staged: ${staged.written.join(", ")}.`,
641
+ `Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`,
554
642
  ...(clones.length === 0
555
643
  ? ["Every indexed clone already exists."]
556
644
  : [
@@ -561,8 +649,9 @@ export async function runGraphInstall(
561
649
  ...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
562
650
  `Indexer: ${prereqs.indexer ?? "on PATH"}.`,
563
651
  ],
652
+ beforeRun,
564
653
  });
565
- if (outcome.kind === "declined") return { kind: "declined", wrote: staged.written };
654
+ if (outcome.kind === "declined") return { kind: "declined", wrote };
566
655
  if (outcome.kind === "failed") {
567
656
  return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
568
657
  }
@@ -571,10 +660,10 @@ export async function runGraphInstall(
571
660
  // answer: an unseeded graph is not usable until the timer first fires.
572
661
  if (options.noSeed === true) {
573
662
  ui.notify(
574
- `Timer enabled; skipped the seeding run. The graph is NOT usable until ${REINDEX_UNIT}.timer first fires.`,
663
+ `Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`,
575
664
  "warning",
576
665
  );
577
- return { kind: "installed", wrote: staged.written };
666
+ return { kind: "installed", wrote };
578
667
  }
579
668
 
580
669
  const health = await (options.probe ?? probeCodeGraph)(project);
@@ -583,13 +672,13 @@ export async function runGraphInstall(
583
672
  // Staged but not trusted. Reporting success here is how an operator learns
584
673
  // months later that no worker ever read an index.
585
674
  ui.notify(
586
- [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", staged.next].join("\n"),
675
+ [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"),
587
676
  "error",
588
677
  );
589
678
  return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
590
679
  }
591
680
  ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
592
- return { kind: "installed", wrote: staged.written };
681
+ return { kind: "installed", wrote };
593
682
  }
594
683
 
595
684
  /** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
@@ -618,7 +707,7 @@ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
618
707
  return graphRepos(project);
619
708
  }
620
709
 
621
- /** Where the reindex script lands, for the wizard's tail to name. */
622
- export function reindexScriptLocation(): string {
623
- return reindexScriptPath();
710
+ /** Where this project's reindex script lands, for the wizard's tail to name. */
711
+ export function reindexScriptLocation(project: ProjectConfig): string {
712
+ return reindexScriptPath(project);
624
713
  }
@@ -93,6 +93,7 @@ import {
93
93
  POLICY_BRIEF_NAME,
94
94
  RELEASE_REQUIREMENT_CHOICES,
95
95
  REPORT_SCOPE_CHOICES,
96
+ REVIEW_STRICTNESS_CHOICES,
96
97
  SETUP_DEFAULTS,
97
98
  amendChoices,
98
99
  answersFromProject,
@@ -125,11 +126,16 @@ import {
125
126
  BASE_FRESHNESS,
126
127
  BEHIND_BASE_ACTIONS,
127
128
  DEFAULT_CAPS,
129
+ DEFAULT_REVIEW_MAX_ROUNDS,
130
+ DEFAULT_REVIEW_STRICTNESS,
128
131
  DENIED_RELEASE_GRANTS,
129
132
  DRAFT_POLICIES,
130
133
  RELEASE_REQUIREMENTS,
131
134
  INTERRUPT_CATEGORIES,
132
135
  RELEASE_SHAPES,
136
+ REVIEW_MAX_ROUNDS_MAX,
137
+ REVIEW_MAX_ROUNDS_MIN,
138
+ REVIEW_STRICTNESS,
133
139
  WEEKDAYS,
134
140
  type AuthorityHolder,
135
141
  type Caps,
@@ -142,6 +148,7 @@ import {
142
148
  type ReleaseRequirement,
143
149
  type ReportScopeChoice,
144
150
  type ResolvedGrants,
151
+ type ReviewPolicy,
145
152
  } from "./types.ts";
146
153
  import { withProgress } from "./ui/progress.ts";
147
154
  import type { WizardUi } from "./wizard-ui.ts";
@@ -972,6 +979,7 @@ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAns
972
979
  inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
973
980
  blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
974
981
  failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
982
+ backlog: labels.get(seed.stateLabels.backlog.toLowerCase()) ?? seed.stateLabels.backlog,
975
983
  },
976
984
  targetRepos,
977
985
  policy: {
@@ -1087,19 +1095,28 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
1087
1095
 
1088
1096
  // One confirm instead of three prompts: the namespaced defaults are right for
1089
1097
  // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
1090
- // its reputation.
1098
+ // its reputation. The park label is in the same confirm, with its ownership
1099
+ // stated — it is not conductor-written, but it has to exist for the operator
1100
+ // to have the gesture (#507).
1091
1101
  const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
1092
1102
  const customiseStates = await askYesNo(
1093
1103
  ui,
1094
1104
  "customise-state-labels",
1095
1105
  "State labels",
1096
1106
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
1097
- `"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
1107
+ `"${stateLabels.failed}" so the tracker alone shows live state; "${stateLabels.backlog}" is ` +
1108
+ `yours — the conductor never writes it, but an issue carrying it is never claimed. Rename them?`,
1098
1109
  );
1099
1110
  if (customiseStates) {
1100
1111
  stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress);
1101
1112
  stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked);
1102
1113
  stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed);
1114
+ stateLabels.backlog = await ask(
1115
+ ui,
1116
+ "state-label-backlog",
1117
+ "Label the operator applies to park an issue (the conductor never writes it)",
1118
+ stateLabels.backlog,
1119
+ );
1103
1120
  }
1104
1121
 
1105
1122
  const routingLabelPrefix = await ask(
@@ -1379,6 +1396,73 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1379
1396
  * and `claim-only` means anything that can invoke it can start dispatch once
1380
1397
  * the live claim and poller pass.
1381
1398
  */
1399
+ /**
1400
+ * How many review rounds one PR lifecycle may be returned at most — the hard
1401
+ * bound against endless polishing (#678). Validated in the dialog against the
1402
+ * same integer range the loader validates against, so an out-of-range answer
1403
+ * is re-asked rather than written for the daemon to reject. The consequence of
1404
+ * the ceiling is stated in the title: at it, the PR is left open and escalated
1405
+ * once, never returned again.
1406
+ */
1407
+ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1408
+ // Bounded like askValid above: a dialog that cannot be escaped is worse than
1409
+ // one that gives up and leaves the config alone. The `--answers` path
1410
+ // matters here specifically — answersUi is a map, not a consumer, so every
1411
+ // retry reads the same value and an out-of-range file used to recurse
1412
+ // forever instead of failing closed (#678).
1413
+ for (let attempt = 0; attempt < 3; attempt++) {
1414
+ const raw = await ask(
1415
+ ui,
1416
+ "review-max-rounds",
1417
+ `Review rounds per PR lifecycle — an integer ${REVIEW_MAX_ROUNDS_MIN} to ${REVIEW_MAX_ROUNDS_MAX}; ` +
1418
+ `at the ceiling the PR is left open and escalated once`,
1419
+ String(current),
1420
+ );
1421
+ const value = Number(raw);
1422
+ if (Number.isInteger(value) && value >= REVIEW_MAX_ROUNDS_MIN && value <= REVIEW_MAX_ROUNDS_MAX) {
1423
+ return value;
1424
+ }
1425
+ ui.notify(
1426
+ `Review rounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX} ` +
1427
+ `— shown the current value again.`,
1428
+ "warning",
1429
+ );
1430
+ }
1431
+ throw new Cancelled();
1432
+ }
1433
+
1434
+ /**
1435
+ * How green PRs are reviewed (#678), asked with the merge preconditions and
1436
+ * the arming proof: they are the same kind of declared policy — a typed,
1437
+ * mechanical boundary on what may happen to a PR — so they ride the same area.
1438
+ *
1439
+ * The three levels and their bars are shown before the select, with the
1440
+ * recommended level marked, so an operator chooses between named thresholds
1441
+ * rather than between three words. The select cursor opens on the current
1442
+ * answer (the configured level on a re-run, the recommended default on a
1443
+ * first run); the rounds are a validated integer within the same range the
1444
+ * loader enforces.
1445
+ */
1446
+ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
1447
+ ui.notify(
1448
+ REVIEW_STRICTNESS.map((level) => `${level}${level === DEFAULT_REVIEW_STRICTNESS ? " (recommended)" : ""} — ${REVIEW_STRICTNESS_CHOICES[level]}`).join(
1449
+ "\n",
1450
+ ),
1451
+ "info",
1452
+ );
1453
+ return {
1454
+ strictness: await askLiteral(
1455
+ ui,
1456
+ "review-strictness",
1457
+ "Review strictness for green PRs",
1458
+ REVIEW_STRICTNESS,
1459
+ REVIEW_STRICTNESS_CHOICES,
1460
+ a.review.strictness,
1461
+ ),
1462
+ maxRounds: await askReviewRounds(ui, a.review.maxRounds),
1463
+ };
1464
+ }
1465
+
1382
1466
  const askPolicy: AreaAsker = async (ui, a) => ({
1383
1467
  ...a,
1384
1468
  policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
@@ -1390,6 +1474,7 @@ const askPolicy: AreaAsker = async (ui, a) => ({
1390
1474
  ARM_PROOF_CHOICES,
1391
1475
  a.armProof,
1392
1476
  ),
1477
+ review: await askReviewPolicy(ui, a),
1393
1478
  });
1394
1479
 
1395
1480
  /** How a stuck run reaches a human, and who triages it when it does. */
@@ -2441,6 +2526,7 @@ function captureInventory(
2441
2526
  };
2442
2527
  captureIfWritten(plan.runtime.service);
2443
2528
  captureIfWritten(plan.runtime.herdrUnit);
2529
+ captureIfWritten(plan.runtime.harnessMount);
2444
2530
  captureIfWritten(plan.runtime.herdrConfig);
2445
2531
  captureIfWritten(plan.runtime.herdrEnv);
2446
2532
  captureIfWritten(plan.runtime.recoverUnit);