omp-conductor 0.18.0 → 0.18.2

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 +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  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 +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -52,6 +52,8 @@ import {
52
52
  agentRenameVerdict,
53
53
  checkEscalation,
54
54
  DEFAULT_AGENT_RENAME_DEPS,
55
+ defaultIdentityProbes,
56
+ defaultServiceRuntime,
55
57
  planHostRuntime,
56
58
  totalConfiguredWorkers,
57
59
  writeHostRuntime,
@@ -61,6 +63,7 @@ import {
61
63
  type AgentRenameDeps,
62
64
  type EscalationDeps,
63
65
  type ServiceRuntime,
66
+ type WorkerIdentityProbes,
64
67
  } from "./setup-host.ts";
65
68
  import type { WizardUi } from "./wizard-ui.ts";
66
69
  import type { Caps, ProjectConfig, Store } from "./types.ts";
@@ -81,6 +84,14 @@ export interface InstallDeps {
81
84
  escalation?: EscalationDeps;
82
85
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
83
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;
84
95
  unitDir?: string;
85
96
  /**
86
97
  * Where the recovery playbook installs. Injectable so the idempotency gate
@@ -188,6 +199,13 @@ export async function runHostInstall(
188
199
  // happens only after consent (#510), so the prompt's "Nothing has been run
189
200
  // yet" is true of the staged tree when it is printed, and a declined run
190
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));
191
209
  const plan = planHostRuntime(
192
210
  project,
193
211
  caps,
@@ -197,6 +215,7 @@ export async function runHostInstall(
197
215
  unitDir,
198
216
  deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
199
217
  deps.multiProject ?? hostMultiProject(),
218
+ identityProbes,
200
219
  );
201
220
  // No pane-shell key is planned (unusable login shell, unparseable config):
202
221
  // the operator hears why before the consent prompt, not after an install
@@ -298,6 +317,15 @@ export async function runHostInstall(
298
317
  ]),
299
318
  ]),
300
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
+ ]),
301
329
  "",
302
330
  liveWorkers === 0
303
331
  ? "No workers are live — nothing to drain before the restart."
@@ -110,11 +110,13 @@ import {
110
110
  parseOmpSettingsYaml,
111
111
  planAgainstLabels,
112
112
  planLabels,
113
+ readConfiguredOmpRoles,
113
114
  summariseAmend,
114
115
  summarisePlan,
115
116
  wantedLabels,
116
117
  writeOrchestratorBrief,
117
118
  type AmendAreaId,
119
+ type ConfiguredOmpRoles,
118
120
  type LabelPlan,
119
121
  type OperatorJudgment,
120
122
  type ScopeCheck,
@@ -126,6 +128,7 @@ import {
126
128
  BASE_FRESHNESS,
127
129
  BEHIND_BASE_ACTIONS,
128
130
  DEFAULT_CAPS,
131
+ DEFAULT_REVIEW_ADJUDICATOR_ROLE,
129
132
  DEFAULT_REVIEW_MAX_ROUNDS,
130
133
  DEFAULT_REVIEW_STRICTNESS,
131
134
  DENIED_RELEASE_GRANTS,
@@ -133,6 +136,7 @@ import {
133
136
  RELEASE_REQUIREMENTS,
134
137
  INTERRUPT_CATEGORIES,
135
138
  RELEASE_SHAPES,
139
+ REVIEW_ADJUDICATOR_RE,
136
140
  REVIEW_MAX_ROUNDS_MAX,
137
141
  REVIEW_MAX_ROUNDS_MIN,
138
142
  REVIEW_STRICTNESS,
@@ -151,6 +155,7 @@ import {
151
155
  type ReviewPolicy,
152
156
  } from "./types.ts";
153
157
  import { withProgress } from "./ui/progress.ts";
158
+ import { modelRolesIn } from "./omp-settings.ts";
154
159
  import type { WizardUi } from "./wizard-ui.ts";
155
160
 
156
161
  /**
@@ -979,6 +984,7 @@ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAns
979
984
  inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
980
985
  blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
981
986
  failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
987
+ backlog: labels.get(seed.stateLabels.backlog.toLowerCase()) ?? seed.stateLabels.backlog,
982
988
  },
983
989
  targetRepos,
984
990
  policy: {
@@ -1094,19 +1100,28 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
1094
1100
 
1095
1101
  // One confirm instead of three prompts: the namespaced defaults are right for
1096
1102
  // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
1097
- // its reputation.
1103
+ // its reputation. The park label is in the same confirm, with its ownership
1104
+ // stated — it is not conductor-written, but it has to exist for the operator
1105
+ // to have the gesture (#507).
1098
1106
  const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
1099
1107
  const customiseStates = await askYesNo(
1100
1108
  ui,
1101
1109
  "customise-state-labels",
1102
1110
  "State labels",
1103
1111
  `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
1104
- `"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
1112
+ `"${stateLabels.failed}" so the tracker alone shows live state; "${stateLabels.backlog}" is ` +
1113
+ `yours — the conductor never writes it, but an issue carrying it is never claimed. Rename them?`,
1105
1114
  );
1106
1115
  if (customiseStates) {
1107
1116
  stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress);
1108
1117
  stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked);
1109
1118
  stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed);
1119
+ stateLabels.backlog = await ask(
1120
+ ui,
1121
+ "state-label-backlog",
1122
+ "Label the operator applies to park an issue (the conductor never writes it)",
1123
+ stateLabels.backlog,
1124
+ );
1110
1125
  }
1111
1126
 
1112
1127
  const routingLabelPrefix = await ask(
@@ -1421,6 +1436,136 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1421
1436
  throw new Cancelled();
1422
1437
  }
1423
1438
 
1439
+ /** The answer-file key the adjudicator question is recorded under. */
1440
+ const REVIEW_ADJUDICATOR_KEY = "review-adjudicator-role";
1441
+
1442
+ /**
1443
+ * Whether one role is a legitimate adjudicator answer on this host right now:
1444
+ * it must be a single loadable role token (`REVIEW_ADJUDICATOR_RE`) AND a
1445
+ * `modelRoles` key of the daemon's global settings or of the project's omp
1446
+ * overlay — or the deterministic default, which setup must always be able to
1447
+ * write back. Anything else — including a project's current role that was
1448
+ * removed from both surfaces, or a malformed `modelRoles` key the loader
1449
+ * would refuse — is an unavailable role and must not be accepted, whatever
1450
+ * the config says (#875).
1451
+ */
1452
+ function isAdjudicatorAvailable(live: ConfiguredOmpRoles, overlay: readonly string[], role: string): boolean {
1453
+ if (!REVIEW_ADJUDICATOR_RE.test(role)) return false;
1454
+ return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE || live.global.includes(role) || overlay.includes(role);
1455
+ }
1456
+
1457
+ /**
1458
+ * The adjudicator roles the dialog accepts, derived from the live OMP role
1459
+ * configuration — the daemon's global settings, then the project's omp
1460
+ * overlay — with the current answer first and the deterministic default
1461
+ * offered exactly once (#875). The order is the contract: an amend's Enter
1462
+ * re-affirms the current value, and the shipped default stays expressible for
1463
+ * a project whose operator never answered.
1464
+ *
1465
+ * Every role is filtered through the same token grammar the loader enforces:
1466
+ * a `modelRoles` key the config gate would reject must never be offered (or
1467
+ * accepted), because persisting it would write a policy the next loadConfig
1468
+ * refuses and stop the fleet. The current answer leads the list only while it
1469
+ * is still available: a role removed from both the global settings and the
1470
+ * overlay must not be accepted back into the config by a bare Enter — the
1471
+ * caller warns that it cannot be kept, and the accepted set excludes it, so
1472
+ * Entering it refuses exactly like typing any other unavailable role.
1473
+ */
1474
+ function adjudicatorChoices(live: ConfiguredOmpRoles, overlay: readonly string[], current: string): string[] {
1475
+ const choices: string[] = [];
1476
+ const seen = new Set<string>();
1477
+ const first = isAdjudicatorAvailable(live, overlay, current) ? [current] : [];
1478
+ for (const role of [...first, ...live.global, ...overlay, DEFAULT_REVIEW_ADJUDICATOR_ROLE]) {
1479
+ if (REVIEW_ADJUDICATOR_RE.test(role) && !seen.has(role)) {
1480
+ seen.add(role);
1481
+ choices.push(role);
1482
+ }
1483
+ }
1484
+ return choices;
1485
+ }
1486
+
1487
+ /** One role row's description: what makes this role available and why. */
1488
+ function adjudicatorDescription(
1489
+ role: string,
1490
+ live: ConfiguredOmpRoles,
1491
+ overlay: readonly string[],
1492
+ ): string | undefined {
1493
+ if (live.global.includes(role) || overlay.includes(role)) {
1494
+ return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE
1495
+ ? "configured OMP model role — the deterministic default"
1496
+ : "configured OMP model role";
1497
+ }
1498
+ if (role === DEFAULT_REVIEW_ADJUDICATOR_ROLE) {
1499
+ return "the deterministic default every project loads until answered";
1500
+ }
1501
+ return undefined;
1502
+ }
1503
+
1504
+ /**
1505
+ * Which OMP model role runs a PR's terminal review-ceiling adjudication
1506
+ * (#875). The offered roles are derived from the live OMP role configuration
1507
+ * (the daemon account's global settings and the project's overlay, never a
1508
+ * conductor-owned registry), the prompt opens on the current answer, and any
1509
+ * answer that is not one of those configured roles is refused with the place
1510
+ * to configure it named.
1511
+ *
1512
+ * Driven through `askValid`, not a select, on purpose: a select surface can
1513
+ * only ever return an offered label, so the unconfigured-value refusal would
1514
+ * be unreachable on the real UIs and the answer-file path would fail with a
1515
+ * generic "must be a listed label" before this guidance ran. A validated text
1516
+ * answer gives every surface — terminal, scripted and `--answers` — the same
1517
+ * bounded three tries and the same actionable error naming the settings file.
1518
+ */
1519
+ async function askReviewAdjudicator(
1520
+ ui: WizardUi,
1521
+ current: string,
1522
+ live: ConfiguredOmpRoles,
1523
+ overlay: readonly string[],
1524
+ ): Promise<string> {
1525
+ const choices = adjudicatorChoices(live, overlay, current);
1526
+ if (!live.decoded) {
1527
+ ui.notify(
1528
+ `The daemon's OMP settings at ${live.path} could not be read — only the project's overlay roles and the default are offered.`,
1529
+ "warning",
1530
+ );
1531
+ }
1532
+ // A project whose configured role was removed from both surfaces must not be
1533
+ // allowed to keep it: the warning names the role and the way back, and the
1534
+ // accepted list above already excludes it — Entering it now refuses exactly
1535
+ // like typing any other unavailable role (#875).
1536
+ if (!isAdjudicatorAvailable(live, overlay, current)) {
1537
+ ui.notify(
1538
+ `"${current}" is this project's current adjudicator but not a currently configured OMP model role — it cannot be kept. ` +
1539
+ `Add "${current}" back as a modelRoles key to ${live.path} or this project's omp settings overlay, ` +
1540
+ `or type one of the available roles below.`,
1541
+ "warning",
1542
+ );
1543
+ }
1544
+ ui.notify(
1545
+ choices
1546
+ .map((role) => {
1547
+ const described = adjudicatorDescription(role, live, overlay);
1548
+ return `${role}${role === current ? " (current)" : ""}${described === undefined ? "" : ` — ${described}`}`;
1549
+ })
1550
+ .join("\n"),
1551
+ "info",
1552
+ );
1553
+ return askValid(
1554
+ ui,
1555
+ REVIEW_ADJUDICATOR_KEY,
1556
+ "Adjudicator for a PR at the review ceiling — one configured OMP model role, not a model",
1557
+ current,
1558
+ (value) => {
1559
+ if (choices.some((choice) => choice === value)) return undefined;
1560
+ return (
1561
+ `"${value}" is not one of the OMP model roles available here — add it as a modelRoles key to the daemon's OMP settings ` +
1562
+ `(${live.path}) or this project's omp settings overlay, or type an offered role. ` +
1563
+ `A role is a single token like "task" — never a provider or model.`
1564
+ );
1565
+ },
1566
+ );
1567
+ }
1568
+
1424
1569
  /**
1425
1570
  * How green PRs are reviewed (#678), asked with the merge preconditions and
1426
1571
  * the arming proof: they are the same kind of declared policy — a typed,
@@ -1431,7 +1576,10 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1431
1576
  * rather than between three words. The select cursor opens on the current
1432
1577
  * answer (the configured level on a re-run, the recommended default on a
1433
1578
  * first run); the rounds are a validated integer within the same range the
1434
- * loader enforces.
1579
+ * loader enforces; the adjudicator role is offered from the live OMP role
1580
+ * configuration (#875) — the options and their availability come from the
1581
+ * daemon's OMP settings and the project's overlay, never from a conductor
1582
+ * model registry.
1435
1583
  */
1436
1584
  async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
1437
1585
  ui.notify(
@@ -1440,6 +1588,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
1440
1588
  ),
1441
1589
  "info",
1442
1590
  );
1591
+ const live = readConfiguredOmpRoles();
1443
1592
  return {
1444
1593
  strictness: await askLiteral(
1445
1594
  ui,
@@ -1450,6 +1599,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
1450
1599
  a.review.strictness,
1451
1600
  ),
1452
1601
  maxRounds: await askReviewRounds(ui, a.review.maxRounds),
1602
+ adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)),
1453
1603
  };
