omp-conductor 0.9.1 → 0.12.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.
package/src/setup.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  resolveCaps,
41
41
  resolvePolicy,
42
42
  resolveReleaseGrants,
43
+ SCOPE_PRESETS,
43
44
  stateDir,
44
45
  } from "./config.ts";
45
46
  import { graphProjectPath, graphRepos } from "./graph.ts";
@@ -61,6 +62,8 @@ import {
61
62
  type ProjectPolicy,
62
63
  type ReleaseRequirement,
63
64
  type ReportScope,
65
+ type ReportScopeChoice,
66
+ type ReportingPolicy,
64
67
  type RepoTarget,
65
68
  type ResolvedGrants,
66
69
  } from "./types.ts";
@@ -88,7 +91,9 @@ export interface SetupAnswers {
88
91
  telegramChatId?: string;
89
92
  fallbackToIssueComment: boolean;
90
93
  /** How loud the supervising orchestrator session should be. */
91
- reportScope: ReportScope;
94
+ reportScope: ReportScopeChoice;
95
+ /** The daily digest wall-clock (`HH:MM`) for the `quiet` choice, blank = model-timed. */
96
+ quietDigestAt?: string;
92
97
  /**
93
98
  * Mechanical gate for release/deploy-shaped tool calls, per shape. Always
94
99
  * complete: the wizard asks about every shape, so an answer object can never
@@ -175,7 +180,11 @@ export const SETUP_DEFAULTS = {
175
180
  * brief spells the same three options out — so "material" cannot come to mean
176
181
  * one thing in the dialog and another in the session that has to honour it.
177
182
  */
178
- export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string; description: string }[] = [
183
+ export const REPORT_SCOPE_CHOICES: readonly {
184
+ scope: ReportScopeChoice;
185
+ label: string;
186
+ description: string;
187
+ }[] = [
179
188
  {
180
189
  scope: "decisions",
181
190
  label: "Decisions interrupt, rest batches",
@@ -192,6 +201,12 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
192
201
  label: "Escalations only",
193
202
  description: "escalations when they happen, plus one daily digest — silent otherwise",
194
203
  },
204
+ {
205
+ scope: "quiet",
206
+ label: "Quiet (scheduled digest)",
207
+ description:
208
+ "interrupt only for escalations, fleet stops and confirmed failures; one daily rollup on your schedule",
209
+ },
195
210
  ];
196
211
 
197
212
  /**
@@ -204,7 +219,7 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
204
219
  * answers "an operator being asked the question for the first time", where the
205
220
  * recommended answer is the useful one — and they see it, and confirm it.
206
221
  */
207
- export const SETUP_DEFAULT_REPORT_SCOPE: ReportScope = REPORT_SCOPE_CHOICES[0]!.scope;
222
+ export const SETUP_DEFAULT_REPORT_SCOPE: ReportScopeChoice = REPORT_SCOPE_CHOICES[0]!.scope;
208
223
 
209
224
  /**
210
225
  * What each precondition value means to the operator being asked about it, in
@@ -560,8 +575,24 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
560
575
  // and a release require without anyone having to know a default (#129).
561
576
  policy: clonePolicy(a.policy),
562
577
  // Written out even when it is the default, so an operator amending the
563
- // volume has a line in the file to point at.
564
- reporting: { scope: a.reportScope },
578
+ // volume has a line in the file to point at. A `scope` answer is
579
+ // materialised as its preset policy (with `scopePreset` set) so the file
580
+ // and the prompt agree with no migration rule; `quiet` is the explicit
581
+ // form — a curated interrupt list and a scheduled digest (#229).
582
+ reporting:
583
+ a.reportScope === "quiet"
584
+ ? {
585
+ interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
586
+ digest: {
587
+ cadence: "daily",
588
+ ...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
589
+ },
590
+ }
591
+ : {
592
+ interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
593
+ digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
594
+ scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
595
+ },
565
596
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
566
597
  // uninstall, and neither can land in a repo the daemon then tries to commit.
567
598
  workspaceRoot: defaultWorkspaceRoot(),
@@ -667,7 +698,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
667
698
  releaseGrants: resolveReleaseGrants(p),
668
699
  policy: resolvePolicy(p),
669
700
  orchestratorMode: p.escalation.orchestrator,
670
- reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
701
+ reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
671
702
  writeOrchestratorBrief: false,
672
703
  };
673
704
 
@@ -704,7 +735,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
704
735
  QUEUE_LABEL: p.queueLabel,
705
736
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
706
737
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
707
- REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
738
+ REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
708
739
  POLICY_SOURCE: policySourceLine(p),
709
740
  };
710
741
  }
@@ -1234,7 +1265,7 @@ export const AMEND_AREAS: {
1234
1265
  name: "reporting scope",
1235
1266
  asks: "how much the orchestrator says unprompted",
1236
1267
  describe: (p) => {
1237
- const scope = p.reporting?.scope ?? DEFAULT_REPORT_SCOPE;
1268
+ const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
1238
1269
  const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
1239
1270
  return `${scope} — ${choice?.description ?? "unknown scope"}`;
1240
1271
  },
package/src/store.ts CHANGED
@@ -26,6 +26,8 @@ import type {
26
26
  FrictionKind,
27
27
  FrictionObservation,
28
28
  FrictionSignal,
29
+ HeldNotice,
30
+ HeldNoticeDraft,
29
31
  LabelOp,
30
32
  MergeLock,
31
33
  ReportDeliveryState,
@@ -332,6 +334,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
332
334
  CREATE INDEX IF NOT EXISTS reports_project_state
333
335
  ON reports (project, state, nextAttemptAt);
334
336
 
337
+ -- Interrupts a project's reporting.interruptOn policy deferred to the digest
338
+ -- (#229): an escalation that must not page the operator's phone now is held
339
+ -- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
340
+ -- digest pass and no longer owed. A table rather than a flag on reports: these
341
+ -- are escalations that never reached a send, distinct from reports the model
342
+ -- wrote on purpose.
343
+ CREATE TABLE IF NOT EXISTS held_notices (
344
+ id TEXT PRIMARY KEY,
345
+ project TEXT NOT NULL,
346
+ category TEXT NOT NULL,
347
+ summary TEXT NOT NULL,
348
+ detail TEXT NOT NULL,
349
+ createdAt INTEGER NOT NULL,
350
+ digestedAt INTEGER
351
+ );
352
+ CREATE INDEX IF NOT EXISTS held_notices_project_undigested
353
+ ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
354
+
335
355
  -- The action ledger for the conductor-owned mutation verbs (#126). Every
336
356
  -- decided call lands here, refusals included: the question an escalation asks
337
357
  -- is "what did this run try", and a table that only records what succeeded
@@ -902,12 +922,12 @@ export function openStore(dbPath: string): Store {
902
922
  const countFailures = db.query<{ n: number }, [string, number]>(
903
923
  `SELECT COUNT(*) AS n FROM runs
904
924
  WHERE project = ? AND issue = ? AND state = 'failed'
905
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
925
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
906
926
  );
907
927
  const countContinuations = db.query<{ n: number }, [string, number]>(
908
928
  `SELECT COUNT(*) AS n FROM runs
909
929
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
910
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
930
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
911
931
  );
912
932
  // How many times one issue reached a given class. Recovery uses it to bound a
913
933
  // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
@@ -1079,6 +1099,33 @@ export function openStore(dbPath: string): Store {
1079
1099
  ORDER BY createdAt ASC, rowid ASC
1080
1100
  LIMIT ?`,
1081
1101
  );
1102
+
1103
+ // Held notices (#229): escalations deferred to the digest by the project's
1104
+ // `reporting.interruptOn` policy. `digestedAt` NULL = still owed.
1105
+ const insertHeldNotice = db.query<unknown, SqlValue[]>(
1106
+ `INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
1107
+ VALUES (?, ?, ?, ?, ?, ?, NULL)`,
1108
+ );
1109
+ const selectUndigestedNotices = db.query<
1110
+ { id: string; category: string; summary: string; detail: string; createdAt: number },
1111
+ [string]
1112
+ >(
1113
+ `SELECT id, category, summary, detail, createdAt FROM held_notices
1114
+ WHERE project = ? AND digestedAt IS NULL
1115
+ ORDER BY createdAt ASC`,
1116
+ );
1117
+ const markNoticesDigestedRow = db.query<unknown, [number, string]>(
1118
+ `UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
1119
+ );
1120
+ // The newest digest dedupe key a project has actually run toward — an
1121
+ // `updatedAt`-newest row whose key is a digest prefix and that did not end in
1122
+ // failure. The CLI refuses an off-schedule digest by comparing this against
1123
+ // today's key under `digestDue` (#229).
1124
+ const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
1125
+ `SELECT dedupeKey AS key FROM reports
1126
+ WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
1127
+ ORDER BY createdAt DESC LIMIT 1`,
1128
+ );
1082
1129
  const claimReportRow = db.query<unknown, [string, number, string, number]>(
1083
1130
  `UPDATE reports
1084
1131
  SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
@@ -1445,6 +1492,30 @@ export function openStore(dbPath: string): Store {
1445
1492
  return row === null ? undefined : toDispatchSummary(row.summary);
1446
1493
  },
1447
1494
 
1495
+ lastDigestDedupeKey(project: string): string | undefined {
1496
+ const row = selectLastDigestKey.get(project);
1497
+ return row?.key ?? undefined;
1498
+ },
1499
+
1500
+ addHeldNotice(notice: HeldNoticeDraft): void {
1501
+ insertHeldNotice.run(
1502
+ crypto.randomUUID(),
1503
+ notice.project,
1504
+ notice.category,
1505
+ notice.summary,
1506
+ notice.detail,
1507
+ notice.createdAt,
1508
+ );
1509
+ },
1510
+
1511
+ undigestedNotices(project: string): HeldNotice[] {
1512
+ return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
1513
+ },
1514
+
1515
+ markNoticesDigested(project: string, at: number): void {
1516
+ markNoticesDigestedRow.run(at, project);
1517
+ },
1518
+
1448
1519
  recordFriction,
1449
1520
 
1450
1521
  recordGhRefusal,
package/src/types.ts CHANGED
@@ -114,6 +114,14 @@ export interface RepoTarget {
114
114
  * the worker brief.
115
115
  */
116
116
  graphProject?: string;
117
+ /**
118
+ * Ordered-migration-chain guard (#227): repo-relative directory whose *.py
119
+ * files declare `revision`/`down_revision`. When set, `conductor_pr_merge`
120
+ * refuses a merge that would collide with the base branch's chain (id reuse,
121
+ * a deleted migration, or a combined graph with more than one head). Absent
122
+ * = no chain check.
123
+ */
124
+ migrations?: { dir: string };
117
125
  }
