omp-conductor 0.13.0 → 0.14.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.
@@ -272,6 +272,28 @@ export function mergedPrInfoFrom(raw: string): MergedPrInfo | undefined {
272
272
  : undefined;
273
273
  }
274
274
 
275
+ /** Parse the commit identity emitted by GitHub's branch REST endpoint. */
276
+ export function branchHeadFrom(raw: string): string | undefined {
277
+ let parsed: unknown;
278
+ try {
279
+ parsed = JSON.parse(raw) as unknown;
280
+ } catch {
281
+ return undefined;
282
+ }
283
+ if (
284
+ parsed === null ||
285
+ typeof parsed !== "object" ||
286
+ !("commit" in parsed) ||
287
+ parsed.commit === null ||
288
+ typeof parsed.commit !== "object" ||
289
+ !("sha" in parsed.commit)
290
+ ) {
291
+ return undefined;
292
+ }
293
+ const sha = parsed.commit.sha;
294
+ return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined;
295
+ }
296
+
275
297
  /** Parse an Actions workflow-run list, skipping malformed entries without
276
298
  * turning a valid empty list into an unreadable response. */
277
299
  export function workflowRunsFrom(raw: string): WorkflowRun[] | undefined {
@@ -1187,16 +1209,32 @@ export function makeTracker(
1187
1209
  }
1188
1210
  },
1189
1211
 
