omp-conductor 0.18.1 → 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.
@@ -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
  /**
@@ -1431,6 +1436,136 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1431
1436
  throw new Cancelled();
1432
1437
  }
1433
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
+
1434
1569
  /**
1435
1570
  * How green PRs are reviewed (#678), asked with the merge preconditions and
1436
1571
  * the arming proof: they are the same kind of declared policy — a typed,
@@ -1441,7 +1576,10 @@ async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
1441
1576
  * rather than between three words. The select cursor opens on the current
1442
1577
  * answer (the configured level on a re-run, the recommended default on a
1443
1578
  * first run); the rounds are a validated integer within the same range the
1444
- * 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.
1445
1583
  */
1446
1584
  async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
1447
1585
  ui.notify(
@@ -1450,6 +1588,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
1450
1588
  ),
1451
1589
  "info",
1452
1590
  );
1591
+ const live = readConfiguredOmpRoles();
1453
1592
  return {
1454
1593
  strictness: await askLiteral(
1455
1594
  ui,
@@ -1460,6 +1599,7 @@ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPol
1460
1599
  a.review.strictness,
1461
1600
  ),
1462
1601
  maxRounds: await askReviewRounds(ui, a.review.maxRounds),
1602
+ adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)),
1463
1603
  };
1464
1604
  }
1465
1605
 
package/src/setup.ts CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  stateDir,
51
51
  } from "./config.ts";
52
52
  import { graphProjectPath, graphRepos } from "./graph.ts";
53
- import { ompSettingsOverlay } from "./omp-settings.ts";
53
+ import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
54
54
  import {
55
55
  CONFIG_VERSION,
56
56
  DEFAULT_ARM_PROOF,
@@ -1575,6 +1575,53 @@ export function detectTelegram(): TelegramPresence {
1575
1575
  return result;
1576
1576
  }
1577
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
+
1578
1625
  /** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
1579
1626
  * compared against emptiness and then dropped on the floor. */
1580
1627
  function hasTelegramToken(envPath: string): boolean {
@@ -1796,6 +1843,7 @@ export function summarisePlan(
1796
1843
 
1797
1844
  lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
1798
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`);
1799
1847
 
1800
1848
  const reporting = project.reporting as ReportingPolicy;
1801
1849
  const briefPath = orchestratorBriefPath(a);
@@ -2013,18 +2061,20 @@ export const AMEND_AREAS: {
2013
2061
  },
2014
2062
  policy: {
2015
2063
  name: "merge & release preconditions",
2016
- 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",
2017
2065
  describe: (p) => {
2018
2066
  const policy = resolvePolicy(p);
2019
2067
  const review = resolveReview(p);
2020
2068
  // Counted rather than listed: this row is elided at 96 characters, and the
2021
- // 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).
2022
2072
  return (
2023
2073
  `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
2024
2074
  `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
2025
2075
  `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
2026
2076
  `${policy.release.environments.length} env(s); ` +
2027
- `review ${review.strictness} ×${review.maxRounds}`
2077
+ `review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})`
2028
2078
  );
2029
2079
  },
2030
2080
  },
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { formatZonedMinute } from "./availability.ts";
21
- import type { DaemonStop } from "./types.ts";
21
+ import type { DaemonStop, GroomingRecord } 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";
@@ -206,6 +206,141 @@ function formatLastStop(lastStop: DaemonStop | undefined): string[] {
206
206
  }
207
207
 
208
208
 