1454
1604
  }
1455
1605
 
@@ -2516,6 +2666,7 @@ function captureInventory(
2516
2666
  };
2517
2667
  captureIfWritten(plan.runtime.service);
2518
2668
  captureIfWritten(plan.runtime.herdrUnit);
2669
+ captureIfWritten(plan.runtime.harnessMount);
2519
2670
  captureIfWritten(plan.runtime.herdrConfig);
2520
2671
  captureIfWritten(plan.runtime.herdrEnv);
2521
2672
  captureIfWritten(plan.runtime.recoverUnit);
package/src/setup.ts CHANGED
@@ -39,7 +39,6 @@ import {
39
39
  import {
40
40
  clonePolicy,
41
41
  configPath,
42
- DEFAULT_STATE_LABELS,
43
42
  defaultMirrorRoot,
44
43
  defaultWorkspaceRoot,
45
44
  resolveArmProof,
@@ -51,7 +50,7 @@ import {
51
50
  stateDir,
52
51
  } from "./config.ts";
53
52
  import { graphProjectPath, graphRepos } from "./graph.ts";
54
- import { ompSettingsOverlay } from "./omp-settings.ts";
53
+ import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
55
54
  import {
56
55
  CONFIG_VERSION,
57
56
  DEFAULT_ARM_PROOF,
@@ -144,7 +143,7 @@ export interface SetupAnswers {
144
143
  /** Tracker repo as `owner/repo` — the only spelling `gh` takes without a host. */
145
144
  trackerRepo: string;
146
145
  queueLabel: string;
147
- stateLabels: { inProgress: string; blocked: string; failed: string };
146
+ stateLabels: { inProgress: string; blocked: string; failed: string; backlog: string };
148
147
  routingLabelPrefix: string;
149
148
  targetRepos: {
150
149
  name: string;
@@ -318,7 +317,12 @@ export interface LabelPlan {
318
317
  * cannot drift apart: one spelling of "ready-for-agent" in the package. */
319
318
  export const SETUP_DEFAULTS = {
320
319
  queueLabel: "ready-for-agent",
321
- stateLabels: { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed" },
320
+ stateLabels: {
321
+ inProgress: "agent:in-progress",
322
+ blocked: "agent:blocked",
323
+ failed: "agent:failed",
324
+ backlog: "backlog",
325
+ },
322
326
  routingLabelPrefix: "repo:",
323
327
  defaultBranch: "main",
324
328
  /** Both authorities start with the human; the wizard asks to move each one. */
@@ -578,13 +582,15 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
578
582
  const REQUIRED_SCOPES = ["repo", "project"] as const;
579
583
 
580
584
  /** GitHub's own palette, so the tracker reads at a glance: green means queued,
581
- * blue means moving, amber means waiting on you, red means it gave up, and
582
- * purple is routing — the operator's input, the one label the loop never writes. */
585
+ * blue means moving, amber means waiting on you, red means it gave up, grey
586
+ * means the operator parked it, and purple means routing the operator's
587
+ * input, the label the loop never writes. */
583
588
  const LABEL_COLOURS = {
584
589
  queue: "0e8a16",
585
590
  inProgress: "1d76db",
586
591
  blocked: "fbca04",
587
592
  failed: "b60205",
593
+ backlog: "8b949e",
588
594
  routing: "6f42c1",
589
595
  } as const;
590
596
 
@@ -668,14 +674,15 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
668
674
 
669
675
  /**
670
676
  * Every label setup wants on the tracker, before existence is known: the queue
671
- * and state labels the loop reads and writes, plus one routing label per routed
672
- * repo. Pure the prefix and the repo keys are already answered at this point
673
- * in the interview which is what lets a test pin the whole set without `gh`.
677
+ * and state labels the loop reads and writes, the operator's park label (which
678
+ * the loop must never write), plus one routing label per routed repo. Pure
679
+ * the prefix and the repo keys are already answered at this point in the
680
+ * interview — which is what lets a test pin the whole set without `gh`.
674
681
  *
675
- * Routing labels are provisioned, never applied: `route()` requires exactly one
676
- * `<prefix><repo>` label per issue and treats zero or two as unroutable, so a
677
- * tracker without them can queue nothing. Creating them queues nothing either —
678
- * applying one, with the queue label, stays the operator's sign-off.
682
+ * The park label is provisioned like the rest but owned the other way round:
683
+ * it exists so the operator has a gesture, and the conductor never applies or
684
+ * removes it (#507). Creating it queues nothing either — applying one, with
685
+ * the queue label, stays the operator's sign-off.
679
686
  */
680
687
  export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
681
688
  return [
@@ -699,6 +706,11 @@ export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
699
706
  colour: LABEL_COLOURS.failed,
700
707
  description: "The conductor gave up on this issue after its retry budget",
701
708
  },
709
+ {
710
+ name: a.stateLabels.backlog,
711
+ colour: LABEL_COLOURS.backlog,
712
+ description: "Parked by the operator — never claimed, only the operator may set or clear it",
713
+ },
702
714
  ...a.targetRepos.map((r) => ({
703
715
  name: `${a.routingLabelPrefix}${r.name}`,
704
716
  colour: LABEL_COLOURS.routing,
@@ -973,7 +985,11 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
973
985
  name: a.projectName,
974
986
  tracker: { kind: "github", repo: a.trackerRepo },
975
987
  queueLabel: a.queueLabel,
976
- stateLabels: { ...a.stateLabels, backlog: DEFAULT_STATE_LABELS.backlog },
988
+ // The park label is now an answer like the other three: the wizard offers
989
+ // it and every answers constructor carries it, so writing the answers as
990
+ // they stand is the single source — slice 1's hardcoded default bridge is
991
+ // gone (#507 slice 2).
992
+ stateLabels: { ...a.stateLabels },
977
993
  routing: { labelPrefix: a.routingLabelPrefix, repos },
978
994
  caps,
979
995
  ...(a.workerModel !== undefined && a.workerModel.trim().length > 0
@@ -1559,6 +1575,53 @@ export function detectTelegram(): TelegramPresence {
1559
1575
  return result;
1560
1576
  }
1561
1577
 
1578
+ /**
1579
+ * Env override for the daemon account's global omp settings file, exactly the
1580
+ * seam `OMP_TELEGRAM_STATE_DIR` gives telegram discovery: a test (or a second
1581
+ * fleet on the same machine) can redirect the read without touching a real
1582
+ * `~/.omp/agent/config.yml`.
1583
+ */
1584
+ export const OMP_GLOBAL_SETTINGS_ENV = "OMP_CONDUCTOR_OMP_SETTINGS_PATH";
1585
+
1586
+ /**
1587
+ * The model roles the daemon account's global omp settings actually configure
1588
+ * — the keys of its `modelRoles` stanza (#875) — the "live OMP role
1589
+ * configuration" an adjudicator answer must be checked against. OMP's own
1590
+ * settings file (`~/.omp/agent/config.yml`) is the source rather than any
1591
+ * conductor-owned registry: conductor stores only role names, so the only
1592
+ * place an unrelated role could be invented is here, and reading this file is
1593
+ * what keeps setup from offering (or accepting) a role OMP never configured.
1594
+ *
1595
+ * A missing file is no roles; an unreadable or malformed one is also no roles
1596
+ * but is reported through `decoded: false` so the wizard can say why instead
1597
+ * of silently offering the default alone.
1598
+ */
1599
+ export function readConfiguredOmpRoles(): ConfiguredOmpRoles {
1600
+ const override = process.env[OMP_GLOBAL_SETTINGS_ENV]?.trim();
1601
+ const path = override && override.length > 0 ? override : join(homedir(), ".omp", "agent", "config.yml");
1602
+ if (!existsSync(path)) return { path, global: [], decoded: true };
1603
+ try {
1604
+ const parsed = parseYaml(readFileSync(path, "utf8"));
1605
+ return {
1606
+ path,
1607
+ global: [...new Set(modelRolesIn(parsed))].sort(),
1608
+ decoded: true,
1609
+ };
1610
+ } catch {
1611
+ return { path, global: [], decoded: false };
1612
+ }
1613
+ }
1614
+
1615
+ /** The global omp settings read (`readConfiguredOmpRoles`) reports. */
1616
+ export interface ConfiguredOmpRoles {
1617
+ /** The path read (the override, or the default under the operator's home). */
1618
+ readonly path: string;
1619
+ /** The model-role names the file's `modelRoles` stanza declares. */
1620
+ readonly global: readonly string[];
1621
+ /** False when the file exists but could not be read or parsed. */
1622
+ readonly decoded: boolean;
1623
+ }
1624
+
1562
1625
  /** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
1563
1626
  * compared against emptiness and then dropped on the floor. */
1564
1627
  function hasTelegramToken(envPath: string): boolean {
@@ -1780,6 +1843,7 @@ export function summarisePlan(
1780
1843
 
1781
1844
  lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
1782
1845
  lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`);
1846
+ lines.push(` adjudicator ${a.review.adjudicator} — the OMP model role that runs that ceiling adjudication`);
1783
1847
 
1784
1848
  const reporting = project.reporting as ReportingPolicy;
1785
1849
  const briefPath = orchestratorBriefPath(a);
@@ -1997,18 +2061,20 @@ export const AMEND_AREAS: {
1997
2061
  },
1998
2062
  policy: {
1999
2063
  name: "merge & release preconditions",
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",
2064
+ 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, how many rounds a PR may be returned, and which OMP model role adjudicates a PR at that ceiling",
2001
2065
  describe: (p) => {
2002
2066
  const policy = resolvePolicy(p);
2003
2067
  const review = resolveReview(p);
2004
2068
  // Counted rather than listed: this row is elided at 96 characters, and the
2005
- // full table is in the plan summary the consent screen shows next.
2069
+ // full table is in the plan summary the consent screen shows next. The
2070
+ // adjudicator is the third review decision, so it is named with the other
2071
+ // two (#875).
2006
2072
  return (
2007
2073
  `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
2008
2074
  `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
2009
2075
  `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
2010
2076
  `${policy.release.environments.length} env(s); ` +
2011
- `review ${review.strictness} ×${review.maxRounds}`
2077
+ `review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})`
2012
2078
  );
2013
2079
  },
2014
2080
  },
package/src/shell.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * One CLI argument rendered exactly as an operator would type it, for surfaces
3
+ * that print executable commands.
4
+ *
5
+ * A command a surface prints is a command the reader copies, so an argument
6
+ * that could mean something else is a defect: a project named `odd $fleet`
7
+ * must not render `--project odd $fleet` (two arguments, and a `$` that the
8
+ * shell would read). Safe characters pass through unquoted; anything else is
9
+ * wrapped in single quotes, folding an embedded quote with the standard
10
+ * `'\''` — the same convention the privileged-step renderer applies to its
11
+ * printed `sudo …` lines (#810).
12
+ */
13
+ export function shellQuote(value: string): string {
14
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
15
+ }