1190
- async workflowRunsAt(repo: string, sha: string): Promise<WorkflowRun[] | undefined> {
1212
+ async branchHead(repo: string, branch: string): Promise<string | undefined> {
1213
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || branch.length === 0) return undefined;
1214
+ try {
1215
+ return branchHeadFrom(
1216
+ await runGh(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`]),
1217
+ );
1218
+ } catch {
1219
+ return undefined;
1220
+ }
1221
+ },
1222
+
1223
+ async workflowRunsAt(
1224
+ repo: string,
1225
+ sha: string,
1226
+ opts?: { event?: string; branch?: string },
1227
+ ): Promise<WorkflowRun[] | undefined> {
1191
1228
  if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !/^[0-9a-f]{40,64}$/i.test(sha)) {
1192
1229
  return undefined;
1193
1230
  }
1231
+ const query = [`head_sha=${encodeURIComponent(sha)}`];
1232
+ if (opts?.event !== undefined) query.push(`event=${encodeURIComponent(opts.event)}`);
1233
+ if (opts?.branch !== undefined) query.push(`branch=${encodeURIComponent(opts.branch)}`);
1234
+ query.push("per_page=50");
1194
1235
  try {
1195
1236
  return workflowRunsFrom(
1196
- await runGh([
1197
- "api",
1198
- `repos/${repo}/actions/runs?head_sha=${encodeURIComponent(sha)}&per_page=50`,
1199
- ]),
1237
+ await runGh(["api", `repos/${repo}/actions/runs?${query.join("&")}`]),
1200
1238
  );
1201
1239
  } catch {
1202
1240
  return undefined;
package/src/types.ts CHANGED
@@ -136,8 +136,8 @@ export interface RepoTarget {
136
136
  * `escalations` interrupts only for a tier-2 decision. `material` reports every
137
137
  * material event as it happens. `decisions` sits between them: a decision the
138
138
  * session needs, or a condition that stops the fleet, interrupts immediately;
139
- * every other material event accumulates and ships with the next tick report —
140
- * one message on a schedule instead of a ping per merge (#138).
139
+ * every other material event accumulates and ships with the configured digest
140
+ * instead of as a ping per merge (#138).
141
141
  */
142
142
  export const REPORT_SCOPES = ["escalations", "decisions", "material"] as const;
143
143
 
@@ -177,6 +177,24 @@ export const INTERRUPT_CATEGORIES = [
177
177
 
178
178
  export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
179
179
 
180
+ /** Operator-local weekdays used by a weekly availability window. */
181
+ export const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
182
+
183
+ export type Weekday = (typeof WEEKDAYS)[number];
184
+
185
+ /**
186
+ * One operator-local interruption window. A day names the day the window
187
+ * opens; when `start` is later than `end`, it closes on the following day.
188
+ */
189
+ export interface WeeklyAvailability {
190
+ timezone: string;
191
+ days: Weekday[];
192
+ start: string;
193
+ end: string;
194
+ /** Categories that may still interrupt outside the window. */
195
+ bypass: InterruptCategory[];
196
+ }
197
+
180
198
  /** When the daily/report rollup is due. */
181
199
  export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
182
200
 
@@ -190,6 +208,8 @@ export type DigestCadence = (typeof DIGEST_CADENCES)[number];
190
208
  export interface ReportingPolicy {
191
209
  interruptOn: InterruptCategory[];
192
210
  digest: { cadence: DigestCadence; at?: string; timezone?: string };
211
+ /** Absent preserves the legacy 24-hour interruption behaviour. */
212
+ availability?: WeeklyAvailability;
193
213
  /** Set when the policy came from a legacy `scope` preset. */
194
214
  scopePreset?: ReportScope;
195
215
  }
@@ -710,6 +730,10 @@ export const SETTLEMENT_FLAG_KINDS = [
710
730
  "changed-line-missing",
711
731
  /** `changed:` named a path the PR never touched. The weaker direction. */
712
732
  "unmatched-claim",
733
+ /** The same file appears as both claimed-but-untouched and
734
+ * touched-but-unclaimed — the `changed:` line's format defeated the parser;
735
+ * read the diff directly. */
736
+ "report-format-unparsed",
713
737
  /** A test file left the tree and no rename in the PR accounts for it. */
714
738
  "test-file-deleted",
715
739
  /** A skip/only/focus marker appears on a line the PR added. */
@@ -869,7 +893,13 @@ export interface Tracker {
869
893
  /** The merge commit and base ref for a merged pull request. */
870
894
  mergedPrInfo(url: string): Promise<MergedPrInfo | undefined>;
871
895
  /** Workflow runs GitHub associated with one exact commit SHA. */
872
- workflowRunsAt(repo: string, sha: string): Promise<WorkflowRun[] | undefined>;
896
+ workflowRunsAt(
897
+ repo: string,
898
+ sha: string,
899
+ opts?: { event?: string; branch?: string },
900
+ ): Promise<WorkflowRun[] | undefined>;
901
+ /** The live commit at one repository branch, or undefined when unreadable. */
902
+ branchHead(repo: string, branch: string): Promise<string | undefined>;
873
903
  /** The newest run of this workflow on the base branch before `before`.
874
904
  * `null` means no earlier run; `undefined` means the history could not be read. */
875
905
  previousWorkflowRun(
@@ -959,6 +989,9 @@ export const FAILURE_CLASSES = [
959
989
  "settlement-stuck",
960
990
  "provider-credit",
961
991
  "provider-transient",
992
+ /** A reviewer closed pushed-green or pushed-pending work without merging it:
993
+ * a review decision, not a worker failure. */
994
+ "returned-for-revision",
962
995
  "unknown",
963
996
  ] as const;
964
997
 
@@ -994,6 +1027,17 @@ export const BASE_CHECK_STATES = [
994
1027
 
995
1028
  export type BaseCheckState = (typeof BASE_CHECK_STATES)[number];
996
1029
 
1030
+ /** Current push-workflow health at a routed repository's live base head. */
1031
+ export interface BaseHealth {
1032
+ repo: string;
1033
+ branch: string;
1034
+ headSha: string;
1035
+ verdict: "green" | "red" | "pending" | "unknown";
1036
+ runsCount: number;
1037
+ detail?: string;
1038
+ checkedAt: number;
1039
+ }
1040
+
997
1041
  /**
998
1042
  * Execution state is separate from the tracker's own labels on purpose: labels
999
1043
  * are coarse and human-editable, while the loop needs to distinguish "pushed
@@ -1266,6 +1310,43 @@ export interface ReportEnqueue {
1266
1310
  deduped: boolean;
1267
1311
  }
1268
1312
 
1313
+ /** Maximum ledger rows of each kind placed in one digest prompt. Older rows
1314
+ * stay first, so a busy day drains deterministically over later digests. */
1315
+ export const DIGEST_BACKLOG_LIMIT = 20;
1316
+
1317
+ /** One ordinary material outcome waiting for a rendered digest (#274). */
1318
+ export interface MaterialEvent {
1319
+ id: string;
1320
+ project: string;
1321
+ category: string;
1322
+ summary: string;
1323
+ evidence: string;
1324
+ occurredAt: number;
1325
+ recordedAt: number;
1326
+ /** The accepted outbox row that took responsibility for this event. */
1327
+ digestReportId?: string;
1328
+ }
1329
+
1330
+ /** What the orchestrator records when an outcome happens. */
1331
+ export interface MaterialEventDraft {
1332
+ project: string;
1333
+ category: string;
1334
+ summary: string;
1335
+ evidence: string;
1336
+ occurredAt: number;
1337
+ recordedAt: number;
1338
+ }
1339
+
1340
+ /** Counts and oldest timestamps for the two distinct digest sources. */
1341
+ export interface DigestBacklog {
1342
+ materialCount: number;
1343
+ materialOldestAt?: number;
1344
+ heldNoticeCount: number;
1345
+ /** Subset held only for the next availability window. */
1346
+ availabilityHeldNoticeCount?: number;
1347
+ heldNoticeOldestAt?: number;
1348
+ }
1349
+
1269
1350
  /**
1270
1351
  * Where one operator decision stands (#136).
1271
1352
  *
@@ -1359,8 +1440,15 @@ export interface Store {
1359
1440
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
1360
1441
  /** Merged rows whose post-merge workflow verdict is still pending, oldest first. */
1361
1442
  runsNeedingBaseCheck(project: string, limit?: number): RunRecord[];
1362
- /** Newest checked merge per routed repository inside the supplied history window. */
1363
- latestBaseChecks(project: string, sinceEpochMs: number): RunRecord[];
1443
+ /** Replace the current live-head health row for one routed repository. */
1444
+ upsertBaseHealth(project: string, row: BaseHealth): void;
1445
+ /** Current live-head health, ordered by routed repository name. */
1446
+ baseHealth(project: string): BaseHealth[];
1447
+ /** Recently merged routed repository/branch pairs that establish health scope. */
1448
+ mergedRepoBranches(
1449
+ project: string,
1450
+ sinceEpochMs: number,
1451
+ ): { repo: string; baseRef?: string }[];
1364
1452
  /** Newest attempt per issue that preserved work or failed to, so `status`
1365
1453
  * can name every WIP tip a re-claim would build on and every tree that is
1366
1454
  * still the only copy. */
@@ -1405,12 +1493,26 @@ export interface Store {
1405
1493
  /** The newest `digest:` dedupe key a project has run toward without ending in
1406
1494
  * failure — what the off-schedule-digest refusal compares against (#229). */
1407
1495
  lastDigestDedupeKey(project: string): string | undefined;
1408
- /** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
1496
+ /** Hold a tier-2 escalation the interrupt policy or availability window deferred. */
1409
1497
  addHeldNotice(notice: HeldNoticeDraft): void;
1410
- /** Held notices still owed (not yet re-surfaced by a digest pass). */
1411
- undigestedNotices(project: string): HeldNotice[];
1412
- /** Mark every undigested notice for a project as re-surfaced. */
1413
- markNoticesDigested(project: string, at: number): void;
1498
+ /**
1499
+ * Held notices still owed, oldest first and bounded. `availabilityOnly`
1500
+ * selects notices that may be released at the next configured opening.
1501
+ */
1502
+ undigestedNotices(
1503
+ project: string,
1504
+ limit?: number,
1505
+ availabilityOnly?: boolean,
1506
+ categories?: readonly InterruptCategory[],
1507
+ urgent?: boolean,
1508
+ ): HeldNotice[];
1509
+ /** Persist one ordinary material outcome without sending it. */
1510
+ recordMaterialEvent(event: MaterialEventDraft): MaterialEvent;
1511
+ getMaterialEvent(id: string): MaterialEvent | undefined;
1512
+ /** Ordinary material outcomes still owed, oldest first and bounded. */
1513
+ undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
1514
+ /** Count and age source for status and digest prompt bounds. */
1515
+ digestBacklog(project: string): DigestBacklog;
1414
1516
  /** Add one bounded observation to the per-day friction rollup. */
1415
1517
  recordFriction(project: string, observation: FrictionObservation): void;
1416
1518
  /** Repeated signals not surfaced within the supplied cooldown window. */
@@ -1440,7 +1542,35 @@ export interface Store {
1440
1542
  * suppressed without asking the model what it sent yesterday.
1441
1543
  */
1442
1544
  enqueueReport(draft: ReportDraft): ReportEnqueue;
1545
+ /** Atomically hand a digest to the outbox and associate only the named
1546
+ * material events and deferred escalations. A handoff deduplicated by its
1547
+ * optional daily key consumes nothing new. */
1548
+ enqueueDigestReport(
1549
+ draft: ReportDraft,
1550
+ materialEventIds: readonly string[],
1551
+ heldNoticeIds: readonly string[],
1552
+ ): ReportEnqueue;
1553
+ /** Atomically enqueue a working-hours catch-up unless a due digest currently
1554
+ * owns the handoff window. `undefined` leaves every notice unowned. */
1555
+ enqueueAvailabilityReport(
1556
+ draft: ReportDraft,
1557
+ heldNoticeIds: readonly string[],
1558
+ ): ReportEnqueue | undefined;
1443
1559
  getReport(id: string): ReportRecord | undefined;
1560
+ /** Atomically remove an unsent material report from the outbox and preserve
1561
+ * its body as a held notice after policy or availability changes. */
1562
+ deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean;
1563
+ /** Remove a still-pending working-hours catch-up and make its associated
1564
+ * notices digest-eligible again after a live policy change. */
1565
+ releasePendingAvailabilityReport(id: string, project: string, possibleRepeat?: boolean): boolean;
1566
+ /** Give one due digest a bounded, once-per-day lease on availability-held
1567
+ * notices. Pending catch-ups are released inside the same transaction. */
1568
+ reserveAvailabilityDigest(
1569
+ project: string,
1570
+ cycleKey: string,
1571
+ at: number,
1572
+ expiresAt: number,
1573
+ ): boolean;
1444
1574
  /** `pending` rows whose backoff has elapsed, oldest first. */
1445
1575
  dueReports(project: string, now: number, limit: number): ReportRecord[];
1446
1576
  /** Atomically take a due `pending` row into `sending` under `attemptId`, so
@@ -1582,22 +1712,31 @@ export interface Escalation {
1582
1712
  urgent?: true;
1583
1713
  }
1584
1714
 
1585
- /** A tier-2 escalation the project's interrupt policy deferred to the digest. */
1586
1715
  export interface HeldNotice {
1587
1716
  id: string;
1588
1717
  category: InterruptCategory;
1589
1718
  summary: string;
1590
1719
  detail: string;
1591
1720
  createdAt: number;
1721
+ /**
1722
+ * This notice was otherwise interruptible and was held only by the
1723
+ * availability window. It may be surfaced when the current policy opens.
1724
+ */
1725
+ releaseOnAvailable?: true;
1726
+ /** Urgent recovery may bypass category batching, but never availability. */
1727
+ urgent?: true;
1592
1728
  }
1593
1729
 
1594
- /** What it takes to persist one held notice. */
1595
1730
  export interface HeldNoticeDraft {
1731
+ /** Caller-supplied identity when the handoff must print or deduplicate it. */
1732
+ id?: string;
1596
1733
  project: string;
1597
1734
  category: InterruptCategory;
1598
1735
  summary: string;
1599
1736
  detail: string;
1600
1737
  createdAt: number;
1738
+ releaseOnAvailable?: true;
1739
+ urgent?: true;
1601
1740
  }
1602
1741
 
1603
1742
  /**
@@ -132,6 +132,30 @@ export function githubVerbActions(
132
132
  `live ${repo.defaultBranch} lookup`,
133
133
  );
134
134
 
135
+ const remoteTagCommit = async (
136
+ mirror: string,
137
+ tag: string,
138
+ ): Promise<{ ok: true; sha: string | undefined } | { ok: false; stderr: string }> => {
139
+ const directRef = `refs/tags/${tag}`;
140
+ const peeledRef = `${directRef}^{}`;
141
+ const { argv, result } = await git(mirror, ["ls-remote", "origin", directRef, peeledRef]);
142
+ if (!result.ok) return failed(result, argv);
143
+ if (result.stdout.trim() === "") return { ok: true, sha: undefined };
144
+
145
+ const refs = new Map(
146
+ result.stdout
147
+ .trim()
148
+ .split("\n")
149
+ .map((line) => line.trim().split(/\s+/, 2))
150
+ .filter((entry): entry is [string, string] => entry.length === 2)
151
+ .map(([sha, ref]) => [ref, sha] as const),
152
+ );
153
+ const sha = commitFrom(refs.get(peeledRef) ?? refs.get(directRef) ?? "");
154
+ return sha === undefined
155
+ ? { ok: false, stderr: `remote tag ${tag} lookup returned no full commit SHA` }
156
+ : { ok: true, sha };
157
+ };
158
+
135
159
  const releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({
136
160
  ok: false,
137
161
  stderr:
@@ -217,11 +241,40 @@ export function githubVerbActions(
217
241
  if (!liveBefore.ok) return liveBefore;
218
242
  if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
219
243
 
244
+ const localTag = await git(mirror, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]);
245
+ let previousSha: string | undefined;
246
+ if (localTag.result.ok) {
247
+ previousSha = commitFrom(localTag.result.stdout);
248
+ if (previousSha === undefined) {
249
+ return { ok: false, stderr: `tag ${tag} returned no full commit SHA` };
250
+ }
251
+
252
+ const remoteTag = await remoteTagCommit(mirror, tag);
253
+ if (!remoteTag.ok) return remoteTag;
254
+ if (remoteTag.sha === target.sha) {
255
+ return {
256
+ ok: true,
257
+ sha: target.sha,
258
+ detail: `tag ${tag} already exists at ${target.sha} and is already on origin`,
259
+ };
260
+ }
261
+ if (remoteTag.sha !== undefined) {
262
+ return {
263
+ ok: false,
264
+ stderr:
265
+ `refusing release: ${tag} already exists on origin at ${remoteTag.sha}. ` +
266
+ "A published tag is never re-cut or moved; choose a new tag name.",
267
+ };
268
+ }
269
+ }
270
+
271
+ const repointed = previousSha !== undefined;
220
272
  // An unattended daemon cannot depend on a host-level Git identity or a
221
273
  // signing key with an interactive passphrase.
222
274
  const tagged = await git(mirror, [
223
275
  ...RELEASE_TAG_CONFIG,
224
276
  "tag",
277
+ ...(repointed ? ["-f"] : []),
225
278
  "-a",
226
279
  tag,
227
280
  "-m",
@@ -232,30 +285,95 @@ export function githubVerbActions(
232
285
 
233
286
  const liveAfter = await liveDefaultHead(mirror, execution.repo);
234
287
  if (!liveAfter.ok || liveAfter.sha !== target.sha) {
235
- const removed = await git(mirror, ["tag", "-d", tag]);
288
+ const rollback = previousSha !== undefined
289
+ ? await git(mirror, [
290
+ ...RELEASE_TAG_CONFIG,
291
+ "tag",
292
+ "-f",
293
+ "-a",
294
+ tag,
295
+ "-m",
296
+ `release ${tag}`,
297
+ previousSha,
298
+ ])
299
+ : await git(mirror, ["tag", "-d", tag]);
236
300
  const reason = liveAfter.ok
237
301
  ? releaseTargetMoved(execution.repo, target.sha, liveAfter.sha).stderr
238
- : `created ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`;
239
- return {
240
- ok: false,
241
- stderr:
242
- reason +
243
- (removed.result.ok
244
- ? " The unpushed local tag was deleted."
245
- : ` WARNING: the unpushed local tag could not be deleted: ${failed(removed.result, removed.argv).stderr}`),
246
- };
302
+ : `${repointed ? "re-pointed" : "created"} ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`;
303
+ const rollbackDetail = repointed
304
+ ? rollback.result.ok
305
+ ? ` The tag was restored to ${previousSha}.`
306
+ : ` WARNING: the tag could not be restored to ${previousSha}: ${failed(rollback.result, rollback.argv).stderr}`
307
+ : rollback.result.ok
308
+ ? " The unpushed local tag was deleted."
309
+ : ` WARNING: the unpushed local tag could not be deleted: ${failed(rollback.result, rollback.argv).stderr}`;
310
+ return { ok: false, stderr: reason + rollbackDetail };
247
311
  }
248
- return { ok: true, sha: target.sha, detail: `tagged ${tag} at ${target.sha}` };
312
+ return {
313
+ ok: true,
314
+ sha: target.sha,
315
+ detail: repointed
316
+ ? `re-pointed unpushed ${tag} from ${previousSha} to ${target.sha}`
317
+ : `tagged ${tag} at ${target.sha}`,
318
+ };
249
319
  }
250
320
 
251
321
  const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`);
252
322
  if (!target.ok) return target;
323
+ let tagSha = target.sha;
253
324
  const live = await liveDefaultHead(mirror, execution.repo);
254
325
  if (!live.ok) return live;
255
- if (target.sha !== live.sha) return releaseTargetMoved(execution.repo, target.sha, live.sha);
326
+
327
+ const remoteTag = await remoteTagCommit(mirror, tag);
328
+ if (!remoteTag.ok) return remoteTag;
329
+ if (remoteTag.sha === tagSha) {
330
+ return { ok: true, sha: tagSha, detail: `refs/tags/${tag} already on origin at ${tagSha}` };
331
+ }
332
+ if (remoteTag.sha !== undefined) {
333
+ return {
334
+ ok: false,
335
+ stderr:
336
+ `refusing release: origin already has ${tag} at ${remoteTag.sha}, ` +
337
+ `which differs from the local tag at ${tagSha}. ` +
338
+ "A published tag is never force-moved; cut a new tag instead.",
339
+ };
340
+ }
341
+
342
+ let oldSha: string | undefined;
343
+ if (tagSha !== live.sha) {
344
+ const fresh = await readCommit(
345
+ mirror,
346
+ ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
347
+ `refreshed ${execution.repo.defaultBranch}`,
348
+ );
349
+ if (!fresh.ok) return fresh;
350
+ if (fresh.sha !== live.sha) return releaseTargetMoved(execution.repo, fresh.sha, live.sha);
351
+
352
+ oldSha = tagSha;
353
+ const retagged = await git(mirror, [
354
+ ...RELEASE_TAG_CONFIG,
355
+ "tag",
356
+ "-f",
357
+ "-a",
358
+ tag,
359
+ "-m",
360
+ `release ${tag}`,
361
+ live.sha,
362
+ ]);
363
+ if (!retagged.result.ok) return failed(retagged.result, retagged.argv);
364
+ tagSha = live.sha;
365
+ }
366
+
256
367
  const pushed = await git(mirror, ["push", "origin", `refs/tags/${tag}`]);
257
368
  return pushed.result.ok
258
- ? { ok: true, sha: target.sha, detail: `pushed refs/tags/${tag} at ${target.sha}` }
369
+ ? {
370
+ ok: true,
371
+ sha: tagSha,
372
+ detail:
373
+ oldSha === undefined
374
+ ? `pushed refs/tags/${tag} at ${tagSha}`
375
+ : `re-pointed unpushed ${tag} from ${oldSha} to ${tagSha}; pushed refs/tags/${tag} at ${tagSha}`,
376
+ }
259
377
  : failed(pushed.result, pushed.argv);
260
378
  },
261
379
  };
@@ -265,9 +265,9 @@ export interface ReleaseFacts {
265
265
  openPrs: number;
266
266
  /** Queue depth, or `undefined` when the tracker could not be read. */
267
267
  queueDepth: number | undefined;
268
- /** Newest observed verdict for the released routed repository. */
268
+ /** Current live-head workflow verdict for the released routed repository. */
269
269
  baseCheck?: RunRecord["baseCheck"];
270
- /** Evidence attached to a newest red verdict. */
270
+ /** Evidence attached to a current red verdict. */
271
271
  redBase?: string;
272
272
  }
273
273
 
@@ -978,18 +978,17 @@ async function releaseVerb(
978
978
  }
979
979
  }
