omp-conductor 0.17.1 → 0.18.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.
@@ -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,
@@ -131,16 +136,18 @@ function linuxOnly(deps: InstallDeps): string | undefined {
131
136
  }
132
137
 
133
138
  /**
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.
139
+ * Whether a file holds exactly the rendered bytes. The "keep" side of
140
+ * setup-host.ts's `actionFor`: a unit systemd already reads that matches what
141
+ * would be written needs no reinstall. Reads can fail (permissions, vanished
142
+ * mid-flight) — a read error is a mismatch, never silently "matches", because
143
+ * the whole point is to make the plan only claim a unit install is redundant
144
+ * when it can see the installed copy. Comparing against the rendered content
145
+ * rather than the staged file keeps the comparison read-only: staging now
146
+ * happens only after consent (#720), so nothing may be written to compare.
140
147
  */
141
- function sameFile(a: string, b: string): boolean {
148
+ function sameContent(path: string, content: string): boolean {
142
149
  try {
143
- return readFileSync(a, "utf8") === readFileSync(b, "utf8");
150
+ return readFileSync(path, "utf8") === content;
144
151
  } catch {
145
152
  return false;
146
153
  }
@@ -450,9 +457,10 @@ export interface GraphInstallOptions extends InstallDeps {
450
457
  * Staging and enabling alone installs a service that fails on every run: the
451
458
  * generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
452
459
  * 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.
460
+ * than a graph. That is why staging was never allowed to call the install a
461
+ * finished job. So: prerequisites, then a conflict check, then clones, then
462
+ * install, then seed and verify — one preview, one confirm, and nothing
463
+ * staged before the confirm (#720).
456
464
  */
457
465
  export async function runGraphInstall(
458
466
  project: ProjectConfig,
@@ -494,7 +502,38 @@ export async function runGraphInstall(
494
502
  return { kind: "refused", reason: missing };
495
503
  }
496
504
 
497
- const staged = writeGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR);
505
+ const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR;
506
+ // The plan is computed eagerly — `planGraphSetup` renders every byte, so the
507
+ // prompt can print exactly what would be written and the unitsCurrent gate
508
+ // below can decide a re-run installs nothing. But rendering is read-only: no
509
+ // file is written here. Staging happens only after consent (#720), so the
510
+ // prompt's "stages once you confirm" is true of the staged tree when it is
511
+ // printed, and a declined run leaves every staged file byte-identical.
512
+ const plan = planGraphSetup(project, unitDir);
513
+ // Never overwrite another project's staged artefact: the stems derive from
514
+ // the project name, and two names can fold to one stem ("My Project" vs
515
+ // "my_project"), so an existing file's generated-for marker is checked
516
+ // before the consent prompt — a collision is refused before anything is
517
+ // asked, let alone written (#720).
518
+ const conflict = stagingConflict(project, [
519
+ plan.script.path,
520
+ plan.service.path,
521
+ plan.timer.path,
522
+ ...Object.values(unitPaths(project, unitDir)),
523
+ ]);
524
+ if (conflict !== undefined) {
525
+ const message = graphConflictMessage(conflict);
526
+ ui.notify(message, "error");
527
+ return { kind: "refused", reason: message };
528
+ }
529
+ // A host that ran the pre-#720 project-less version keeps its old files:
530
+ // their timer refreshes whichever project generated it last, forever. Name
531
+ // the remediation before the consent prompt — deleting files outside this
532
+ // command's own names is exactly how the overwrite happened in the first
533
+ // place, so it is host action, not install action.
534
+ const legacy = legacyReindexFiles();
535
+ if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning");
536
+
498
537
  const absent = repos.filter((r) => !existsSync(r.graphProject));
499
538
 
500
539
  // 2. Missing clones, as the operator. A root-owned index-only clone under the
@@ -507,17 +546,18 @@ export async function runGraphInstall(
507
546
  }));
508
547
 
509
548
  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
549
+ const stem = reindexUnitName(project);
550
+ const { service, timer } = unitPaths(project, unitDir);
551
+ // The units systemd already reads, compared to the rendered content — the
513
552
  // same distinction setup-host.ts draws between `installedAction` and
514
553
  // `service.action` (read the installed file, compare to the fresh content,
515
554
  // "keep" vs "update"). "Installed" is not enough: a green timer serving a
516
555
  // stale unit is the older bug, so when they differ the install runs. Only
517
556
  // when both units already match does the plan omit them — then just the
518
- // outstanding clone and seed remain.
557
+ // outstanding clone and seed remain. Nothing is written to compare: staging
558
+ // is post-consent now, so the comparison is against the rendered bytes.
519
559
  const unitsCurrent =
520
- blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
560
+ blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content);
521
561
  // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
522
562
  // one preview and one confirm, and a second confirm here also invented a
523
563
  // third outcome — a declined seed — that neither the caller nor the
@@ -525,32 +565,52 @@ export async function runGraphInstall(
525
565
  const seed: PrivilegedStep[] =
526
566
  options.noSeed === true
527
567
  ? []
528
- : [{ title: `seed the indexes (runs ${REINDEX_UNIT}.service once, minutes per repo)`, argv: ["systemctl", "start", `${REINDEX_UNIT}.service`] }];
568
+ : [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }];
529
569
  const install: PrivilegedStep[] =
530
570
  blocked === undefined
531
571
  ? [
532
572
  ...(unitsCurrent
533
573
  ? []
534
574
  : [
535
- { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
575
+ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] },
536
576
  { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
537
- { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
577
+ { title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] },
538
578
  ]),
539
579
  ...seed,
540
580
  ]
541
581
  : [];
542
582
 
543
583
  if (blocked !== undefined) {
584
+ // A non-Linux host cannot run systemd, so staging IS the deliverable: the
585
+ // rendered files are written for the operator to copy to the box that will
586
+ // run them, and only the systemctl half is refused. There is no consent
587
+ // prompt on this path — nothing privileged to approve — so the write lands
588
+ // here, not in the (never reached) post-consent hook (#720, as #510).
589
+ const staged = writeGraphSetup(project, unitDir);
544
590
  ui.notify(`${blocked} ${service} and ${timer}`, "warning");
545
591
  return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
546
592
  }
547
593
 
594
+ // What the post-consent staging actually wrote, surfaced by the outcomes
595
+ // that report it. Empty on a decline — the hook never ran.
596
+ let wrote: readonly string[] = [];
597
+ const beforeRun = async (): Promise<void> => {
598
+ // Consent has been given; this project's files may now land. Staging here —
599
+ // post-confirm, pre-step, under the same confirm that authorised the batch
600
+ // — is what makes the prompt's plan true of the staged tree when it is
601
+ // printed: a declined prompt never reaches this hook, so nothing is staged
602
+ // and a re-run still plans an install (#720). `writeGraphSetup`'s ownership
603
+ // backstop throws here with nothing written if a collision raced the
604
+ // pre-consent check.
605
+ wrote = writeGraphSetup(project, unitDir).written;
606
+ };
607
+
548
608
  const outcome = await runPrivileged([...clones, ...install], ui, {
549
609
  ...(options.privileged === undefined ? {} : { deps: options.privileged }),
550
610
  title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
551
611
  answerKey: `install-code-graph.${project.name}`,
552
612
  preamble: [
553
- `Staged: ${staged.written.join(", ")}.`,
613
+ `Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`,
554
614
  ...(clones.length === 0
555
615
  ? ["Every indexed clone already exists."]
556
616
  : [
@@ -561,8 +621,9 @@ export async function runGraphInstall(
561
621
  ...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
562
622
  `Indexer: ${prereqs.indexer ?? "on PATH"}.`,
563
623
  ],
624
+ beforeRun,
564
625
  });
565
- if (outcome.kind === "declined") return { kind: "declined", wrote: staged.written };
626
+ if (outcome.kind === "declined") return { kind: "declined", wrote };
566
627
  if (outcome.kind === "failed") {
567
628
  return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
568
629
  }
@@ -571,10 +632,10 @@ export async function runGraphInstall(
571
632
  // answer: an unseeded graph is not usable until the timer first fires.
572
633
  if (options.noSeed === true) {
573
634
  ui.notify(
574
- `Timer enabled; skipped the seeding run. The graph is NOT usable until ${REINDEX_UNIT}.timer first fires.`,
635
+ `Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`,
575
636
  "warning",
576
637
  );
577
- return { kind: "installed", wrote: staged.written };
638
+ return { kind: "installed", wrote };
578
639
  }
579
640
 
580
641
  const health = await (options.probe ?? probeCodeGraph)(project);
@@ -583,13 +644,13 @@ export async function runGraphInstall(
583
644
  // Staged but not trusted. Reporting success here is how an operator learns
584
645
  // months later that no worker ever read an index.
585
646
  ui.notify(
586
- [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", staged.next].join("\n"),
647
+ [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"),
587
648
  "error",
588
649
  );
589
650
  return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
590
651
  }
591
652
  ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
592
- return { kind: "installed", wrote: staged.written };
653
+ return { kind: "installed", wrote };
593
654
  }
594
655
 
595
656
  /** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
@@ -618,7 +679,7 @@ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
618
679
  return graphRepos(project);
619
680
  }
620
681
 
621
- /** Where the reindex script lands, for the wizard's tail to name. */
622
- export function reindexScriptLocation(): string {
623
- return reindexScriptPath();
682
+ /** Where this project's reindex script lands, for the wizard's tail to name. */
683
+ export function reindexScriptLocation(project: ProjectConfig): string {
684
+ return reindexScriptPath(project);
624
685
  }
@@ -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";
@@ -1379,6 +1386,73 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
1379
1386
  * and `claim-only` means anything that can invoke it can start dispatch once
1380
1387
  * the live claim and poller pass.
1381
1388
  */
1389
+ /**
1390
+ * How many review rounds one PR lifecycle may be returned at most — the hard
1391
+ * bound against endless polishing (#678). Validated in the dialog against the
1392
+ * same integer range the loader validates against, so an out-of-range answer
1393
+ * is re-asked rather than written for the daemon to reject. The consequence of
1394
+ * the ceiling is stated in the title: at it, the PR is left open and escalated
1395
+ * once, never returned again.
1396
+ */
1397
+ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1398
+ // Bounded like askValid above: a dialog that cannot be escaped is worse than
1399
+ // one that gives up and leaves the config alone. The `--answers` path
1400
+ // matters here specifically — answersUi is a map, not a consumer, so every
1401
+ // retry reads the same value and an out-of-range file used to recurse
1402
+ // forever instead of failing closed (#678).
1403
+ for (let attempt = 0; attempt < 3; attempt++) {
1404
+ const raw = await ask(
1405
+ ui,
1406
+ "review-max-rounds",
1407
+ `Review rounds per PR lifecycle — an integer ${REVIEW_MAX_ROUNDS_MIN} to ${REVIEW_MAX_ROUNDS_MAX}; ` +
1408
+ `at the ceiling the PR is left open and escalated once`,
1409
+ String(current),
1410
+ );
1411
+ const value = Number(raw);
1412
+ if (Number.isInteger(value) && value >= REVIEW_MAX_ROUNDS_MIN && value <= REVIEW_MAX_ROUNDS_MAX) {
1413
+ return value;
1414
+ }
1415
+ ui.notify(
1416
+ `Review rounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX} ` +
1417
+ `— shown the current value again.`,
1418
+ "warning",
1419
+ );
1420
+ }
1421
+ throw new Cancelled();
1422
+ }
1423
+
1424
+ /**
1425
+ * How green PRs are reviewed (#678), asked with the merge preconditions and
1426
+ * the arming proof: they are the same kind of declared policy — a typed,
1427
+ * mechanical boundary on what may happen to a PR — so they ride the same area.
1428
+ *
1429
+ * The three levels and their bars are shown before the select, with the
1430
+ * recommended level marked, so an operator chooses between named thresholds
1431
+ * rather than between three words. The select cursor opens on the current
1432
+ * answer (the configured level on a re-run, the recommended default on a
1433
+ * first run); the rounds are a validated integer within the same range the
1434
+ * loader enforces.
1435
+ */
1436
+ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
1437
+ ui.notify(
1438
+ REVIEW_STRICTNESS.map((level) => `${level}${level === DEFAULT_REVIEW_STRICTNESS ? " (recommended)" : ""} — ${REVIEW_STRICTNESS_CHOICES[level]}`).join(
1439
+ "\n",
1440
+ ),
1441
+ "info",
1442
+ );
1443
+ return {
1444
+ strictness: await askLiteral(
1445
+ ui,
1446
+ "review-strictness",
1447
+ "Review strictness for green PRs",
1448
+ REVIEW_STRICTNESS,
1449
+ REVIEW_STRICTNESS_CHOICES,
1450
+ a.review.strictness,
1451
+ ),
1452
+ maxRounds: await askReviewRounds(ui, a.review.maxRounds),
1453
+ };
1454
+ }
1455
+
1382
1456
  const askPolicy: AreaAsker = async (ui, a) => ({
1383
1457
  ...a,
1384
1458
  policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
@@ -1390,6 +1464,7 @@ const askPolicy: AreaAsker = async (ui, a) => ({
1390
1464
  ARM_PROOF_CHOICES,
1391
1465
  a.armProof,
1392
1466
  ),
1467
+ review: await askReviewPolicy(ui, a),
1393
1468
  });
1394
1469
 
1395
1470
  /** How a stuck run reaches a human, and who triages it when it does. */
package/src/setup.ts CHANGED
@@ -39,12 +39,14 @@ import {
39
39
  import {
40
40
  clonePolicy,
41
41
  configPath,
42
+ DEFAULT_STATE_LABELS,
42
43
  defaultMirrorRoot,
43
44
  defaultWorkspaceRoot,
44
45
  resolveArmProof,
45
46
  resolveCaps,
46
47
  resolvePolicy,
47
48
  resolveReleaseGrants,
49
+ resolveReview,
48
50
  SCOPE_PRESETS,
49
51
  stateDir,
50
52
  } from "./config.ts";
@@ -57,8 +59,10 @@ import {
57
59
  DEFAULT_CAPS,
58
60
  DEFAULT_PROJECT_POLICY,
59
61
  DEFAULT_REPORT_SCOPE,
62
+ DEFAULT_REVIEW_POLICY,
60
63
  DENIED_RELEASE_GRANTS,
61
64
  RELEASE_SHAPES,
65
+ REVIEW_STRICTNESS,
62
66
  WEEKDAYS,
63
67
  type ArmProof,
64
68
  type BaseFreshness,
@@ -75,6 +79,8 @@ import {
75
79
  type ReleaseRequirement,
76
80
  type ReportScopeChoice,
77
81
  type ReportingPolicy,
82
+ type ReviewPolicy,
83
+ type ReviewStrictness,
78
84
  type Weekday,
79
85
  type WeeklyAvailability,
80
86
  type RepoTarget,
@@ -215,6 +221,13 @@ export interface SetupAnswers {
215
221
  * leave it to be defaulted by whichever reader gets there first.
216
222
  */
217
223
  armProof: ArmProof;
224
+ /**
225
+ * How green PRs are reviewed and returned (#678): the strictness level and
226
+ * the hard ceiling on review rounds per PR lifecycle. Always complete: the
227
+ * wizard asks about it in the policy area, so an answers object can never
228
+ * leave it to be defaulted by whichever reader gets there first.
229
+ */
230
+ review: ReviewPolicy;
218
231
  /**
219
232
  * Hand-edited recovery merge authorizations carried through setup unchanged.
220
233
  * The wizard never grants one; forgetting them during an unrelated amend
@@ -314,6 +327,8 @@ export const SETUP_DEFAULTS = {
314
327
  releaseGrants: DENIED_RELEASE_GRANTS,
315
328
  /** The strictest reading of the prose these conditions replaced (#129). */
316
329
  policy: DEFAULT_PROJECT_POLICY,
330
+ /** The recommended review strictness and its default ceiling (#678). */
331
+ review: DEFAULT_REVIEW_POLICY,
317
332
  /** The daemon runs its own triage session unless an operator already runs one. */
318
333
  orchestratorMode: "embedded",
319
334
  } as const;
@@ -405,6 +420,35 @@ export const ARM_PROOF_CHOICES: { readonly [K in ArmProof]: string } = {
405
420
  "claim-only": "anyone who can invoke the already-privileged `omp-conductor arm` command can start dispatch once the live claim and poller pass",
406
421
  };
407
422
 
423
+ /**
424
+ * What each review strictness blocks, in the operator's words, and the single
425
+ * canonical phrasing of the three bars (#678).
426
+ *
427
+ * Mapped over the closed union for the reason {@link MERGE_DUTY} is: a fourth
428
+ * level fails to compile here instead of reaching a wizard with no question for
429
+ * it, an amend row that cannot describe it, and a rendered brief that shows it
430
+ * blank. Each sentence is self-contained — "Low's bar" would reference prose
431
+ * the brief may not be rendering — and it is the *same* text the wizard shows
432
+ * one level at a time and the composed orchestrator brief carries for the
433
+ * configured level, so the definition an operator chose is the threshold a
434
+ * session enforces.
435
+ *
436
+ * The two consumers read this map rather than their own prose: the setup
437
+ * dialog explains every level from it, and {@link reviewDuty} renders the
438
+ * effective one from it. "One canonical implementation, not duplicated prose
439
+ * branches" is this object.
440
+ */
441
+ export const REVIEW_STRICTNESS_CHOICES: { readonly [K in ReviewStrictness]: string } = {
442
+ low: "correctness, security, data-loss or explicit acceptance-criteria failures — anything else stays a review comment",
443
+ medium:
444
+ "correctness, security, data-loss or explicit acceptance-criteria failures, plus material maintainability or " +
445
+ "reliability defects likely to become incidents within six months — anything else stays a review comment",
446
+ high:
447
+ "correctness, security, data-loss or explicit acceptance-criteria failures, plus material maintainability or " +
448
+ "reliability defects likely to become incidents within six months, plus concrete quality defects — never " +
449
+ "subjective style churn or unbounded refactoring",
450
+ };
451
+
408
452
  export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]: string } = {
409
453
  "runs-settled": "every run of the released repo actually merged, not merely reached a green PR",
410
454
  "fleet-runs-settled": "every run in the project actually merged — suite-wide strictness for shapes that consume several repos",
@@ -465,6 +509,34 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
465
509
  " one is a hard boundary, not a preference.",
466
510
  };
467
511
 
512
+ /**
513
+ * Duty 1's review-return contract, worded from `project.review` (#678).
514
+ *
515
+ * One canonical implementation rather than prose branches in the template: the
516
+ * configured level's bar is the same {@link REVIEW_STRICTNESS_CHOICES} text
517
+ * the setup dialog explains each level with, and the ceiling is the configured
518
+ * number — so the strictness an operator chose in setup and the threshold this
519
+ * session enforces cannot diverge. The paragraph names `conductor_pr_review`
520
+ * (the #677 verb) as the only return path and states the three ceiling
521
+ * behaviours: leave the PR open, record the unresolved findings, escalate once.
522
+ */
523
+ export function reviewDuty(p: ProjectConfig): string {
524
+ const { strictness, maxRounds } = resolveReview(p);
525
+ return (
526
+ "\n" +
527
+ "**Review policy:** before you merge (or settle) a green PR, review it at\n" +
528
+ `this project's strictness. The level is **${strictness}**: ` +
529
+ `${REVIEW_STRICTNESS_CHOICES[strictness]}. ` +
530
+ "`conductor_pr_review` is the verb for findings at or above that bar, and\n" +
531
+ "for nothing else — anything below it stays a review comment on the PR,\n" +
532
+ "never a return. Every corrected head gets a fresh review, up to a hard\n" +
533
+ `ceiling of ${maxRounds} ${maxRounds === 1 ? "round" : "rounds"} per PR lifecycle, visible in ` +
534
+ "`omp-conductor status` as `review-revision N`. At the ceiling\n" +
535
+ "`conductor_pr_review` refuses: leave the PR open, record the unresolved\n" +
536
+ "findings, and escalate once — never a further round."
537
+ );
538
+ }
539
+
468
540
  /**
469
541
  * The Promotion paragraph, worded from `authority.promotion`.
470
542
  *
@@ -901,7 +973,7 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
901
973
  name: a.projectName,
902
974
  tracker: { kind: "github", repo: a.trackerRepo },
903
975
  queueLabel: a.queueLabel,
904
- stateLabels: { ...a.stateLabels },
976
+ stateLabels: { ...a.stateLabels, backlog: DEFAULT_STATE_LABELS.backlog },
905
977
  routing: { labelPrefix: a.routingLabelPrefix, repos },
906
978
  caps,
907
979
  ...(a.workerModel !== undefined && a.workerModel.trim().length > 0
@@ -928,6 +1000,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
928
1000
  // policy has a line in the file to point at — and the recovery playbook
929
1001
  // can read which proof the project opted into (#613).
930
1002
  arm: { proof: a.armProof },
1003
+ // Written out in full for the same reason as `arm`: the file then carries
1004
+ // the strictness and the ceiling an operator chose, without anyone having
1005
+ // to know a migration rule (#678).
1006
+ review: { ...a.review },
931
1007
  ...(a.recoveryMerges === undefined
932
1008
  ? {}
933
1009
  : { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
@@ -1018,6 +1094,10 @@ export function defaultAnswers(projectName: string, opts: { added?: boolean } =
1018
1094
  // The strict reading, matching the loader's absent-key default: a project
1019
1095
  // that never answered keeps today's authenticated challenge round-trip.
1020
1096
  armProof: DEFAULT_ARM_PROOF,
1097
+ // First-run default: the recommended strictness and its ceiling, so a
1098
+ // fresh project opens on the answer the issue recommends — and the loader
1099
+ // materialises the same value for a config that has no key at all.
1100
+ review: { ...SETUP_DEFAULTS.review },
1021
1101
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
1022
1102
  reportScope: SETUP_DEFAULT_REPORT_SCOPE,
1023
1103
  writeOrchestratorBrief: false,
@@ -1117,6 +1197,10 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1117
1197
  // The loader materialises `arm` complete, so this is the answer the project
1118
1198
  // actually has — never a default guessed at the amend prompt.
1119
1199
  armProof: resolveArmProof(p),
1200
+ // Same reason as `armProof`: the loader materialises `review` complete, so
1201
+ // a re-run preserves the configured strictness and ceiling rather than
1202
+ // opening on a default the operator already answered.
1203
+ review: resolveReview(p),
1120
1204
  orchestratorMode: p.escalation.orchestrator,
1121
1205
  reportScope: reportScopeFromPolicy(p.reporting),
1122
1206
  writeOrchestratorBrief: false,
@@ -1184,6 +1268,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
1184
1268
  QUEUE_LABEL: p.queueLabel,
1185
1269
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
1186
1270
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
1271
+ REVIEW_DUTY: reviewDuty(p),
1187
1272
  PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
1188
1273
  POLICY_SOURCE: policySourceLine(p),
1189
1274
  };
@@ -1693,6 +1778,9 @@ export function summarisePlan(
1693
1778
  ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
1694
1779
  );
1695
1780
 
1781
+ lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
1782
+ lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`);
1783
+
1696
1784
  const reporting = project.reporting as ReportingPolicy;
1697
1785
  const briefPath = orchestratorBriefPath(a);
1698
1786
  lines.push(
@@ -1909,16 +1997,18 @@ export const AMEND_AREAS: {
1909
1997
  },
1910
1998
  policy: {
1911
1999
  name: "merge & release preconditions",
1912
- asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships",
2000
+ asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed, and how many rounds a PR may be returned",
1913
2001
  describe: (p) => {
1914
2002
  const policy = resolvePolicy(p);
2003
+ const review = resolveReview(p);
1915
2004
  // Counted rather than listed: this row is elided at 96 characters, and the
1916
2005
  // full table is in the plan summary the consent screen shows next.
1917
2006
  return (
1918
2007
  `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
1919
2008
  `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
1920
2009
  `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
1921
- `${policy.release.environments.length} env(s)`
2010
+ `${policy.release.environments.length} env(s); ` +
2011
+ `review ${review.strictness} ×${review.maxRounds}`
1922
2012
  );
1923
2013
  },
1924
2014
  },
@@ -22,7 +22,7 @@ import type { DaemonStop } from "./types.ts";
22
22
  import { settlementFlagSummary } from "./diff-flags.ts";
23
23
  import type { CodeGraphHealth } from "./graph-health.ts";
24
24
  import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
25
- import { formatOrchestratorDown } from "./orchestrator-down.ts";
25
+ import { formatDownDuration, formatOrchestratorDown } from "./orchestrator-down.ts";
26
26
  import { planUsageLine } from "./usage.ts";
27
27
  import { SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
28
28
  import type { WorkerPausePhase } from "./worker.ts";
@@ -32,12 +32,12 @@ import {
32
32
  formatDispatchSummary,
33
33
  formatFreezes,
34
34
  formatReleaseGrants,
35
- formatSalvagedRuns,
36
35
  isPaused,
37
36
  pausedAt,
38
37
  pauseProvenance,
39
38
  type StatusSnapshot,
40
39
  } from "./daemon.ts";
40
+ import { formatQuarantinedRuns, formatSalvagedRuns } from "./settlement.ts";
41
41
 
42
42
  // layered status
43
43
  // ---------------------------------------------------------------------------
@@ -405,6 +405,13 @@ function formatProjectBody(
405
405
  ` continuations ${s.caps.maxContinuationsPerIssue}`,
406
406
  "",
407
407
  ...formatReleaseGrants(s.releaseGrants),
408
+ // The effective review policy (#678), read from the same snapshot surface
409
+ // as the grants: the level is what Duty 1 judges findings against, and the
410
+ // ceiling is its hard bound — an orchestrator reading `review-revision N`
411
+ // below has to see both without opening the config.
412
+ "review",
413
+ ` strictness ${s.review.strictness}`,
414
+ ` max rounds/PR ${s.review.maxRounds}`,
408
415
  "",
409
416
  formatDispatchSummary(s.dispatch),
410
417
  "",
@@ -419,16 +426,30 @@ function formatProjectBody(
419
426
  // a failure and from an ordinary continuation, with the round number
420
427
  // from the durable revision row (#692). The live pause overlay still
421
428
  // wins: an operator pause is the newer fact about the same session.
429
+ const paused = phase === "pausing" || phase === "paused";
422
430
  const round = s.reviewRounds?.[r.id];
423
- const state =
424
- phase === "pausing" || phase === "paused"
425
- ? phase
426
- : round !== undefined
427
- ? `review-revision ${round}`
428
- : r.state;
431
+ const state = paused
432
+ ? phase
433
+ : round !== undefined
434
+ ? `review-revision ${round}`
435
+ : r.state;
436
+ // Turn rate (#730): a stalled run and a fast one used to render
437
+ // identically as a bare turn count. The rate is the lifetime average
438
+ // over the elapsed shown — the snapshot carries no checkpoint from
439
+ // which a recent-interval rate could be derived — so it is labelled
440
+ // `avg`, and `elapsed` is elapsed since claim, never "remaining wall
441
+ // clock": a paused worker banks its budget, which the snapshot cannot
442
+ // know. A paused run gets neither number, because its elapsed includes
443
+ // banked pause time and its state already says `paused`.
444
+ const elapsedMs = Math.max(0, now - r.startedAt);
445
+ const progress = paused
446
+ ? ""
447
+ : ` ${formatDownDuration(elapsedMs)} elapsed ${(
448
+ (r.turns * 60_000) / Math.max(elapsedMs, 1_000)
449
+ ).toFixed(1)} turns/min avg`;
429
450
  lines.push(
430
451
  ` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
431
- `${r.turns}/${r.maxTurns} turns ${r.spendUsd.toFixed(2)} ${r.branch}` +
452
+ `${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +
432
453
  (r.prUrl ? ` ${r.prUrl}` : ""),
433
454
  );
434
455
  // The orchestrator's Duty 1 reads this command, and a flagged run's
@@ -441,6 +462,7 @@ function formatProjectBody(
441
462
  lines.push(...formatBaseHealth(s.baseHealth));
442
463
  lines.push(...formatFreezes(s.freezes));
443
464
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
465
+ lines.push(...formatQuarantinedRuns(s.quarantinedRuns));
444
466
  lines.push(...formatOpenReports(s.openReports));
445
467
  lines.push(...formatDigestBacklog(s.digestBacklog));
446
468
  if (s.liveWorkers > 0) {