omp-conductor 0.12.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.
package/src/diff-flags.ts CHANGED
@@ -290,9 +290,19 @@ function expandBraces(token: string): string[] {
290
290
  return alternatives.map((alt) => prefix + alt + suffix);
291
291
  }
292
292
 
293
+ /** A trailing slash-separated extension list:
294
+ * `scripts/a.sh/.py` → [`scripts/a.sh`, `scripts/a.py`]. */
295
+ function expandExtensionAlternation(token: string): string[] {
296
+ const match = /^(.+?)\.([A-Za-z][A-Za-z0-9]{1,7})((?:\/\.[A-Za-z][A-Za-z0-9]{1,7})+)$/.exec(token);
297
+ if (match === null) return [token];
298
+ const [, base, first, rest] = match;
299
+ if (base === undefined || first === undefined || rest === undefined) return [token];
300
+ return [first, ...rest.split("/.").filter(Boolean)].map((extension) => `${base}.${extension}`);
301
+ }
302
+
293
303
  /** Every path-shaped token on the report's `changed:` line. An absent line and
294
304
  * a line naming nothing are the same answer: nothing was disclosed. */
295
- export function claimedPaths(report: string): string[] {
305
+ function parseClaimedPaths(report: string, extensionAlternatives?: Set<string>): string[] {
296
306
  const line = CHANGED_LINE.exec(report)?.[1] ?? "";
297
307
  const seen = new Set<string>();
298
308
  // Split on whitespace and semicolons, and on commas *outside* a brace group:
@@ -306,13 +316,21 @@ export function claimedPaths(report: string): string[] {
306
316
  // directions — sees plain paths: a brace token dies earlier at the
307
317
  // CLAIMED_PATH filter if it is never opened up (#224).
308
318
  for (const expanded of expandBraces(normalised)) {
309
- if (!CLAIMED_PATH.test(expanded) && !DOTTED_MODULE.test(expanded)) continue;
310
- seen.add(expanded);
319
+ const alternatives = expandExtensionAlternation(expanded);
320
+ for (const path of alternatives) {
321
+ if (!CLAIMED_PATH.test(path) && !DOTTED_MODULE.test(path)) continue;
322
+ seen.add(path);
323
+ if (alternatives.length > 1) extensionAlternatives?.add(path);
324
+ }
311
325
  }
312
326
  }
313
327
  return [...seen];
314
328
  }
315
329
 
330
+ export function claimedPaths(report: string): string[] {
331
+ return parseClaimedPaths(report);
332
+ }
333
+
316
334
  /**
317
335
  * Whether one claim covers one path. Every rule here is deliberately generous,
318
336
  * because each one that fails produces an omission flag on an honest report:
@@ -505,7 +523,8 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
505
523
 
506
524
  function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
507
525
  const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
508
- const claims = claimedPaths(audit.report);
526
+ const extensionAlternatives = new Set<string>();
527
+ const claims = parseClaimedPaths(audit.report, extensionAlternatives);
509
528
  const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
510
529
  // Coverage pools the current report with every prior attempt's disclosures:
511
530
  // a file the final report no longer names was disclosed while the work was
@@ -553,12 +572,62 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
553
572
  if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
554
573
  continue;
555
574
  }
575
+ if (extensionAlternatives.has(claim)) {
576
+ const stem = claim.slice(0, claim.lastIndexOf("."));
577
+ const matchedAlternative = claims.some(
578
+ (other) =>
579
+ other !== claim &&
580
+ extensionAlternatives.has(other) &&
581
+ other.slice(0, other.lastIndexOf(".")) === stem &&
582
+ audit.diff.files.some((f) => covers(other, f.path) || covers(other, f.previousPath ?? "")),
583
+ );
584
+ if (matchedAlternative) continue;
585
+ }
556
586
  flags.push({
557
587
  kind: "unmatched-claim",
558
588
  file: claim,
559
589
  detail: "named by the report's `changed:` line but not touched by the PR",
560
590
  });
561
591
  }
592
+
593
+ // A parser failure can otherwise accuse the report in both directions for
594
+ // the same file. Collapse only that self-refuting pair; unrelated findings
595
+ // remain intact.
596
+ const unmatched = flags.filter((flag) => flag.kind === "unmatched-claim");
597
+ const removed = new Set<SettlementFlag>();
598
+ let exampleClaim = "";
599
+ let examplePath = "";
600
+ for (const undisclosed of flags.filter((flag) => flag.kind === "undisclosed-file")) {
601
+ const name = basename(undisclosed.file);
602
+ const extension = name.lastIndexOf(".");
603
+ const stem = extension > 0 ? name.slice(0, extension) : name;
604
+ if (stem.length < 3) continue;
605
+ for (const claim of unmatched) {
606
+ if (!claim.file.includes(stem)) continue;
607
+ removed.add(undisclosed);
608
+ removed.add(claim);
609
+ if (exampleClaim === "") {
610
+ exampleClaim = claim.file;
611
+ examplePath = undisclosed.file;
612
+ }
613
+ }
614
+ }
615
+ if (removed.size === 0) return;
616
+
617
+ const kept = flags.filter((flag) => !removed.has(flag));
618
+ const removedClaims = unmatched.filter((flag) => removed.has(flag)).length;
619
+ flags.splice(
620
+ 0,
621
+ flags.length,
622
+ ...kept,
623
+ {
624
+ kind: "report-format-unparsed",
625
+ file: "(report)",
626
+ detail:
627
+ `${removedClaims} claim(s) on the \`changed:\` line could not be parsed as paths yet name the same ` +
628
+ `file(s) the PR touched (e.g. ${exampleClaim} vs ${examplePath}) — read the diff directly`,
629
+ },
630
+ );
562
631
  }
563
632
 
564
633
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
@@ -745,6 +814,10 @@ export function formatSettlementFlags(
745
814
  pooledHeading(diff.attempts),
746
815
  ];
747
816
  for (const flag of flags.slice(0, RENDERED_FLAGS)) {
817
+ if (flag.kind === "pr-adopted") {
818
+ lines.push(` ${flag.kind} — ${flag.detail}`);
819
+ continue;
820
+ }
748
821
  lines.push(
749
822
  ` ${flag.kind} ${flag.file}${flag.line === undefined ? "" : `:${flag.line}`} — ${flag.detail}` +
750
823
  (flag.unattributed === true ? " [unattributed: the dispatching issue never names this file]" : ""),
@@ -9,27 +9,53 @@
9
9
 
10
10
  import type { ReportingPolicy } from "./types.ts";
11
11
 
12
- /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
13
- export function localDayKey(at: number, timezone?: string): string {
14
- const parts = new Intl.DateTimeFormat("en-CA", {
15
- timeZone: timezone,
16
- year: "numeric",
17
- month: "2-digit",
18
- day: "2-digit",
19
- }).formatToParts(new Date(at));
20
- const get = (type: "year" | "month" | "day"): string =>
21
- parts.find((p) => p.type === type)?.value ?? "00";
22
- return `${get("year")}-${get("month")}-${get("day")}`;
12
+ const MINUTE_MS = 60_000;
13
+ const NEXT_DIGEST_HORIZON_MS = 3 * 24 * 60 * MINUTE_MS;
14
+
15
+ export type DigestScheduleState =
16
+ | { mode: "disabled" }
17
+ | { mode: "per-tick" }
18
+ | { mode: "due"; timezone: string }
19
+ | { mode: "scheduled"; timezone: string; nextAt?: number };
20
+
21
+ const localMinuteFormatters = new Map<string, Intl.DateTimeFormat>();
22
+ interface CachedDigestSchedule {
23
+ lastDigestDayKey: string | undefined;
24
+ from: number;
25
+ until: number;
26
+ state: Extract<DigestScheduleState, { mode: "scheduled" }>;
27
+ }
28
+
29
+ const schedules = new WeakMap<ReportingPolicy["digest"], CachedDigestSchedule>();
30
+
31
+
32
+ function localDigestMinute(at: number, timezone?: string): { day: string; clock: string } {
33
+ const key = timezone ?? "";
34
+ let formatter = localMinuteFormatters.get(key);
35
+ if (formatter === undefined) {
36
+ formatter = new Intl.DateTimeFormat("en-CA", {
37
+ timeZone: timezone,
38
+ year: "numeric",
39
+ month: "2-digit",
40
+ day: "2-digit",
41
+ hour: "2-digit",
42
+ minute: "2-digit",
43
+ hourCycle: "h23",
44
+ });
45
+ localMinuteFormatters.set(key, formatter);
46
+ }
47
+ const parts = formatter.formatToParts(new Date(at));
48
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
49
+ parts.find((part) => part.type === type)?.value ?? "00";
50
+ return {
51
+ day: `${get("year")}-${get("month")}-${get("day")}`,
52
+ clock: `${get("hour")}:${get("minute")}`,
53
+ };
23
54
  }
24
55
 
25
- /** `HH:MM` wall-clock in the zone, 24h and zero-padded. */
26
- function localClockAt(at: number, timezone?: string): string {
27
- return new Intl.DateTimeFormat("en-GB", {
28
- timeZone: timezone,
29
- hour: "2-digit",
30
- minute: "2-digit",
31
- hourCycle: "h23",
32
- }).format(new Date(at));
56
+ /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */
57
+ export function localDayKey(at: number, timezone?: string): string {
58
+ return localDigestMinute(at, timezone).day;
33
59
  }
34
60
 
35
61
  /**
@@ -51,9 +77,51 @@ export function digestDue(
51
77
  if (cadence === "none") return false;
52
78
  if (cadence === "per-tick") return true;
53
79
  // daily
54
- if (at === undefined) {
55
- return lastDigestDayKey !== localDayKey(now, timezone);
80
+ const local = localDigestMinute(now, timezone);
81
+ if (at === undefined) return lastDigestDayKey !== local.day;
82
+ if (lastDigestDayKey === local.day) return false;
83
+ return local.clock >= at;
84
+ }
85
+
86
+ /** Mechanical status for the next digest opportunity, using the same
87
+ * predicate that gates report submission. Minute scanning deliberately keeps
88
+ * skipped/repeated DST wall-clock times on the runtime's real timeline. */
89
+ export function digestScheduleState(
90
+ policy: Pick<ReportingPolicy, "digest">,
91
+ lastDigestDayKey: string | undefined,
92
+ now: number,
93
+ ): DigestScheduleState {
94
+ if (policy.digest.cadence === "none") return { mode: "disabled" };
95
+ if (policy.digest.cadence === "per-tick") return { mode: "per-tick" };
96
+
97
+ const timezone =
98
+ policy.digest.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
99
+ if (digestDue(policy, lastDigestDayKey, now)) return { mode: "due", timezone };
100
+
101
+ const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
102
+ const cached = schedules.get(policy.digest);
103
+ if (
104
+ cached !== undefined &&
105
+ cached.lastDigestDayKey === lastDigestDayKey &&
106
+ from >= cached.from &&
107
+ from < cached.until
108
+ ) {
109
+ return cached.state;
56
110
  }
57
- if (lastDigestDayKey === localDayKey(now, timezone)) return false;
58
- return localClockAt(now, timezone) >= at;
59
- }
111
+
112
+ const end = now + NEXT_DIGEST_HORIZON_MS;
113
+ let cursor = from + MINUTE_MS;
114
+ while (cursor <= end && !digestDue(policy, lastDigestDayKey, cursor)) cursor += MINUTE_MS;
115
+ const state: Extract<DigestScheduleState, { mode: "scheduled" }> = {
116
+ mode: "scheduled",
117
+ timezone,
118
+ ...(cursor > end ? {} : { nextAt: cursor }),
119
+ };
120
+ schedules.set(policy.digest, {
121
+ lastDigestDayKey,
122
+ from,
123
+ until: cursor,
124
+ state,
125
+ });
126
+ return state;
127
+ }
package/src/escalate.ts CHANGED
@@ -20,11 +20,12 @@
20
20
  * those strings end up in daemon logs and, on the fallback path, in a public
21
21
  * issue comment.
22
22
  */
23
-
23
+ import { createHash } from "node:crypto";
24
24
  import { readFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { join } from "node:path";
27
27
 
28
+ import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
28
29
  import type { OrchestratorHandle } from "./orchestrator.ts";
29
30
  import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
30
31
 
@@ -137,13 +138,20 @@ export function formatEscalation(e: Escalation, project: string): string {
137
138
  * still escalate, just to an issue comment.
138
139
  */
139
140
  export function createEscalator(
140
- p: ProjectConfig,
141
+ source: ProjectConfig | (() => ProjectConfig),
141
142
  tracker: Tracker,
142
143
  store: Store,
143
144
  orchestrator?: OrchestratorHandle,
145
+ now: () => number = Date.now,
146
+ deliveryAllowed: () => boolean = () => true,
144
147
  ): Escalator {
148
+ const currentProject = typeof source === "function" ? source : (): ProjectConfig => source;
145
149
  return {
146
150
  async escalate(e: Escalation): Promise<void> {
151
+ // The daemon's provider resolves the config reloaded at the latest tick
152
+ // boundary. Capture one snapshot for this delivery, including late
153
+ // settlement callbacks, so a mid-call edit cannot split its policy.
154
+ const p = currentProject();
147
155
  // Stable across daemon restarts: same project, issue, tier and summary is
148
156
  // the same event, however many times the loop rediscovers it.
149
157
  const key = `${p.name}:${e.issue}:${e.tier}:${e.summary}`;
@@ -210,26 +218,45 @@ export function createEscalator(
210
218
  }
211
219
  }
212
220
 
221
+ if (e.tier === 2) {
222
+ const category = e.category ?? "tier2";
223
+ const at = now();
224
+ let disposition: InterruptDisposition;
225
+ if (!deliveryAllowed()) {
226
+ disposition = "availability";
227
+ } else if (e.urgent) {
228
+ // Urgency may bypass category batching when the digest loop itself is
229
+ // broken (#246), but it cannot invent an out-of-hours bypass the
230
+ // operator did not configure (#273).
231
+ const window = p.reporting?.availability;
232
+ disposition =
233
+ window !== undefined &&
234
+ !availabilityOpen(window, at) &&
235
+ !window.bypass.includes(category)
236
+ ? "availability"
237
+ : "interrupt";
238
+ } else {
239
+ disposition = interruptDisposition(p.reporting, category, at);
240
+ }
241
+ if (disposition !== "interrupt") {
242
+ store.addHeldNotice({
243
+ id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
244
+ project: p.name,
245
+ category,
246
+ summary: e.summary,
247
+ detail: text,
248
+ createdAt: at,
249
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
250
+ ...(e.urgent === true ? { urgent: true } : {}),
251
+ });
252
+ store.markNotified(key);
253
+ return;
254
+ }
255
+ }
256
+
213
257
  if (e.tier === 2 && chatId) {
214
258
  const token = readTelegramToken();
215
259
  if (token) {
216
- // A category this policy defers does not page now: it is held here so
217
- // the digest is the delivery authority for it (#229). A missing
218
- // `reporting` block means the default (page everything); an explicit
219
- // list decides each escalation by its category, defaulting `tier2`.
220
- const category = e.category ?? "tier2";
221
- const interruptOn = p.reporting?.interruptOn;
222
- if (interruptOn !== undefined && !interruptOn.includes(category)) {
223
- store.addHeldNotice({
224
- project: p.name,
225
- category,
226
- summary: e.summary,
227
- detail: text,
228
- createdAt: Date.now(),
229
- });
230
- store.markNotified(key);
231
- return;
232
- }
233
260
  // A send failure throws: `markNotified` stays uncalled so the next
234
261
  // poll retries instead of writing the event off as delivered. No
235
262
  // backoff in here — the dispatcher tick *is* the retry, and an
@@ -22,6 +22,9 @@ export interface ClassifyFacts {
22
22
  pr?: "open" | "merged" | "closed";
23
23
  mergeable?: "conflicting" | "clean" | "unknown";
24
24
  checks?: { name: string; state: string; link?: string }[];
25
+ /** Full session error recovered from the transcript. Kept as a fact so a
26
+ * classification retry does not lose an HTTP status the run row cannot store. */
27
+ sessionError?: { status?: number; message: string };
25
28
  /** Tail (ANSI-stripped) of the first failed check's log, when one was
26
29
  * reachable. Lets the table tell an infrastructure outage (#177) from a
27
30
  * deterministic test failure by the log's own words. */
@@ -221,6 +224,8 @@ const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
221
224
 
222
225
  export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
223
226
  const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
227
+ const providerError =
228
+ facts.sessionError ?? (run.lastError === undefined ? undefined : { message: run.lastError });
224
229
 
225
230
  // The PR landed while the row says otherwise. Whatever else is true about this
226
231
  // run, it succeeded, and the recovery is bookkeeping.
@@ -249,10 +254,7 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
249
254
  // first request produces a turn-0 row with no artifacts, which
250
255
  // `env-start-failure` would otherwise absorb and lose the cause.
251
256
  if (run.state === "failed" || run.state === "killed") {
252
- const credit =
253
- run.lastError === undefined
254
- ? undefined
255
- : providerCreditRefusal({ message: run.lastError });
257
+ const credit = providerError === undefined ? undefined : providerCreditRefusal(providerError);
256
258
  if (credit !== undefined) {
257
259
  return { cls: "provider-credit", recovery: "requeue", evidence: credit };
258
260
  }
@@ -267,7 +269,7 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
267
269
  // the same reason as the credit branch: a turn-0 stall would otherwise be
268
270
  // absorbed by `env-start-failure` and lose its cause.
269
271
  if (run.state === "failed" || run.state === "killed") {
270
- const transient = run.lastError === undefined ? undefined : providerTransientFault({ message: run.lastError });
272
+ const transient = providerError === undefined ? undefined : providerTransientFault(providerError);
271
273
  if (transient !== undefined) {
272
274
  return { cls: "provider-transient", recovery: "requeue", evidence: transient };
273
275
  }
package/src/fleet.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
+ import { formatZonedMinute } from "./availability.ts";
30
31
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
32
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
32
33
  import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
@@ -36,8 +37,9 @@ import { renderBriefForProject } from "./setup.ts";
36
37
  import type { ProjectConfig, Store } from "./types.ts";
37
38
  import { settlementFlagSummary } from "./diff-flags.ts";
38
39
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
39
- import { formatOpenReports } from "./reports.ts";
40
+ import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
40
41
  import {
42
+ formatBaseHealth,
41
43
  formatDispatchSummary,
42
44
  formatReleaseGrants,
43
45
  formatSalvagedRuns,
@@ -1026,7 +1028,7 @@ export function formatFleetStatus(
1026
1028
  // a halt) answers "who stopped the fleet" without opening a file (#185). An
1027
1029
  // unparseable sentinel — paused but with no datable line 1 — is itself news:
1028
1030
  // it means a run admitted before an *unknown* pause cannot prove innocence
1029
- // (#174), so every mutation is refused.
1031
+ // (#174), so completion mutations fail closed while release gates remain usable.
1030
1032
  const dispatchLine =
1031
1033
  layers.dispatch === "paused"
1032
1034
  ? (() => {
@@ -1036,7 +1038,7 @@ export function formatFleetStatus(
1036
1038
  return `dispatch paused (source: ${prov.source}${reason})`;
1037
1039
  }
1038
1040
  return isPaused() && pausedAt() === undefined
1039
- ? "dispatch paused (unparseable sentinel — all mutations refused)"
1041
+ ? "dispatch paused (unparseable sentinel — new work and completion mutations refused; release gates remain available)"
1040
1042
  : "dispatch paused";
1041
1043
  })()
1042
1044
  : `dispatch ${layers.dispatch}`;
@@ -1059,12 +1061,41 @@ export function formatFleetStatus(
1059
1061
  ].join("\n");
1060
1062
  }
1061
1063
 
1064
+ function formatAvailabilityStatus(s: StatusSnapshot): string[] {
1065
+ const availability = s.availability;
1066
+ if (availability === undefined) return [];
1067
+ if (availability.mode === "always") {
1068
+ return ["availability 24-hour interrupts (no weekly window)"];
1069
+ }
1070
+ const mode =
1071
+ availability.nextTransitionAt === undefined || availability.timezone === undefined
1072
+ ? `${availability.mode}; next transition could not be calculated`
1073
+ : `${availability.mode} until ${formatZonedMinute(availability.nextTransitionAt, availability.timezone)}`;
1074
+ const bypass = availability.bypass.length === 0 ? "none" : availability.bypass.join(", ");
1075
+ return [`availability ${mode}; quiet-hours bypass ${bypass}`];
1076
+ }
1077
+
1078
+ function formatDigestScheduleStatus(s: StatusSnapshot): string[] {
1079
+ const schedule = s.digestSchedule;
1080
+ if (schedule === undefined) return [];
1081
+ if (schedule.mode === "disabled") return ["next digest disabled"];
1082
+ if (schedule.mode === "per-tick") return ["next digest every tick"];
1083
+ if (schedule.mode === "due") return ["next digest due now"];
1084
+ return [
1085
+ schedule.nextAt === undefined
1086
+ ? "next digest could not be calculated"
1087
+ : `next digest ${formatZonedMinute(schedule.nextAt, schedule.timezone)}`,
1088
+ ];
1089
+ }
1090
+
1062
1091
  function formatProjectBody(s: StatusSnapshot): string {
1063
1092
  const lines = [
1064
1093
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
1065
1094
  ...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
1066
1095
  `config ${s.configPath}`,
1067
1096
  `state ${s.stateDir}`,
1097
+ ...formatAvailabilityStatus(s),
1098
+ ...formatDigestScheduleStatus(s),
1068
1099
  "",
1069
1100
  "caps",
1070
1101
  ` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
@@ -1110,6 +1141,9 @@ function formatProjectBody(s: StatusSnapshot): string {
1110
1141
  })`,
1111
1142
  ]),
1112
1143
  ` new worker turns ${s.caps.workerMaxTurns}`,
1144
+ ...s.turnOverrides.map(
1145
+ ({ issue, maxTurns }) => ` turn override #${issue} → ${maxTurns} (next attempt)`,
1146
+ ),
1113
1147
  ` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
1114
1148
  ` failed attempts ${s.caps.maxAttemptsPerIssue}`,
1115
1149
  ` continuations ${s.caps.maxContinuationsPerIssue}`,
@@ -1136,8 +1170,10 @@ function formatProjectBody(s: StatusSnapshot): string {
1136
1170
  if (flagged !== undefined) lines.push(` ${flagged}`);
1137
1171
  }
1138
1172
  }
1173
+ lines.push(...formatBaseHealth(s.baseHealth));
1139
1174
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
1140
1175
  lines.push(...formatOpenReports(s.openReports));
1176
+ lines.push(...formatDigestBacklog(s.digestBacklog));
1141
1177
  if (s.liveWorkers > 0) {
1142
1178
  lines.push(
1143
1179
  "",
package/src/omp.ts CHANGED
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
21
21
  import { worktreeConfinement } from "./confinement.ts";
22
- import { releasePolicyTripwire } from "./release-policy.ts";
22
+ import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
23
23
  import type {
24
24
  HostToParent,
25
25
  ParentToHost,
@@ -158,7 +158,7 @@ export async function createLocalSession(opts: {
158
158
  /** Install the release/deploy tool-call gate with these per-shape grants. */
159
159
  releaseGrants?: ResolvedGrants;
160
160
  /** Durable audit callback invoked only when that gate rejects a call. */
161
- onReleaseBlocked?: (shape: ReleaseShape) => void;
161
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
162
162
  /**
163
163
  * The conductor verb socket this session's mutation tools call (#126).
164
164
  *
@@ -393,7 +393,7 @@ export interface CreateSessionOptions {
393
393
  resume?: boolean;
394
394
  role: SessionRole;
395
395
  releaseGrants?: ResolvedGrants;
396
- onReleaseBlocked?: (shape: ReleaseShape) => void;
396
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
397
397
 
398
398
  /**
399
399
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -681,7 +681,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
681
681
  break;
682
682
  }
683
683
  case "release-blocked":
684
- opts.onReleaseBlocked?.(message.shape);
684
+ opts.onReleaseBlocked?.(message.shape, {
685
+ tool: message.tool,
686
+ reason: message.reason,
687
+ args: message.args,
688
+ });
685
689
  break;
686
690
  }
687
691
  }