118
126
 
119
127
  /**
@@ -140,6 +148,59 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
140
148
  */
141
149
  export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
142
150
 
151
+ /**
152
+ * The four answers the wizard offers for "how loud should the orchestrator be".
153
+ * Three are legacy presets; `quiet` is the explicit form: interrupt only for
154
+ * escalations, fleet stops and confirmed failures, with one daily rollup
155
+ * (#229).
156
+ */
157
+ export type ReportScopeChoice = ReportScope | "quiet";
158
+
159
+ /**
160
+ * Which categories may interrupt the operator's phone. Each maps to a tier-2
161
+ * escalation channel: `tier2` (a plain decision), `decision-needed`, a
162
+ * fleet-stopping condition, a confirmed failure, or any material event. What an
163
+ * `interruptOn` list omits is not dropped — it accumulates for the digest.
164
+ * Data rather than booleans so the config validator, the escalator and the
165
+ * orchestrator prompt all enumerate the same vocabulary (#229).
166
+ */
167
+ export const INTERRUPT_CATEGORIES = [
168
+ "tier2",
169
+ "decision-needed",
170
+ "fleet-stopped",
171
+ "confirmed-failure",
172
+ "material",
173
+ ] as const;
174
+
175
+ export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
176
+
177
+ /** When the daily/report rollup is due. */
178
+ export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
179
+
180
+ export type DigestCadence = (typeof DIGEST_CADENCES)[number];
181
+
182
+ /**
183
+ * Explicit reporting policy: which categories interrupt, and when the daily
184
+ * rollup happens. `scopePreset` is set when the config came from a legacy
185
+ * `scope:` preset, and drives the preset tick prompt text.
186
+ */
187
+ export interface ReportingPolicy {
188
+ interruptOn: InterruptCategory[];
189
+ digest: { cadence: DigestCadence; at?: string; timezone?: string };
190
+ /** Set when the policy came from a legacy `scope` preset. */
191
+ scopePreset?: ReportScope;
192
+ }
193
+
194
+ /**
195
+ * The default policy: the legacy `material` preset, spelled out so an explicit
196
+ * config can name it and the wizard can show it.
197
+ */
198
+ export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
199
+ interruptOn: [...INTERRUPT_CATEGORIES],
200
+ digest: { cadence: "per-tick" },
201
+ scopePreset: DEFAULT_REPORT_SCOPE,
202
+ };
203
+
143
204
  /**
144
205
  * The irreversible tool-call shapes this package recognises and gates. Data
145
206
  * here rather than in `release-policy.ts` because the config validator, the
@@ -431,6 +492,9 @@ export const LABEL_REASONS = [
431
492
  "promoted-to-queue",
432
493
  /** Re-briefed after a failed attempt, and put back on the queue. */