209
+ // durable to-spec grooming lifecycle (#809)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * The durable mechanical-hold reasons admission's reconcile writes as blocked
214
+ * rows (#735) — the lane/dependency holds that clear by themselves. Mirrors
215
+ * `BLOCKED_GROOMING_HOLDS` in `store.ts`; the status reads the stored reason
216
+ * strings so both sides of the contract stay on the store vocabulary.
217
+ */
218
+ const MECHANICAL_GROOMING_REASONS: Record<string, true> = {
219
+ "file-lane": true,
220
+ "depends-on": true,
221
+ };
222
+
223
+ /**
224
+ * The to-spec refusal classes persisted as blocked rows (#772) — a result
225
+ * that failed validation is a mechanical block, never a verdict. Mirrors the
226
+ * failure kinds of `ToSpecFailure` in `to-spec.ts`.
227
+ */
228
+ const REFUSED_GROOMING_REASONS: Record<string, true> = {
229
+ malformed: true,
230
+ "missing-source": true,
231
+ "stale-source": true,
232
+ };
233
+
234
+ /** The durable in-flight launch marker (#777) — a batch is running right now.
235
+ * Mirrors `TO_SPEC_IN_FLIGHT_REASON` in `orchestrator-tick.ts`. */
236
+ const GROOMING_IN_FLIGHT_REASON = "in-flight";
237
+
238
+ /**
239
+ * One project's durable grooming state as status lines, or nothing when there
240
+ * is nothing to report (#809).
241
+ *
242
+ * Renders the state already owned by the `grooming_verdicts` table plus the
243
+ * dispatch snapshot's parked count — each category below is derived from the
244
+ * project-scoped durable rows the caller passes, never from parsing evidence
245
+ * strings and never from a second cache:
246
+ *
247
+ * - `awaiting to-spec` — the routable queue candidates the last dispatch
248
+ * pass saw that no durable row covers: `routed − rows`, clamped at zero so
249
+ * stale rows (candidates since removed from the queue) cannot push it
250
+ * negative. `routed` is the same denominator the tick's grooming trigger
251
+ * reads (`summary.routed >= groomBelow`), and the fleet's queue-digest
252
+ * arithmetic already treats durable rows as current-queue facts
253
+ * (`claimable = routed − knownBlocked`), so this is the operator-facing
254
+ * half of the same subtraction. Parent/epic exclusions and backlog
255
+ * candidates that never entered the queue are tracker-side and not
256
+ * store-knowable; the row lines below carry the durable results either
257
+ * way.
258
+ * - `promotable` / `considered` / `blocked` — completed to-spec results
259
+ * (`blocked` rows whose reason is a groomer verdict or a product-judgement
260
+ * label, e.g. `needs-product-decision`).
261
+ * - `mechanically blocked` — admission's lane/dependency holds.
262
+ * - `refused` — to-spec results that failed validation (`malformed`,
263
+ * `missing-source`, `stale-source`), told apart from the holds so an
264
+ * operator sees whether the runway cannot move or a result cannot be
265
+ * trusted.
266
+ * - `in-flight` — a launched batch is running (#777).
267
+ * - `operator-parked` — the dispatch snapshot's parked count (#507).
268
+ *
269
+ * Rendering is read-only: nothing here reconciles, promotes, retries, or
270
+ * mutates grooming records. Empty categories are omitted — zero-value rows
271
+ * would be noise, and an absent block means a fleet with no grooming state.
272
+ */
273
+ export interface GroomingStatusInput {
274
+ /** Project-scoped durable rows (`Store.groomingVerdicts`), issue-ascending. */
275
+ records: readonly GroomingRecord[];
276
+ /** Routable queue candidates from the last dispatch pass
277
+ * (`DispatchSummary.routed`). */
278
+ routed: number;
279
+ /** Operator-parked candidates from the last dispatch pass
280
+ * (`DispatchSummary.parked`). */
281
+ parked: number;
282
+ }
283
+
284
+ /** How many issue identifiers one category line may carry — enough to act on,
285
+ * never a queue dump. Mirrors the dispatch hold sampling. */
286
+ const GROOMING_STATUS_SAMPLE = 5;
287
+
288
+ /** One category line: label, count, and a bounded sample of issue identifiers
289
+ * with their durable reasons where the reason differs from the line label
290
+ * (e.g. `#13 already-done` under `considered`, `#19 malformed` under
291
+ * `refused`). Absent at zero. */
292
+ function groomingStatusLine(
293
+ label: string,
294
+ rows: readonly GroomingRecord[],
295
+ ): string | undefined {
296
+ if (rows.length === 0) return undefined;
297
+ const sample = rows
298
+ .slice(0, GROOMING_STATUS_SAMPLE)
299
+ .map((row) => (row.reason === label ? `#${row.issue}` : `#${row.issue} ${row.reason}`));
300
+ const more = rows.length > GROOMING_STATUS_SAMPLE ? ", …" : "";
301
+ return ` ${label.padEnd(21)}${rows.length} ${sample.join(", ")}${more}`;
302
+ }
303
+
304
+ export function formatGroomingStatus(input: GroomingStatusInput): string | undefined {
305
+ const { records, routed, parked } = input;
306
+ const promotable = records.filter((row) => row.verdict === "promotable");
307
+ const considered = records.filter((row) => row.verdict === "considered");
308
+ const blockedResults = records.filter(
309
+ (row) =>
310
+ row.verdict === "blocked" &&
311
+ MECHANICAL_GROOMING_REASONS[row.reason] !== true &&
312
+ REFUSED_GROOMING_REASONS[row.reason] !== true &&
313
+ row.reason !== GROOMING_IN_FLIGHT_REASON,
314
+ );
315
+ const mechanical = records.filter(
316
+ (row) => row.verdict === "blocked" && MECHANICAL_GROOMING_REASONS[row.reason] === true,
317
+ );
318
+ const refused = records.filter(
319
+ (row) => row.verdict === "blocked" && REFUSED_GROOMING_REASONS[row.reason] === true,
320
+ );
321
+ const inFlight = records.filter(
322
+ (row) => row.verdict === "blocked" && row.reason === GROOMING_IN_FLIGHT_REASON,
323
+ );
324
+ const awaiting = Math.max(0, routed - records.length);
325
+ const lines: string[] = [];
326
+ if (awaiting > 0) {
327
+ lines.push(` ${"awaiting to-spec".padEnd(21)}${awaiting} (of ${routed} routable)`);
328
+ }
329
+ for (const line of [
330
+ groomingStatusLine("promotable", promotable),
331
+ groomingStatusLine("considered", considered),
332
+ groomingStatusLine("blocked", blockedResults),
333
+ groomingStatusLine("mechanically blocked", mechanical),
334
+ groomingStatusLine("refused", refused),
335
+ groomingStatusLine("in-flight", inFlight),
336
+ ]) {
337
+ if (line !== undefined) lines.push(line);
338
+ }
339
+ if (parked > 0) lines.push(` ${"operator-parked".padEnd(21)}${parked}`);
340
+ if (lines.length === 0) return undefined;
341
+ return ["grooming (to-spec)", ...lines].join("\n");
342
+ }
343
+
209
344
  export function formatFleetStatus(
210
345
  s: StatusSnapshot,
211
346
  layers: FleetLayers,
@@ -220,6 +355,7 @@ export function formatFleetStatus(
220
355
  intake: string | undefined = undefined,
221
356
  lastStop: DaemonStop | undefined = undefined,
222
357
  siblings: { project: string; live: number }[] = [],
358
+ grooming: string | undefined = undefined,
223
359
  ): string {
224
360
  const tickLine =
225
361
  layers.ticksDetail === undefined
@@ -306,6 +442,7 @@ export function formatFleetStatus(
306
442
  ...(decisions === undefined ? [] : [decisions]),
307
443
  ...(failureClasses === undefined ? [] : [failureClasses]),
308
444
  ...(intake === undefined ? [] : [intake]),
445
+ ...(grooming === undefined ? [] : [grooming]),
309
446
  ...(graphBlock === undefined ? [] : [graphBlock]),
310
447
  daemonBlock,
311
448
  ...formatLastStop(lastStop),
package/src/to-spec.ts CHANGED
@@ -33,6 +33,13 @@
33
33
  * "strict"` to the task tool. Conductor re-validates whatever the agent
34
34
  * returned before persisting, because the harness is permissive by design
35
35
  * and the store vets only what this module hands it.
36
+ *
37
+ * One contract, every surface (#883): this Zod schema is the single source of
38
+ * truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
39
+ * `parseToSpecResult` persists against it, and the drift test pins the
40
+ * rendered brief, the shipped agent role, and the floor's return contract to
41
+ * exactly these fields — a field the prose asks for that the schema refuses
42
+ * is a candidate recorded blocked for following instructions.
36
43
  */
37
44
 
38
45
  import { z } from "zod";
@@ -96,6 +103,12 @@ const ToSpecResultSchema = z
96
103
  .optional()
97
104
  .describe("Required when `routing` is `MULTI`: what each slice goes to."),
98
105
  source: ToSpecSourceSchema,
106
+ evidence: z
107
+ .array(z.string().trim().min(1))
108
+ .optional()
109
+ .describe(
110
+ "The files/symbols proving the verdict — required for ALREADY DONE (the code that already does the work, never a title match); welcome on every verdict.",
111
+ ),
99
112
  laterWorkInvalidates: z
100
113
  .boolean()
101
114
  .describe("Whether later work (an epic committed after this candidate was filed) invalidated its premise."),
@@ -126,8 +139,10 @@ const ToSpecResultSchema = z
126
139
  .min(1)
127
140
  .describe("The files/dirs this slice writes — the file lane that serialises concurrent work."),
128
141
  dependencies: z
129
- .array(z.string().trim().min(1))
130
- .describe("Open prerequisites this work is blocked on; empty when none."),
142
+ .array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
143
+ .describe(
144
+ 'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
145
+ ),
131
146
  proposedBrief: z
132
147
  .string()
133
148
  .trim()
@@ -159,6 +174,12 @@ const ToSpecResultSchema = z
159
174
  ctx.addIssue({ code: "custom", message: `${verdict} must not carry proposedBrief` });
160
175
  }
161
176
  }
177
+ if (verdict === "ALREADY DONE" && (value.evidence === undefined || value.evidence.length === 0)) {
178
+ ctx.addIssue({
179
+ code: "custom",
180
+ message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
181
+ });
182
+ }
162
183
  if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
163
184
  ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
164
185
  }
package/src/types.ts CHANGED
@@ -638,26 +638,76 @@ export const REVIEW_MAX_ROUNDS_MAX = 6;
638
638
  * for real correction cycles, and a hard stop against endless polishing. */
639
639
  export const DEFAULT_REVIEW_MAX_ROUNDS = 3;
640
640
 
641
+ /**
642
+ * The one spelling a review policy may ask the adjudicator for: a token naming
643
+ * an OMP model role (`"task"`, `"worker"`, a project's own `modelRoles` key).
644
+ * A role name is never a provider/model — OMP owns provider selection and
645
+ * named model roles, and conductor stores only the role. Used by the config
646
+ * grammar and by the setup dialog's availability check; they must read the
647
+ * same shape or one of them accepts an answer the other rejects.
648
+ */
649
+ export const REVIEW_ADJUDICATOR_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
650
+
651
+ /**
652
+ * Which OMP model role carries a PR's terminal review-ceiling adjudication
653
+ * (#875) when no project policy names one. The deterministic migration default
654
+ * every existing project loads until its operator answers setup: the general
655
+ * OMP session role each install can launch without a provider or model being
656
+ * pinned here.
657
+ */
658
+ export const DEFAULT_REVIEW_ADJUDICATOR_ROLE = "task";
659
+
641
660
  /**
642
661
  * One project's review policy (#678): the strictness its orchestrator applies
643
- * when deciding whether a green PR is returned to its worker, and the hard
644
- * ceiling on how many such rounds one PR lifecycle may consume. The ceiling is
645
- * enforced by the dispatch side, never left to the session's reading of the
646
- * brief: at the ceiling `conductor_pr_review` refuses, the orchestrator leaves
647
- * the PR open, records the unresolved findings and escalates once.
662
+ * when deciding whether a green PR is returned to its worker, the hard
663
+ * ceiling on how many such rounds one PR lifecycle may consume, and which OMP
664
+ * model role carries the final adjudication once that ceiling is reached
665
+ * (#875). The ceiling is enforced by the dispatch side, never left to the
666
+ * session's reading of the brief: at the ceiling `conductor_pr_review`
667
+ * refuses, the orchestrator leaves the PR open, records the unresolved
668
+ * findings and escalates once.
648
669
  */
649
670
  export interface ReviewPolicy {
650
671
  strictness: ReviewStrictness;
651
672
  maxRounds: number;
673
+ /**
674
+ * The OMP model role that runs a PR's terminal review-ceiling adjudication:
675
+ * a named role from OMP's configured model roles (`"task"`, `"worker"`, or a
676
+ * project's own `modelRoles` key), in omp's model-role syntax — never a
677
+ * provider/model, which OMP owns. Absent in config loads as
678
+ * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE}, the deterministic default every
679
+ * existing install upgrades to.
680
+ */
681
+ adjudicator: string;
652
682
  }
653
683
 
654
- /** The documented migration default: what a config written before the key
684
+ /** The documented migration default: what a config written before the fields
655
685
  * existed loads as, deterministically, until the operator answers setup. */
656
686
  export const DEFAULT_REVIEW_POLICY: ReviewPolicy = {
657
687
  strictness: DEFAULT_REVIEW_STRICTNESS,
658
688
  maxRounds: DEFAULT_REVIEW_MAX_ROUNDS,
689
+ adjudicator: DEFAULT_REVIEW_ADJUDICATOR_ROLE,
659
690
  };
660
691
 
692
+ /**
693
+ * The resolved launch provenance of one review-ceiling adjudication (#875):
694
+ * what the policy named and what the role actually resolved to when the
695
+ * adjudicator session launched. OMP owns named roles and exact provider
696
+ * selection, so this record is where a launch's real model/provider lands —
697
+ * the typed fields later status/ledger code reads to show what genuinely
698
+ * adjudicated, not what a config said it should.
699
+ */
700
+ export interface ReviewAdjudicationProvenance {
701
+ /** The policy's adjudicator role, verbatim from {@link ReviewPolicy.adjudicator}. */
702
+ readonly role: string;
703
+ /** The model pattern the role resolved to at launch, in omp's model syntax. */
704
+ readonly model: string;
705
+ /** The provider OMP selected, when the harness could name one at launch. */
706
+ readonly provider?: string;
707
+ /** Epoch ms when this launch resolution was recorded. */
708
+ readonly resolvedAt: number;
709
+ }
710
+
661
711
  /**
662
712
  * One durable review-revision request (#677): the orchestrator returned a
663
713
  * green, run-owned pull request to its worker with blocking findings, and
@@ -2974,6 +3024,12 @@ export const VERB_REFUSALS = [
2974
3024
  /** The routed repository a terminal run recorded no longer has an entry in
2975
3025
  * this project's routing, so the daemon cannot create a PR for it. */
2976
3026
  "recovery-repo-unrouted",
3027
+ /** The run's durable `prUrl` is a non-URL value the tracker cannot address
3028
+ * (other than the normalized `pending` settlement sentinel, #866). There is
3029
+ * no PR to read and none to guess at — a corrupt record, not an unreadable
3030
+ * one, so it refuses by name instead of reading as a never-clearing
3031
+ * `head-unresolvable`. */
3032
+ "recorded-pr-corrupt",
2977
3033
  /** The run has no pull request to act on. */
2978
3034
  "pr-missing",
2979
3035
  /**
@@ -1751,8 +1751,9 @@ async function prReviewVerb(
1751
1751
  const findings = String(args["findings"]);
1752
1752
  const reason = String(args["reason"]) as ReviewReason;
1753
1753
 
1754
- // The shared review-readiness gate (#844): the newest project-owned run
1755
- // that recorded the PR, in a revisable state the same predicate the
1754
+ // The shared review-readiness gate (#844, #870): the newest project-owned
1755
+ // run that recorded the PR a stopped duplicate is transparent to that
1756
+ // selection (#870) — in a revisable state; the same predicate the
1756
1757
  // `pr-review-ready` watch reads, so the verb and the condition resolve
1757
1758
  // ownership and revisability identically. A PR no run of this project
1758
1759
  // opened (or one outside the routed repos) cannot be returned to a worker
@@ -1958,6 +1959,18 @@ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
1958
1959
  */
1959
1960
  const ABBREVIATED_HEAD_SHA = /^[0-9a-f]{7,39}$/i;
1960
1961
 
1962
+ /**
1963
+ * The durable PR field's worker settlement sentinel (#866). A run row whose
1964
+ * settlement recorded "no PR observed yet" carries the literal `pending`
1965
+ * instead of a URL. It asserts nothing about any GitHub pull request — there
1966
+ * is no PR to read — so recovery normalizes it to absence and re-proves the
1967
+ * no-duplicate shape before opening a PR, exactly like a row that recorded no
1968
+ * URL at all. Querying GitHub with it would only produce the tracker's
1969
+ * fail-closed `undefined`, which recovery would misread as an unreadable-but-
1970
+ * real PR and refuse as retryable forever.
1971
+ */
1972
+ const PR_PENDING_SENTINEL = "pending";
1973
+
1961
1974
  /**
1962
1975
  * Open (or adopt) the missing pull request for a settled run whose branch was
1963
1976
  * preserved (#806).
@@ -2302,7 +2315,29 @@ async function prRecoverVerb(
2302
2315
  );
2303
2316
  }
2304
2317
 
2305
- if (target.prUrl !== undefined) {
2318
+ // The durable PR field is normalized and validated before any recorded-PR
2319
+ // lookup (#866). A real recorded PR is a pull-request URL the tracker can
2320
+ // address — the `prUrlParts` shape, the same guard the tracker's own
2321
+ // `prState` applies. The `pending` settlement sentinel is not a URL: it
2322
+ // records the run believed a PR was in flight but never observed one, so it
2323
+ // is normalized to absence and recovery proceeds like a run with no
2324
+ // recorded PR. Any other non-URL value is corruption — there is nothing for
2325
+ // the tracker to read, and had it been queried the fail-closed `undefined`
2326
+ // would read as an unreadable-but-real PR that can never clear — so it
2327
+ // refuses by name instead of hanging the run on a retry.
2328
+ const recordedPrUrl = target.prUrl;
2329
+ const pendingSentinel = recordedPrUrl === PR_PENDING_SENTINEL;
2330
+ if (recordedPrUrl !== undefined && !pendingSentinel && prUrlParts(recordedPrUrl) === undefined) {
2331
+ return refuse(
2332
+ "recorded-pr-corrupt",
2333
+ `refused: run ${target.id} for #${issue} records prUrl ${JSON.stringify(recordedPrUrl)}, which is not a pull ` +
2334
+ "request URL. A non-URL durable prUrl is a corrupt run record, not an unreadable PR — recovery will not " +
2335
+ "guess a GitHub pull request from it. Clear or correct the run's prUrl, then retry.",
2336
+ issue,
2337
+ );
2338
+ }
2339
+
2340
+ if (!pendingSentinel && target.prUrl !== undefined) {
2306
2341
  // The run believes it has a PR. Before creating one, the recorded URL is
2307
2342
  // decided: still open → it is the recovery's answer (idempotent); merged →
2308
2343
  // the work landed; closed → a human declined it; definitively missing →