980
980
  const wantsBase = policy.release.requires.includes("base-branch-green");
981
- const latestBase = wantsBase
982
- ? deps.store.latestBaseChecks(project.name, 0).find((run) => run.repo === repoName)
981
+ const health = wantsBase
982
+ ? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
983
983
  : undefined;
984
- const redBase = latestBase?.settlementFlags?.find(
985
- (flag) => flag.kind === "base-branch-red",
986
- )?.detail;
987
984
  const unmet = releaseRequirementRefusal(policy.release.requires, {
988
985
  unsettledRuns: active.length,
989
986
  openPrs: active.filter((r) => r.prUrl !== undefined).length,
990
987
  queueDepth,
991
- ...(latestBase?.baseCheck === undefined ? {} : { baseCheck: latestBase.baseCheck }),
992
- ...(redBase === undefined ? {} : { redBase }),
988
+ ...(health === undefined ? {} : { baseCheck: health.verdict }),
989
+ ...(health?.verdict === "red" && health.detail !== undefined
990
+ ? { redBase: health.detail }
991
+ : {}),
993
992
  });
994
993
  if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
995
994
 
package/src/worker.ts CHANGED
@@ -22,6 +22,8 @@ const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
22
22
 
23
23
  /** Any explicit verdict line, whatever it claims. */