433
494
  "re-briefed",
495
+ /** Delivered: the PR merged and the issue closed, and the queue label comes
496
+ * off as completed lifecycle cleanup — not superseded, not a re-brief (#230). */
497
+ "completed",
434
498
  /** Parked: it needs a decision only a human can make. */
435
499
  "needs-human",
436
500
  "duplicate",
@@ -534,11 +598,13 @@ export interface ProjectConfig {
534
598
  */
535
599
  policy?: ProjectPolicy;
536
600
  /**
537
- * How loud the orchestrator is. Optional on disk a config written before
538
- * this key existed loads as {@link DEFAULT_REPORT_SCOPE} so read it through
539
- * `resolveReportScope` rather than reaching for `.scope` directly.
601
+ * How loud the orchestrator is, and when the daily rollup happens. Optional
602
+ * on disk a config written before this key existed loads as
603
+ * {@link DEFAULT_REPORT_POLICY} so read it through `resolveReportScope`
604
+ * rather than reaching for `.scope` directly. A legacy `scope:` value is
605
+ * accepted and materialised as a preset (`.scopePreset`).
540
606
  */
541
- reporting?: { scope: ReportScope };
607
+ reporting?: ReportingPolicy;
542
608
  /** Parent directory for per-run worktrees. */
543
609
  workspaceRoot: string;
544
610
  /** Cache of bare clones, so N runs share one fetch instead of N. */
@@ -831,6 +897,8 @@ export const FAILURE_CLASSES = [
831
897
  "orphan-clean",
832
898
  "orphan-dirty",
833
899
  "settlement-stuck",
900
+ "provider-credit",
901
+ "provider-transient",
834
902
  "unknown",
835
903
  ] as const;
836
904
 
@@ -968,6 +1036,9 @@ export interface DispatchSummary {
968
1036
  ready: number;
969
1037
  routed: number;
970
1038
  admitted: number;
1039
+ /** Ready issues carrying a state label — claimed by a live or settling run.
1040
+ * Optional: persisted old rows lack it, so readers use `?? 0` (#228). */
1041
+ claimed?: number;
971
1042
  /** True only for system/API failures, never ordinary policy holds. */
972
1043
  degraded: boolean;
973
1044
  holds: AdmissionHoldSummary[];
@@ -1222,6 +1293,15 @@ export interface Store {
1222
1293
  wasNotified(key: string): boolean;
1223
1294
  recordDispatch(project: string, summary: DispatchSummary): void;
1224
1295
  latestDispatch(project: string): DispatchSummary | undefined;
1296
+ /** The newest `digest:` dedupe key a project has run toward without ending in
1297
+ * failure — what the off-schedule-digest refusal compares against (#229). */
1298
+ lastDigestDedupeKey(project: string): string | undefined;
1299
+ /** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
1300
+ addHeldNotice(notice: HeldNoticeDraft): void;
1301
+ /** Held notices still owed (not yet re-surfaced by a digest pass). */
1302
+ undigestedNotices(project: string): HeldNotice[];
1303
+ /** Mark every undigested notice for a project as re-surfaced. */
1304
+ markNoticesDigested(project: string, at: number): void;
1225
1305
  /** Add one bounded observation to the per-day friction rollup. */
1226
1306
  recordFriction(project: string, observation: FrictionObservation): void;
1227
1307
  /** Repeated signals not surfaced within the supplied cooldown window. */
@@ -1384,6 +1464,28 @@ export interface Escalation {
1384
1464
  summary: string;
1385
1465
  detail?: string;
1386
1466
  runId?: string;
1467
+ /** Tier-2-only interrupt category; defaults to `"tier2"`. Drives whether the
1468
+ * escalation pages now or is held for the digest under the project's
1469
+ * `reporting.interruptOn` policy (#229). */
1470
+ category?: InterruptCategory;
1471
+ }
1472
+
1473
+ /** A tier-2 escalation the project's interrupt policy deferred to the digest. */
1474
+ export interface HeldNotice {
1475
+ id: string;
1476
+ category: InterruptCategory;
1477
+ summary: string;
1478
+ detail: string;
1479
+ createdAt: number;
1480
+ }
1481
+
1482
+ /** What it takes to persist one held notice. */
1483
+ export interface HeldNoticeDraft {
1484
+ project: string;
1485
+ category: InterruptCategory;
1486
+ summary: string;
1487
+ detail: string;
1488
+ createdAt: number;
1387
1489
  }
1388
1490
 
1389
1491
  /**
@@ -1510,6 +1612,11 @@ export const VERB_REFUSALS = [
1510
1612
  "release-shape-not-executable",
1511
1613
  /** Config unreadable, project unknown, repo unrouted. Fail closed. */
1512
1614
  "config-unreadable",
1615
+ /** Merging would corrupt the ordered migration chain in `migrations.dir` (#227). */
1616
+ "chain-conflict",
1617
+ /** The PR diff or the base-branch chain could not be read — fail closed rather
1618
+ * than merge blind into a chain that might be about to fork (#227). */
1619
+ "chain-unreadable",
1513
1620
  /** The privileged half ran and the underlying command failed. */
1514
1621
  "action-failed",
1515
1622
  ] as const;
@@ -42,6 +42,8 @@ import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
44
  import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
45
+ import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
46
+ import type { readBaseChain as readBaseChainType } from "../gitops.ts";
45
47
  import { releaseRefusal } from "../release-policy.ts";
46
48
  import { LIVE_STATES } from "../store.ts";
47
49
  import type {
@@ -181,6 +183,12 @@ export interface VerbDeps {
181
183
  pausedAt: () => number | undefined;
182
184
  log: (message: string) => void;
183
185
  now: () => number;
186
+ /**
187
+ * Read-only access to the ordered-migration-chain guard. A read seam, kept
188
+ * off `VerbActions` (the mutation surface) on purpose: `prMergeVerb` consults
189
+ * whether the merge would fork the chain, it never changes it (#227).
190
+ */
191
+ chain: { readBaseChain: typeof readBaseChainType };
184
192
  }
185
193
 
186
194
  /**
@@ -728,6 +736,47 @@ async function prMergeVerb(
728
736
  );
729
737
  }
730
738
 
739
+ const repoTarget = Object.values(project.routing.repos).find((r) => r.name === target.repo);
740
+ const chainDir = repoTarget?.migrations?.dir;
741
+ if (chainDir !== undefined && repoTarget !== undefined) {
742
+ // Checked against the base tip at merge time, not against a stale advisory:
743
+ // the author hit two merged PRs leaving main with two Alembic heads (#227).
744
+ const diff = await deps.tracker.prDiff(prUrl).catch(() => undefined);
745
+ if (diff === undefined) {
746
+ return refuse(
747
+ "chain-unreadable",
748
+ `refused: the diff of ${prUrl} could not be read, so the migration chain in ${chainDir} cannot be verified. Failing closed: a wrong merge here is a silent production schema drift (#227).`,
749
+ target.issue,
750
+ );
751
+ }
752
+ const { added, changed, deleted } = chainEntriesFromDiff(diff.files, chainDir);
753
+ if (added.length + changed.length + deleted.length > 0) {
754
+ if (diff.truncated) {
755
+ return refuse(
756
+ "chain-unreadable",
757
+ `refused: the diff of ${prUrl} was truncated and it touches ${chainDir} — the chain cannot be verified from a partial diff.`,
758
+ target.issue,
759
+ );
760
+ }
761
+ const base = await deps.chain.readBaseChain(project, repoTarget, chainDir);
762
+ if (!base.ok) {
763
+ return refuse(
764
+ "chain-unreadable",
765
+ `refused: could not read ${chainDir} at the tip of ${repoTarget.defaultBranch} (${base.stderr}).`,
766
+ target.issue,
767
+ );
768
+ }
769
+ const violations = chainViolations({ base: base.entries, added, changed, deleted });
770
+ if (violations.length > 0) {
771
+ return refuse(
772
+ "chain-conflict",
773
+ `refused: merging ${prUrl} would corrupt the ordered migration chain in ${chainDir} — ${violations.join("; ")}. Rebase onto the current tip, regenerate the migration, and call again.`,
774
+ target.issue,
775
+ );
776
+ }
777
+ }
778
+ }
779
+
731
780
  const outcome = await deps.actions.mergePr(prUrl, headSha);
732
781
  if (!outcome.ok) {
733
782
  return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, target.issue);