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.
@@ -33,6 +33,7 @@ import { stateDir } from "./config.ts";
33
33
  import { formatEscalation } from "./escalate.ts";
34
34
  import { createSession, disposeSession } from "./omp.ts";
35
35
  import type { AgentSessionLike } from "./omp.ts";
36
+ import type { ReleaseBlockContext } from "./release-policy.ts";
36
37
  import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
37
38
 
38
39
  /**
@@ -46,7 +47,7 @@ export type CreateSessionFn = (opts: {
46
47
  resume?: boolean;
47
48
  role: SessionRole;
48
49
  releaseGrants?: ResolvedGrants;
49
- onReleaseBlocked?: (shape: ReleaseShape) => void;
50
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
50
51
  onSpawn?: (pid: number) => void;
51
52
  socketPath?: string;
52
53
  verbSocketPath?: string;
@@ -84,7 +85,7 @@ export interface OrchestratorOpts {
84
85
  sessionDir?: string;
85
86
  model?: string;
86
87
  releaseGrants?: ResolvedGrants;
87
- onReleaseBlocked?: (shape: ReleaseShape) => void;
88
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
88
89
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
89
90
  onSpawn?: (pid: number) => void;
90
91
  /** Control socket for the session child, beside its own working directory. */
package/src/plugin.ts CHANGED
@@ -83,10 +83,14 @@ import {
83
83
  DEFAULT_CAPS,
84
84
  DRAFT_POLICIES,
85
85
  RELEASE_REQUIREMENTS,
86
+ INTERRUPT_CATEGORIES,
86
87
  RELEASE_SHAPES,
88
+ WEEKDAYS,
87
89
  type Caps,
88
90
  type ConductorConfig,
89
91
  type OrchestratorMode,
92
+ type InterruptCategory,
93
+ type Weekday,
90
94
  type ProjectConfig,
91
95
  type ProjectPolicy,
92
96
  type ReleaseRequirement,
@@ -230,6 +234,7 @@ async function askValid(
230
234
  throw new Cancelled();
231
235
  }
232
236
 
237
+
233
238
  /** A cap. Unparseable input keeps the current value rather than writing a NaN
234
239
  * the validator would later reject — the operator sees why, immediately. */
235
240
  async function askNumber(ctx: CommandContext, title: string, fallback: number): Promise<number> {
@@ -794,7 +799,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
794
799
  const tuneCaps = await ctx.ui.confirm(
795
800
  "Caps",
796
801
  `Defaults: ${workersDefault} workers, ` +
797
- `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
802
+ `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
803
+ `${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` +
798
804
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
799
805
  `${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
800
806
  `${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
@@ -821,7 +827,32 @@ const askCaps: AreaAsker = async (ctx, a) => {
821
827
  "Spend ceiling per rolling day (USD) — blank = no spend cap",
822
828
  caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
823
829
  );
824
- caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
830
+ const workerMaxTurns = await askNumber(
831
+ ctx,
832
+ "Turn ceiling per worker",
833
+ caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
834
+ );
835
+ caps.workerMaxTurns = workerMaxTurns;
836
+ const turnCeilingFallback = Math.max(
837
+ workerMaxTurns,
838
+ caps.workerMaxTurnsCeiling ??
839
+ Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
840
+ );
841
+ const workerMaxTurnsCeiling = await askNumber(
842
+ ctx,
843
+ "Maximum turn ceiling for one issue",
844
+ turnCeilingFallback,
845
+ );
846
+ if (workerMaxTurnsCeiling < workerMaxTurns) {
847
+ ctx.ui.notify(
848
+ `Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
849
+ `${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
850
+ "warning",
851
+ );
852
+ caps.workerMaxTurnsCeiling = turnCeilingFallback;
853
+ } else {
854
+ caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
855
+ }
825
856
  caps.workerWallClockMs = await askNumber(
826
857
  ctx,
827
858
  "Wall-clock ceiling per worker (ms)",
@@ -898,22 +929,143 @@ const askEscalation: AreaAsker = async (ctx, a) => {
898
929
  return next;
899
930
  };
900
931
 
901
- /** How loud the orchestrator is when nobody asked it anything. */
932
+ /** How loud the orchestrator is and when the operator permits interruptions. */
902
933
  const askReporting: AreaAsker = async (ctx, a) => {
903
934
  const reportScope = await askReportScope(ctx, a.reportScope);
904
- if (reportScope !== "quiet") return { ...a, reportScope };
905
- // `quiet` picks the explicit form, whose only free parameter is when the
906
- // daily rollup happens. Blank = whenever the orchestrator composes it.
907
- const at = await ctx.ui.input(
908
- "Daily rollup time, 24h HH:MM (blank = whenever the orchestrator composes it):",
909
- a.quietDigestAt,
935
+ const continuous = "Continuous (24-hour interrupts)";
936
+ const weekly = "Weekly availability window";
937
+ const mode = await ctx.ui.select(
938
+ "Operator availability",
939
+ [
940
+ {
941
+ label: continuous,
942
+ description: "preserve legacy behavior: configured interrupt categories may page at any hour",
943
+ },
944
+ {
945
+ label: weekly,
946
+ description: "hold non-bypass interruptions outside selected local working hours",
947
+ },
948
+ ],
949
+ { initialIndex: a.availability === undefined ? 0 : 1 },
910
950
  );
911
- const trimmed = at?.trim() ?? "";
912
- if (trimmed !== "" && !/^([01]\d|2[0-3]):[0-5]\d$/.test(trimmed)) {
913
- ctx.ui.notify(`"${trimmed}" is not a 24h HH:MM time leaving the digest model-timed.`, "warning");
914
- return { ...a, reportScope };
951
+ if (mode === undefined) throw new Cancelled();
952
+
953
+ const next: SetupAnswers = { ...a, reportScope };
954
+ delete next.preservedReporting;
955
+ delete next.availability;
956
+ const applyDigestSchedule = (raw: string): SetupAnswers => {
957
+ const updated = { ...next };
958
+ delete updated.dailyDigestAt;
959
+ const value = raw.trim().toLowerCase();
960
+ if (value === "per-tick") {
961
+ updated.digestCadence = "per-tick";
962
+ } else if (value === "off" || value === "disabled" || value === "none") {
963
+ updated.digestCadence = "none";
964
+ } else if (value === "model-timed" || value === "daily") {
965
+ updated.digestCadence = "daily";
966
+ } else {
967
+ updated.digestCadence = "daily";
968
+ updated.dailyDigestAt = raw;
969
+ }
970
+ return updated;
971
+ };
972
+ const askDigestSchedule = async (fallback: string): Promise<SetupAnswers> => {
973
+ const shown =
974
+ a.digestCadence === "per-tick"
975
+ ? "per-tick"
976
+ : a.digestCadence === "none"
977
+ ? "off"
978
+ : a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback);
979
+ const schedule = await askValid(
980
+ ctx,
981
+ 'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)',
982
+ shown,
983
+ (value) =>
984
+ ["per-tick", "model-timed", "daily", "none", "off", "disabled"].includes(
985
+ value.toLowerCase(),
986
+ ) || /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
987
+ ? undefined
988
+ : 'Use "per-tick", "model-timed", "off", or a 24h HH:MM time.',
989
+ );
990
+ return applyDigestSchedule(schedule);
991
+ };
992
+
993
+ if (mode !== weekly) {
994
+ if (mode !== continuous) {
995
+ ctx.ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning");
996
+ }
997
+ if (reportScope !== "quiet") return next;
998
+ return await askDigestSchedule("model-timed");
915
999
  }
916
- return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
1000
+
1001
+ const defaultZone =
1002
+ a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
1003
+ const reportingTimezone = await askValid(
1004
+ ctx,
1005
+ "Operator timezone (IANA, for example Europe/London)",
1006
+ defaultZone,
1007
+ (value) => {
1008
+ try {
1009
+ new Intl.DateTimeFormat("en", { timeZone: value }).format();
1010
+ return undefined;
1011
+ } catch {
1012
+ return `"${value}" is not a known IANA timezone.`;
1013
+ }
1014
+ },
1015
+ );
1016
+ const daysText = await askValid(
1017
+ ctx,
1018
+ "Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)",
1019
+ a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri",
1020
+ (value) => {
1021
+ const days = value.split(",").map((day) => day.trim().toLowerCase());
1022
+ if (days.length === 0 || days.some((day) => !WEEKDAYS.includes(day as Weekday))) {
1023
+ return `Use only: ${WEEKDAYS.join(",")}.`;
1024
+ }
1025
+ return new Set(days).size === days.length ? undefined : "List each weekday only once.";
1026
+ },
1027
+ );
1028
+ const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday);
1029
+ const start = await askValid(
1030
+ ctx,
1031
+ "Availability starts (24h HH:MM)",
1032
+ a.availability?.start ?? "09:00",
1033
+ (value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."),
1034
+ );
1035
+ const end = await askValid(
1036
+ ctx,
1037
+ "Availability ends (24h HH:MM)",
1038
+ a.availability?.end ?? "17:00",
1039
+ (value) =>
1040
+ !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)
1041
+ ? "Use 24h HH:MM."
1042
+ : value === start
1043
+ ? "Start and end must differ."
1044
+ : undefined,
1045
+ );
1046
+ const bypassText = await askValid(
1047
+ ctx,
1048
+ `Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`,
1049
+ a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none",
1050
+ (value) => {
1051
+ if (value.toLowerCase() === "none") return undefined;
1052
+ const categories = value.split(",").map((category) => category.trim().toLowerCase());
1053
+ if (categories.some((category) => !INTERRUPT_CATEGORIES.includes(category as InterruptCategory))) {
1054
+ return `Use only: ${INTERRUPT_CATEGORIES.join(",")}, or "none".`;
1055
+ }
1056
+ return new Set(categories).size === categories.length ? undefined : "List each category only once.";
1057
+ },
1058
+ );
1059
+ const bypass =
1060
+ bypassText.toLowerCase() === "none"
1061
+ ? []
1062
+ : bypassText.split(",").map((category) => category.trim().toLowerCase() as InterruptCategory);
1063
+ const scheduled = await askDigestSchedule(end);
1064
+ return {
1065
+ ...scheduled,
1066
+ reportingTimezone,
1067
+ availability: { days, start, end, bypass },
1068
+ };
917
1069
  };
918
1070
 
919
1071
  /** The operator's own brief. Asked last in the full interview, because the
@@ -36,11 +36,19 @@ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
36
36
 
37
37
  export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
38
38
 
39
- export interface ReleaseBlock {
39
+ export interface ReleaseBlockContext {
40
+ tool: string;
41
+ reason: string;
42
+ args: Record<string, string>;
43
+ }
44
+
45
+ export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
40
46
  project: string;
41
47
  source: "worker" | "orchestrator";
42
48
  shape: ReleaseShape;
43
49
  at: string;
50
+ issue?: number;
51
+ runId?: string;
44
52
  }
45
53
 
46
54
  export type ReleaseDecision = { block: true; reason: string };
@@ -271,6 +279,32 @@ export function releaseDecision(
271
279
  return decision === undefined ? undefined : { shape, decision };
272
280
  }
273
281
 
282
+ const RELEASE_ARG_ALLOWLIST = new Set([
283
+ "repo",
284
+ "stack",
285
+ "artefact",
286
+ "artifact",
287
+ "environment",
288
+ "tag",
289
+ "version",
290
+ "image",
291
+ "prUrl",
292
+ "branch",
293
+ "service",
294
+ ]);
295
+
296
+ /** Keep only explicitly safe release-target strings; redact every other value. */
297
+ export function redactReleaseArgs(input: Record<string, unknown>): Record<string, string> {
298
+ const redacted: Record<string, string> = {};
299
+ for (const [key, value] of Object.entries(input)) {
300
+ redacted[key] =
301
+ RELEASE_ARG_ALLOWLIST.has(key) && typeof value === "string"
302
+ ? value.slice(0, 120)
303
+ : "(redacted)";
304
+ }
305
+ return redacted;
306
+ }
307
+
274
308
  interface ReleasePolicyPi {
275
309
  on(
276
310
  event: "tool_call",
@@ -285,14 +319,18 @@ interface ReleasePolicyPi {
285
319
  export function releasePolicyTripwire(
286
320
  grants: ResolvedGrants,
287
321
  role: SessionRole,
288
- onBlocked: (shape: ReleaseShape) => void = () => {},
322
+ onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
289
323
  ): (pi: ReleasePolicyPi) => void {
290
324
  return (pi) => {
291
325
  pi.on("tool_call", (event) => {
292
326
  const blocked = releaseDecision(grants, role, event.toolName, event.input);
293
327
  if (blocked === undefined) return undefined;
294
328
  try {
295
- onBlocked(blocked.shape);
329
+ onBlocked(blocked.shape, {
330
+ tool: event.toolName,
331
+ reason: blocked.decision.reason,
332
+ args: redactReleaseArgs(event.input),
333
+ });
296
334
  } catch {
297
335
  // Audit is evidence, not the gate. A full disk must not turn a deny into allow.
298
336
  }
@@ -305,11 +343,22 @@ export function recordReleaseBlock(
305
343
  project: string,
306
344
  source: ReleaseBlock["source"],
307
345
  shape: ReleaseShape,
346
+ details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
308
347
  root = stateDir(),
309
348
  now = new Date(),
310
349
  ): void {
311
350
  mkdirSync(root, { recursive: true });
312
- const record: ReleaseBlock = { project, source, shape, at: now.toISOString() };
351
+ // Redact again at the durable boundary. Callers normally pass the tripwire's
352
+ // already-safe context, but no alternate recorder path gets to rely on that.
353
+ const { args, ...attribution } = details;
354
+ const record: ReleaseBlock = {
355
+ project,
356
+ source,
357
+ shape,
358
+ ...attribution,
359
+ ...(args === undefined ? {} : { args: redactReleaseArgs(args) }),
360
+ at: now.toISOString(),
361
+ };
313
362
  appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
314
363
  }
315
364
 
@@ -354,12 +403,23 @@ export function releaseDriftToday(
354
403
  return latest === undefined ? undefined : { count, latest };
355
404
  }
356
405
 
357
- export function releaseDriftDigestLine(project: string, root = stateDir(), now = new Date()): string | undefined {
406
+ export function releaseDriftDigestLine(
407
+ project: string,
408
+ root = stateDir(),
409
+ now = new Date(),
410
+ ): string | undefined {
358
411
  const drift = releaseDriftToday(project, root, now);
359
412
  if (drift === undefined) return undefined;
413
+ const latest = drift.latest;
414
+ const attribution = [
415
+ `${latest.source} ${latest.shape} at ${latest.at}`,
416
+ ...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
417
+ ...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
418
+ ...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
419
+ ].join(", ");
360
420
  return (
361
421
  `Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
362
- `(latest: ${drift.latest.source} ${drift.latest.shape} at ${drift.latest.at}). ` +
422
+ `(latest: ${attribution}). ` +
363
423
  "Include this divergence from releasePolicy=none in today's digest."
364
424
  );
365
425
  }
package/src/reports.ts CHANGED
@@ -36,9 +36,21 @@
36
36
  * conclusion leaves something for the next pass to pick up.
37
37
  */
38
38
 
39
+ import { availabilityDisposition, interruptDisposition } from "./availability.ts";
39
40
  import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
41
  import { localDayKey } from "./digest-schedule.ts";
41
- import type { Escalation, ProjectConfig, ReportRecord, Store } from "./types.ts";
42
+ import {
43
+ DIGEST_BACKLOG_LIMIT,
44
+ INTERRUPT_CATEGORIES,
45
+ type DigestBacklog,
46
+ type Escalation,
47
+ type HeldNotice,
48
+ type InterruptCategory,
49
+ type ProjectConfig,
50
+ type ReportEnqueue,
51
+ type ReportRecord,
52
+ type Store,
53
+ } from "./types.ts";
42
54
 
43
55
  /**
44
56
  * Attempts before a report is written off. Six attempts across the backoff
@@ -85,6 +97,8 @@ export type ReportSend = (text: string) => Promise<number | undefined>;
85
97
  /** What one pass did, by report id. Returned for the daemon's log and the tests. */
86
98
  export interface ReportDeliveryPass {
87
99
  delivered: string[];
100
+ /** Material reports preserved without sending after policy or availability changed. */
101
+ deferred: string[];
88
102
  /** Known-failed, back in `pending` behind a backoff. */
89
103
  requeued: string[];
90
104
  /** The attempt ended without an answer. Left `sending` and flagged as a
@@ -108,7 +122,8 @@ export interface ReportOutbox {
108
122
  }
109
123
 
110
124
  export interface ReportOutboxDeps {
111
- project: ProjectConfig;
125
+ /** A provider lets a resident daemon apply config edits at the next tick. */
126
+ project: ProjectConfig | (() => ProjectConfig);
112
127
  store: Store;
113
128
  /** A report nobody can deliver escalates through this. Optional so a unit
114
129
  * test can exercise delivery without wiring an escalator. */
@@ -116,6 +131,8 @@ export interface ReportOutboxDeps {
116
131
  send?: ReportSend;
117
132
  now?: () => number;
118
133
  log?: (msg: string) => void;
134
+ /** False while the resident daemon cannot validate live delivery policy. */
135
+ deliveryAllowed?: () => boolean;
119
136
  }
120
137
 
121
138
  /**
@@ -200,6 +217,20 @@ export function formatOpenReports(
200
217
  return lines;
201
218
  }
202
219
 
220
+ /** Durable rows still owed to a future digest. Always rendered in `status`: a
221
+ * quiet line proves the accumulator is empty, while a non-zero line makes loss
222
+ * or backlog visible without asking the session what it remembers. */
223
+ export function formatDigestBacklog(backlog: DigestBacklog, now: number = Date.now()): string[] {
224
+ const age = (at: number | undefined): string =>
225
+ at === undefined ? "" : ` (oldest ${humanAge(Math.max(0, now - at))})`;
226
+ return [
227
+ "",
228
+ "digest backlog",
229
+ ` material events ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
230
+ ` held escalations ${backlog.heldNoticeCount}${age(backlog.heldNoticeOldestAt)}`,
231
+ ];
232
+ }
233
+
203
234
  function openReportDetail(r: ReportRecord, now: number): string {
204
235
  const flat = r.lastError?.replace(/\s+/g, " ").trim() ?? "";
205
236
  const error =
@@ -257,10 +288,115 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
257
288
  };
258
289
  }
259
290
 
291
+ /** Keep the mechanical catch-up comfortably inside Telegram's report wrapper. */
292
+ const AVAILABILITY_REPORT_BODY_LIMIT = 3_200;
293
+ const AVAILABILITY_REPORT_KEY_PREFIX = "availability/";
294
+
295
+ interface AvailabilityReportMarker {
296
+ categories: InterruptCategory[];
297
+ urgent: boolean;
298
+ }
299
+
300
+ function availabilityReportMarker(report: ReportRecord): AvailabilityReportMarker | undefined {
301
+ const key = report.dedupeKey;
302
+ if (key === undefined || !key.startsWith(AVAILABILITY_REPORT_KEY_PREFIX)) return undefined;
303
+ const parts = key.slice(AVAILABILITY_REPORT_KEY_PREFIX.length).split("/");
304
+ const urgent = parts[0] === "urgent";
305
+ const encoded = (urgent ? parts[1] : parts[0]) ?? "";
306
+ return {
307
+ urgent,
308
+ categories: encoded
309
+ .split(",")
310
+ .filter((category): category is InterruptCategory =>
311
+ INTERRUPT_CATEGORIES.includes(category as InterruptCategory),
312
+ ),
313
+ };
314
+ }
315
+
316
+ function availabilityNoticeLine(notice: HeldNotice): string {
317
+ const flat = (text: string, limit: number): string => {
318
+ const value = text.replace(/\s+/g, " ").trim();
319
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
320
+ };
321
+ return (
322
+ `- [${notice.category}] ${flat(notice.summary, 180)} ` +
323
+ `(${new Date(notice.createdAt).toISOString()}) — ${flat(notice.detail, 240)}`
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Hand availability-held notices to the existing durable outbox when the live
329
+ * policy permits them again. Association and report creation are one SQLite
330
+ * transaction, so daemon downtime or a send failure cannot lose a notice.
331
+ */
332
+ export function enqueueAvailableHeldNotices(
333
+ project: ProjectConfig,
334
+ store: Store,
335
+ now: number,
336
+ ): ReportEnqueue | undefined {
337
+ // The Store arbitrates the due-digest lease and this catch-up in the same
338
+ // SQLite transaction. A preflight here would reopen the snapshot race.
339
+ const categories = INTERRUPT_CATEGORIES.filter(
340
+ (category) => interruptDisposition(project.reporting, category, now) === "interrupt",
341
+ );
342
+ const urgentCategories = INTERRUPT_CATEGORIES.filter(
343
+ (category) =>
344
+ availabilityDisposition(project.reporting?.availability, category, now) === "interrupt",
345
+ );
346
+ const urgent = store.undigestedNotices(
347
+ project.name,
348
+ DIGEST_BACKLOG_LIMIT,
349
+ true,
350
+ urgentCategories,
351
+ true,
352
+ );
353
+ const eligible =
354
+ urgent.length > 0
355
+ ? urgent
356
+ : store.undigestedNotices(
357
+ project.name,
358
+ DIGEST_BACKLOG_LIMIT,
359
+ true,
360
+ categories,
361
+ false,
362
+ );
363
+ if (eligible.length === 0) return undefined;
364
+
365
+ const lines = [
366
+ "Working-hours catch-up",
367
+ "These interruptions were held durably while the operator was outside the configured availability window:",
368
+ ];
369
+ const selected: HeldNotice[] = [];
370
+ for (const notice of eligible) {
371
+ const line = availabilityNoticeLine(notice);
372
+ const next = [...lines, line].join("\n");
373
+ if (selected.length > 0 && next.length > AVAILABILITY_REPORT_BODY_LIMIT) break;
374
+ lines.push(line);
375
+ selected.push(notice);
376
+ }
377
+
378
+ const selectedCategories = INTERRUPT_CATEGORIES.filter((category) =>
379
+ selected.some((notice) => notice.category === category),
380
+ );
381
+ const urgentMarker = selected[0]?.urgent === true ? "urgent/" : "";
382
+ return store.enqueueAvailabilityReport(
383
+ {
384
+ project: project.name,
385
+ kind: "digest",
386
+ body: lines.join("\n"),
387
+ dedupeKey: `${AVAILABILITY_REPORT_KEY_PREFIX}${urgentMarker}${selectedCategories.join(",")}/${selected[0]!.id}`,
388
+ at: now,
389
+ },
390
+ selected.map((notice) => notice.id),
391
+ );
392
+ }
393
+
260
394
  export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
261
- const { project, store } = deps;
395
+ const { store } = deps;
396
+ const currentProject =
397
+ typeof deps.project === "function" ? deps.project : (): ProjectConfig => deps.project as ProjectConfig;
262
398
  const now = deps.now ?? Date.now;
263
- const send = deps.send ?? telegramReportSend(project);
399
+ const injectedSend = deps.send;
264
400
  const log = deps.log ?? ((): void => {});
265
401
 
266
402
  /**
@@ -279,6 +415,7 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
279
415
  * undeliverable report pages exactly once and never every five minutes.
280
416
  */
281
417
  const pageUndeliverable = async (r: ReportRecord, error: string): Promise<void> => {
418
+ const project = currentProject();
282
419
  if (deps.escalate === undefined) return;
283
420
  try {
284
421
  await deps.escalate({
@@ -318,6 +455,64 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
318
455
  };
319
456
 
320
457
  const attempt = async (r: ReportRecord, pass: ReportDeliveryPass): Promise<void> => {
458
+ const project = currentProject();
459
+ if (deps.deliveryAllowed?.() === false) {
460
+ pass.deferred.push(r.id);
461
+ return;
462
+ }
463
+ const availabilityMarker = availabilityReportMarker(r);
464
+ if (availabilityMarker !== undefined) {
465
+ if (availabilityMarker.categories.length === 0) {
466
+ pass.deferred.push(r.id);
467
+ log(`availability catch-up ${r.id} has no valid category marker and was held fail-closed`);
468
+ return;
469
+ }
470
+ const dispositions = availabilityMarker.categories.map((category) =>
471
+ availabilityMarker.urgent
472
+ ? availabilityDisposition(project.reporting?.availability, category, now())
473
+ : interruptDisposition(project.reporting, category, now()),
474
+ );
475
+ if (dispositions.some((disposition) => disposition !== "interrupt")) {
476
+ if (store.releasePendingAvailabilityReport(r.id, project.name, r.ambiguous)) {
477
+ const reason = dispositions.includes("digest")
478
+ ? "reporting policy changed"
479
+ : "the availability window closed";
480
+ log(`availability catch-up ${r.id} returned to the digest because ${reason}`);
481
+ }
482
+ pass.deferred.push(r.id);
483
+ return;
484
+ }
485
+ }
486
+ if (r.kind === "material") {
487
+ const at = now();
488
+ const disposition = interruptDisposition(project.reporting, "material", at);
489
+ if (disposition !== "interrupt") {
490
+ const firstLine = r.body.split("\n", 1)[0]!;
491
+ const deferred = store.deferPendingReportToNotice(r.id, {
492
+ id: r.id,
493
+ project: project.name,
494
+ category: "material",
495
+ summary: (
496
+ r.ambiguous ? `POSSIBLE REPEAT of report ${r.id}: ${firstLine}` : firstLine
497
+ ).slice(0, 240),
498
+ detail: r.ambiguous
499
+ ? `This report may already have reached Telegram before its outcome was lost.\n\n${r.body}`
500
+ : r.body,
501
+ createdAt: at,
502
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
503
+ });
504
+ if (deferred) {
505
+ pass.deferred.push(r.id);
506
+ log(
507
+ `report ${r.id} preserved for ${
508
+ disposition === "availability" ? "the next availability window" : "a digest"
509
+ } after reporting policy changed`,
510
+ );
511
+ return;
512
+ }
513
+ }
514
+ }
515
+ const send = injectedSend ?? telegramReportSend(project);
321
516
  const attemptId = crypto.randomUUID();
322
517
  // Losing this race is ordinary: another pass, or another daemon, already
323
518
  // owns the attempt. Returning is what keeps one report from being in flight
@@ -400,12 +595,14 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
400
595
 
401
596
  return {
402
597
  recover(staleAt: number): ReportRecord[] {
403
- return store.recoverSendingReports(project.name, staleAt, now());
598
+ return store.recoverSendingReports(currentProject().name, staleAt, now());
404
599
  },
405
600
 
406
601
  async deliverDue(): Promise<ReportDeliveryPass> {
602
+ const project = currentProject();
407
603
  const pass: ReportDeliveryPass = {
408
604
  delivered: [],
605
+ deferred: [],
409
606
  requeued: [],
410
607
  uncertain: [],
411
608
  failed: [],
@@ -22,6 +22,7 @@
22
22
  import { connect } from "node:net";
23
23
 
24
24
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
25
+ import type { ReleaseBlockContext } from "./release-policy.ts";
25
26
 
26
27
  import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
27
28
 
@@ -65,7 +66,7 @@ export type HostToParent =
65
66
  | { t: "session-file"; path: string }
66
67
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
67
68
  | { t: "park-result"; id: number; ok: boolean; error?: string }
68
- | { t: "release-blocked"; shape: ReleaseShape };
69
+ | ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
69
70
 
70
71
  /**
71
72
  * Depth at which a harness event stops being copied for the wire.
@@ -186,8 +187,8 @@ export async function runSessionHost(
186
187
  // The release audit lives in the daemon's state directory, which this
187
188
  // process may not be able to write and must not be trusted to. It
188
189
  // becomes a message; the parent performs the durable write.
189
- onReleaseBlocked: (shape) => {
190
- send({ t: "release-blocked", shape });
190
+ onReleaseBlocked: (shape, context) => {
191
+ send({ t: "release-blocked", shape, ...context });
191
192
  },
192
193
  });
193
194
  } catch (err) {