24
24
  const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
25
+ /** GitHub PR URLs in unstructured prose; capture their canonical `owner/repo`. */
26
+ const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/\d+\b/gi;
25
27
 
26
28
  /** `{{KEY}}` placeholders in a brief template. */
27
29
  const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
@@ -63,6 +65,8 @@ export interface WorkerOpts {
63
65
  brief: string;
64
66
  cwd: string;
65
67
  caps: Caps;
68
+ /** Canonical `owner/repo` identity used to scope prose-only PR URLs. */
69
+ repoSlug?: string;
66
70
  /**
67
71
  * Directory the harness writes this run's transcript into — a directory, not
68
72
  * a file. The SDK takes no `sessionFile` input, so naming a path here would
@@ -179,15 +183,23 @@ export function renderBrief(template: string, vars: Record<string, string>): str
179
183
  * asks the tracker to verify those facts independently. Missing or malformed
180
184
  * evidence fails closed.
181
185
  */
182
- export function deriveResult(report: string): {
186
+ export function deriveResult(report: string, repoSlug?: string): {
183
187
  state: RunState;
184
188
  prUrl?: string;
185
189
  headSha?: string;
186
190
  } {
187
- const prUrl = PR_URL_PATTERN.exec(report)?.[1];
191
+ const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
188
192
  const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
189
- if (PUSHED_GREEN_PATTERN.test(report) && prUrl !== undefined && headSha !== undefined) {
190
- return { state: "pushed-green", prUrl, headSha };
193
+ if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
194
+ return { state: "pushed-green", prUrl: structuredPrUrl, headSha };
195
+ }
196
+
197
+ let prUrl = structuredPrUrl;
198
+ if (prUrl === undefined && repoSlug !== undefined) {
199
+ const expectedRepo = repoSlug.toLowerCase();
200
+ for (const match of report.matchAll(GITHUB_PR_URL_PATTERN)) {
201
+ if (match[1]?.toLowerCase() === expectedRepo) prUrl = match[0];
202
+ }
191
203
  }
192
204
 
193
205
  const state: RunState = BLOCKED_PATTERN.test(report) ? "blocked" : "failed";
@@ -424,7 +436,7 @@ export async function runWorker(
424
436
  const text = reportText(field(message, "content"));
425
437
  if (text !== "") {
426
438
  report = text;
427
- const stated = deriveResult(text);
439
+ const stated = deriveResult(text, o.repoSlug);
428
440
  if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
429
441
  claim = { prUrl: stated.prUrl, headSha: stated.headSha };
430
442
  }
@@ -554,7 +566,7 @@ export async function runWorker(
554
566
  return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
555
567
  }
556
568
  return withSessionFacts({
557
- ...deriveResult(report),
569
+ ...deriveResult(report, o.repoSlug),
558
570
  turns,
559
571
  spendUsd,
560
572
  report,