omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/store.ts CHANGED
@@ -31,6 +31,9 @@ import type {
31
31
  HeldNotice,
32
32
  InterruptCategory,
33
33
  HeldNoticeDraft,
34
+ IntakeDraft,
35
+ IntakeItem,
36
+ IntakeState,
34
37
  MaterialEvent,
35
38
  MaterialEventDraft,
36
39
  LabelOp,
@@ -41,6 +44,7 @@ import type {
41
44
  ReportKind,
42
45
  ReportRecord,
43
46
  RecoveryAction,
47
+ RunPatch,
44
48
  RunRecord,
45
49
  RunState,
46
50
  SessionRole,
@@ -292,6 +296,34 @@ interface MergeLockRow {
292
296
  at: number;
293
297
  }
294
298
 
299
+ /** The `intake_items` table exactly as SQLite hands it back (#299). */
300
+ interface IntakeRow {
301
+ id: string;
302
+ project: string;
303
+ text: string;
304
+ createdAt: number;
305
+ state: string;
306
+ issueUrl: string | null;
307
+ groomedAt: number | null;
308
+ }
309
+
310
+ /**
311
+ * NULL columns become absent properties, matching the other row converters:
312
+ * a record read back out of the store deep-equals the one that went in.
313
+ */
314
+ function toIntakeItem(row: IntakeRow): IntakeItem {
315
+ const item: IntakeItem = {
316
+ id: row.id,
317
+ project: row.project,
318
+ text: row.text,
319
+ createdAt: row.createdAt,
320
+ state: row.state as IntakeState,
321
+ ...(row.issueUrl === null ? {} : { issueUrl: row.issueUrl }),
322
+ ...(row.groomedAt === null ? {} : { groomedAt: row.groomedAt }),
323
+ };
324
+ return item;
325
+ }
326
+
295
327
  const SCHEMA = `
296
328
  CREATE TABLE IF NOT EXISTS runs (
297
329
  id TEXT PRIMARY KEY,
@@ -575,6 +607,23 @@ CREATE TABLE IF NOT EXISTS label_ops (
575
607
  lastError TEXT
576
608
  );
577
609
  CREATE INDEX IF NOT EXISTS label_ops_project_next ON label_ops (project, nextAttemptAt);
610
+
611
+ -- Raw ideas captured by the intake command (#299), held durably until
612
+ -- #300 resolves them into issues. A thought from 02:00 must survive a restart
613
+ -- and a reinstall, so it lives here rather than in any process's memory. The
614
+ -- state CHECK keeps the three-state vocabulary exact; issueUrl and groomedAt
615
+ -- stay NULL until an idea becomes (or is dropped instead of) an issue.
616
+ CREATE TABLE IF NOT EXISTS intake_items (
617
+ id TEXT PRIMARY KEY,
618
+ project TEXT NOT NULL,
619
+ text TEXT NOT NULL,
620
+ createdAt INTEGER NOT NULL,
621
+ state TEXT NOT NULL CHECK (state IN ('pending', 'groomed', 'dismissed')),
622
+ issueUrl TEXT,
623
+ groomedAt INTEGER
624
+ );
625
+ CREATE INDEX IF NOT EXISTS intake_items_project_state
626
+ ON intake_items (project, state, createdAt);
578
627
  `;
579
628
 
580
629
  /**
@@ -937,10 +986,10 @@ export function openStore(dbPath: string): Store {
937
986
  if (!columns.some((column) => column.name === "settlementFlags")) {
938
987
  db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
939
988
  }
940
- // Rows written before the settlement report was persisted (#199) have no
941
- // text to pool across attempts, so a continuation reads empty priors and
942
- // audits exactly as it did before this release. No backfill: the bytes only
943
- // ever existed in a worker's memory.
989
+ // The settlement report was first persisted in #199; rows written before it
990
+ // simply have no report, which is the honest reading of a kill that never
991
+ // produced one. No backfill: the bytes only ever existed in a worker's
992
+ // memory.
944
993
  if (!columns.some((column) => column.name === "report")) {
945
994
  db.exec("ALTER TABLE runs ADD COLUMN report TEXT");
946
995
  }
@@ -1101,6 +1150,38 @@ export function openStore(dbPath: string): Store {
1101
1150
  AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
1102
1151
  ORDER BY startedAt DESC`,
1103
1152
  );
1153
+ // Every attempt that recorded one PR URL, newest first — deliberately NOT the
1154
+ // `recentRuns` newest-attempt-per-issue view. Mediated PR verbs resolve a
1155
+ // PR's owner through this: a requeued continuation hides its predecessor's
1156
+ // PR from `recentRuns` the moment it is created, so ownership must be able
1157
+ // to read past the issue's newest row (#434). Merged rows keep the same
1158
+ // recent-history bound as `recentRuns`.
1159
+ const selectRunsForPr = db.query<RunRow, [string, string, number]>(
1160
+ `SELECT * FROM runs
1161
+ WHERE project = ? AND prUrl = ?
1162
+ AND (state <> 'merged' OR COALESCE(endedAt, startedAt) >= ?)
1163
+ ORDER BY startedAt DESC, rowid DESC`,
1164
+ );
1165
+ // The stats window (#282): rows settling at or after `since` — merged rows
1166
+ // included, so a chain whose PR merged inside the window is an outcome —
1167
+ // plus, for those merged issues, their WHOLE attempt chain even when it
1168
+ // started before the window. A continuation that began weeks ago and merged
1169
+ // today must report one lead time from its first claim, not from the window
1170
+ // start; ordering by issue/startedAt makes the first row of each chain its
1171
+ // first queue-label claim.
1172
+ const selectStatsRuns = db.query<RunRow, [string, number, string, number]>(
1173
+ `SELECT * FROM runs
1174
+ WHERE project = ?
1175
+ AND (
1176
+ COALESCE(endedAt, startedAt) >= ?
1177
+ OR issue IN (
1178
+ SELECT issue FROM runs
1179
+ WHERE project = ? AND state = 'merged'
1180
+ AND COALESCE(endedAt, startedAt) >= ?
1181
+ )
1182
+ )
1183
+ ORDER BY issue ASC, startedAt ASC, rowid ASC`,
1184
+ );
1104
1185
  const selectPendingBaseChecks = db.query<RunRow, [string, number]>(
1105
1186
  `SELECT * FROM runs
1106
1187
  WHERE project = ? AND state = 'merged' AND baseCheck = 'pending'
@@ -1215,6 +1296,14 @@ export function openStore(dbPath: string): Store {
1215
1296
  ORDER BY startedAt DESC, rowid DESC
1216
1297
  LIMIT 1`,
1217
1298
  );
1299
+ // Every attempt for one issue, oldest first. An exhaustion escalation groups
1300
+ // these by `failureClass` to report how the continuation budget was spent
1301
+ // (#439); the numeric `*For` counters cannot reconstruct that on their own.
1302
+ const selectRunsForIssue = db.query<RunRow, [string, number]>(
1303
+ `SELECT * FROM runs
1304
+ WHERE project = ? AND issue = ?
1305
+ ORDER BY startedAt ASC, rowid ASC`,
1306
+ );
1218
1307
  const upsertTurnOverride = db.query<unknown, [string, number, number, number]>(
1219
1308
  `INSERT INTO turn_overrides (project, issue, maxTurns, setAt) VALUES (?, ?, ?, ?)
1220
1309
  ON CONFLICT(project, issue) DO UPDATE SET
@@ -1247,14 +1336,8 @@ export function openStore(dbPath: string): Store {
1247
1336
  ORDER BY setAt DESC, rowid DESC
1248
1337
  LIMIT ?`,
1249
1338
  );
1250
- // Every attempt that settled with a report, in attempt order. NULLs are the
1251
- // rows written before #199, or one killed before the worker returned a
1252
- // report — skipping them makes a continuation read exactly today's priors.
1253
- const selectAttemptReports = db.query<{ attempt: number; report: string }, [string, number]>(
1254
- `SELECT attempt, report FROM runs
1255
- WHERE project = ? AND issue = ? AND report IS NOT NULL
1256
- ORDER BY attempt`,
1257
- );
1339
+ // The settlement report row is kept for the audit trail; nothing pools it
1340
+ // across attempts any more (#488).
1258
1341
  const countStartedSince = db.query<{ n: number }, [string, number]>(
1259
1342
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
1260
1343
  );
@@ -1331,6 +1414,10 @@ export function openStore(dbPath: string): Store {
1331
1414
  const selectGhCalls = db.query<{ source: string; calls: number }, [string]>(
1332
1415
  `SELECT source, calls FROM gh_calls WHERE day = ? ORDER BY source`,
1333
1416
  );
1417
+ const sumGhCallsBetween = db.query<{ total: number }, [string, string]>(
1418
+ `SELECT COALESCE(SUM(calls), 0) AS total FROM gh_calls
1419
+ WHERE day BETWEEN ? AND ?`,
1420
+ );
1334
1421
 
1335
1422
  // The report outbox (#123). Every terminal transition names the attempt it is
1336
1423
  // settling, because a request can come back *after* the stale-`sending` sweep
@@ -1531,12 +1618,20 @@ export function openStore(dbPath: string): Store {
1531
1618
  LIMIT ?`,
1532
1619
  );
1533
1620
  const selectHeldNoticeBacklog = db.query<
1534
- { count: number; availabilityCount: number | null; oldestAt: number | null },
1621
+ {
1622
+ count: number;
1623
+ availabilityCount: number | null;
1624
+ oldestAt: number | null;
1625
+ digestOldestAt: number | null;
1626
+ availabilityOldestAt: number | null;
1627
+ },
1535
1628
  [string]
1536
1629
  >(
1537
1630
  `SELECT COUNT(*) AS count,
1538
1631
  SUM(CASE WHEN releaseOnAvailable = 1 THEN 1 ELSE 0 END) AS availabilityCount,
1539
- MIN(createdAt) AS oldestAt
1632
+ MIN(createdAt) AS oldestAt,
1633
+ MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
1634
+ MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
1540
1635
  FROM held_notices
1541
1636
  WHERE project = ? AND (
1542
1637
  (digestReportId IS NULL AND digestedAt IS NULL)
@@ -1581,6 +1676,23 @@ export function openStore(dbPath: string): Store {
1581
1676
  OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
1582
1677
  )`,
1583
1678
  );
1679
+ const insertIntake = db.query<unknown, SqlValue[]>(
1680
+ `INSERT INTO intake_items (id, project, text, createdAt, state)
1681
+ VALUES (?, ?, ?, ?, 'pending')`,
1682
+ );
1683
+ const selectPendingIntake = db.query<IntakeRow, [string]>(
1684
+ `SELECT * FROM intake_items
1685
+ WHERE project = ? AND state = 'pending'
1686
+ ORDER BY createdAt ASC, rowid ASC`,
1687
+ );
1688
+ const groomIntake = db.query<unknown, SqlValue[]>(
1689
+ `UPDATE intake_items SET state = 'groomed', issueUrl = ?, groomedAt = ?
1690
+ WHERE id = ? AND state = 'pending'`,
1691
+ );
1692
+ const dismissIntake = db.query<unknown, [string]>(
1693
+ `UPDATE intake_items SET state = 'dismissed', issueUrl = NULL, groomedAt = NULL
1694
+ WHERE id = ? AND state = 'pending'`,
1695
+ );
1584
1696
  // The newest digest dedupe key a project has actually run toward — an
1585
1697
  // `updatedAt`-newest row whose key is a digest prefix and that did not end in
1586
1698
  // failure. The CLI refuses an off-schedule digest by comparing this against
@@ -1949,11 +2061,15 @@ export function openStore(dbPath: string): Store {
1949
2061
  return createRunTx(r);
1950
2062
  },
1951
2063
 
1952
- updateRun(id: string, patch: Partial<RunRecord>): void {
2064
+ updateRun(id: string, patch: RunPatch): void {
1953
2065
  const assignments: string[] = [];
1954
2066
  const values: SqlValue[] = [];
1955
2067
  for (const [column, value] of Object.entries(patch)) {
1956
- if (!UPDATABLE_COLUMNS[column]) continue;
2068
+ // An explicitly-`undefined` key is the same as an absent one: a patch
2069
+ // built by spreading a partial result must not widen into columns the
2070
+ // caller did not mean to write (#468). Clearing a column still works by
2071
+ // naming it with an explicit `null`, which `toSql` maps to SQL NULL.
2072
+ if (!UPDATABLE_COLUMNS[column] || value === undefined) continue;
1957
2073
  assignments.push(`${column} = ?`);
1958
2074
  values.push(toSql(value));
1959
2075
  }
@@ -1986,6 +2102,9 @@ export function openStore(dbPath: string): Store {
1986
2102
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[] {
1987
2103
  return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
1988
2104
  },
2105
+ runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[] {
2106
+ return selectRunsForPr.all(project, prUrl, mergedSinceEpochMs).map(toRecord);
2107
+ },
1989
2108
  runsNeedingBaseCheck(project: string, limit = 20): RunRecord[] {
1990
2109
  return selectPendingBaseChecks.all(project, limit).map(toRecord);
1991
2110
  },
@@ -2041,6 +2160,9 @@ export function openStore(dbPath: string): Store {
2041
2160
  const row = selectLatestRun.get(project, issue);
2042
2161
  return row ? toRecord(row) : undefined;
2043
2162
  },
2163
+ runsForIssue(project: string, issue: number): RunRecord[] {
2164
+ return selectRunsForIssue.all(project, issue).map(toRecord);
2165
+ },
2044
2166
 
2045
2167
  setTurnOverride(project: string, issue: number, maxTurns: number, setAt = Date.now()): void {
2046
2168
  setTurnOverrideTx(project, issue, maxTurns, setAt);
@@ -2066,11 +2188,6 @@ export function openStore(dbPath: string): Store {
2066
2188
  return selectTurnOverrideLedger.all(project, issue, issue, opts.limit ?? 50);
2067
2189
  },
2068
2190
 
2069
- attemptReports(project: string, issue: number): { attempt: number; report: string }[] {
2070
- return selectAttemptReports.all(project, issue);
2071
- },
2072
-
2073
-
2074
2191
  runsStartedSince(project: string, sinceEpochMs: number): number {
2075
2192
  return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
2076
2193
  },
@@ -2079,6 +2196,10 @@ export function openStore(dbPath: string): Store {
2079
2196
  return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
2080
2197
  },
2081
2198
 
2199
+ statsRuns(project: string, sinceEpochMs: number): RunRecord[] {
2200
+ return selectStatsRuns.all(project, sinceEpochMs, project, sinceEpochMs).map(toRecord);
2201
+ },
2202
+
2082
2203
  wasNotified(key: string): boolean {
2083
2204
  return (countNotified.get(key)?.n ?? 0) > 0;
2084
2205
  },
@@ -2203,6 +2324,12 @@ export function openStore(dbPath: string): Store {
2203
2324
  ...(held?.oldestAt === null || held?.oldestAt === undefined
2204
2325
  ? {}
2205
2326
  : { heldNoticeOldestAt: held.oldestAt }),
2327
+ ...(held?.digestOldestAt === null || held?.digestOldestAt === undefined
2328
+ ? {}
2329
+ : { digestHeldNoticeOldestAt: held.digestOldestAt }),
2330
+ ...(held?.availabilityOldestAt === null || held?.availabilityOldestAt === undefined
2331
+ ? {}
2332
+ : { availabilityHeldNoticeOldestAt: held.availabilityOldestAt }),
2206
2333
  };
2207
2334
  },
2208
2335
 
@@ -2226,6 +2353,10 @@ export function openStore(dbPath: string): Store {
2226
2353
  return selectGhCalls.all(day);
2227
2354
  },
2228
2355
 
2356
+ ghCallsBetween(sinceDay: string, untilDay: string): number {
2357
+ return sumGhCallsBetween.get(sinceDay, untilDay)?.total ?? 0;
2358
+ },
2359
+
2229
2360
  pendingFriction(
2230
2361
  project: string,
2231
2362
  sinceEpochMs: number,
@@ -2375,6 +2506,31 @@ export function openStore(dbPath: string): Store {
2375
2506
  .map(toReport);
2376
2507
  },
2377
2508
 
2509
+ /**
2510
+ * One escalation handoff per event while the first has not landed (#347,
2511
+ * #453). The notifications ledger cannot decide this — its rows are
2512
+ * permanent, so an identical retry would be refused forever once the first
2513
+ * occurrence was marked (#453). The question is instead "is the first
2514
+ * handoff still owed?": an escalation report still in the outbox (the
2515
+ * `openReports` pending/sending/failed set) for the same project, category
2516
+ * and folded text, or a held notice of the same category and text that has
2517
+ * not been digested yet. Once the report is delivered, or the notice
2518
+ * digested, the identical text is a new occurrence, not a double-send.
2519
+ * Material reports describe one ordinary outcome each time and never
2520
+ * dedupe.
2521
+ */
2522
+ hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean {
2523
+ const flat = body.replace(/\s+/g, " ").trim();
2524
+ return (
2525
+ selectOpenReports
2526
+ .all(project)
2527
+ .some((row) => row.kind === category && row.body.replace(/\s+/g, " ").trim() === flat) ||
2528
+ selectUndigestedNotices
2529
+ .all(project, DIGEST_BACKLOG_LIMIT)
2530
+ .some((row) => row.category === category && row.detail.replace(/\s+/g, " ").trim() === flat)
2531
+ );
2532
+ },
2533
+
2378
2534
  openReports(project: string): ReportRecord[] {
2379
2535
  return selectOpenReports.all(project).map(toReport);
2380
2536
  },
@@ -2421,6 +2577,11 @@ export function openStore(dbPath: string): Store {
2421
2577
  return selectOpenDecisions.all(project).map(toDecision);
2422
2578
  },
2423
2579
 
2580
+ decision(id: string): DecisionRecord | undefined {
2581
+ const row = selectDecision.get(id);
2582
+ return row === null ? undefined : toDecision(row);
2583
+ },
2584
+
2424
2585
  resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean {
2425
2586
  return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
2426
2587
  },
@@ -2532,6 +2693,30 @@ export function openStore(dbPath: string): Store {
2532
2693
  return row === null ? undefined : row.createdAt;
2533
2694
  },
2534
2695
 
2696
+ recordIntake(draft: IntakeDraft): IntakeItem {
2697
+ const item: IntakeItem = {
2698
+ id: newReportId(),
2699
+ project: draft.project,
2700
+ text: draft.text,
2701
+ createdAt: draft.at,
2702
+ state: "pending",
2703
+ };
2704
+ insertIntake.run(item.id, item.project, item.text, item.createdAt);
2705
+ return item;
2706
+ },
2707
+
2708
+ pendingIntake(project: string): IntakeItem[] {
2709
+ return selectPendingIntake.all(project).map(toIntakeItem);
2710
+ },
2711
+
2712
+ resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean {
2713
+ const result =
2714
+ state === "groomed"
2715
+ ? groomIntake.run(issueUrl ?? null, Date.now(), id)
2716
+ : dismissIntake.run(id);
2717
+ return result.changes > 0;
2718
+ },
2719
+
2535
2720
  close(): void {
2536
2721
  db.close(false);
2537
2722
  },
@@ -136,7 +136,7 @@ export class GhError extends Error {
136
136
  * and closed, which is how bodies containing newlines, backticks or a leading
137
137
  * `-` reach `gh` without ever being interpolated into argv.
138
138
  */
139
- async function gh(argv: string[], stdin?: string): Promise<string> {
139
+ export async function gh(argv: string[], stdin?: string): Promise<string> {
140
140
  const proc = Bun.spawn(["gh", ...argv], {
141
141
  // Always a closed stream: commands that do not read stdin see immediate
142
142
  // EOF instead of an open pipe nobody ends.
@@ -392,14 +392,33 @@ function checkVerdict(check: GhCheck): CheckVerdict {
392
392
  * Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
393
393
  * payload, optionally against a caller-observed head. A missing rollup is
394
394
  * pending rather than green because GitHub may not have created the checks yet.
395
+ *
396
+ * By default this answers the merge gate's question — "may this merge" — and a
397
+ * non-open PR is a refusal (`expected OPEN`), so a mutating verb never acts on
398
+ * a merged or closed PR. `opts.read` (the `conductor_pr_status` surface) asks
399
+ * a different question: *what is this PR's state*, and a merged or closed PR
400
+ * is reported as a fact rather than a failure — the merge gate paths never
401
+ * pass `read`, so the `expected OPEN` refusal stays exactly where it gates.
395
402
  */
396
- export function prVerificationFrom(raw: string, expectedHead?: string): PrVerification {
403
+ export function prVerificationFrom(
404
+ raw: string,
405
+ expectedHead?: string,
406
+ opts?: { read?: boolean },
407
+ ): PrVerification {
397
408
  const pr = JSON.parse(raw) as GhPrVerification;
398
409
  if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") {
399
410
  throw new Error("PR head is unavailable");
400
411
  }
401
412
  const headSha = pr.headRefOid;
402
413
  if (pr.state !== "OPEN") {
414
+ if (opts?.read === true) {
415
+ const stateName = pr.state?.toLowerCase() ?? "unknown";
416
+ return {
417
+ status: "failed",
418
+ reason: `PR is ${stateName}; checks are unavailable for a non-open PR`,
419
+ headSha,
420
+ };
421
+ }
403
422
  return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha };
404
423
  }
405
424
  if (pr.isDraft !== false) {
@@ -1275,7 +1294,11 @@ export function makeTracker(
1275
1294
  }
1276
1295
  },
1277
1296
 
1278
- async verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined> {
1297
+ async verifyPr(
1298
+ url: string,
1299
+ expectedHead?: string,
1300
+ opts?: { read?: boolean },
1301
+ ): Promise<PrVerification | undefined> {
1279
1302
  if (!PR_URL.test(url)) return undefined;
1280
1303
  try {
1281
1304
  const raw = await runGh([
@@ -1285,7 +1308,7 @@ export function makeTracker(
1285
1308
  "--json",
1286
1309
  "state,isDraft,headRefOid,statusCheckRollup",
1287
1310
  ]);
1288
- const verification = prVerificationFrom(raw, expectedHead);
1311
+ const verification = prVerificationFrom(raw, expectedHead, opts);
1289
1312
  if (verification.status !== "failed") return verification;
1290
1313
 
1291
1314
  const detailsUrl = failedCheck(raw)?.detailsUrl;