omp-conductor 0.10.0 → 0.13.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
@@ -266,17 +266,49 @@ const CHANGED_LINE = /^[ \t>]*changed:[ \t]*(.*)$/im;
266
266
  */
267
267
  const CLAIMED_PATH = /^(?:[\w.@~+-]+\/)+[\w.@~*+-]*$|^[\w.@~+-]*\.[A-Za-z][A-Za-z0-9]{1,7}$/;
268
268
 
269
+ /** A Python dotted module: `services.rule_purge`, `omp.routing`. Not a path —
270
+ * segments joined by `.` — but a legitimate way for a report to name the file
271
+ * it touched without disclosing the directory it lives in (#224).
272
+ *
273
+ * Every segment is at least two characters so prose abbreviations (`e.g`,
274
+ * `i.e.`, `a.k.a`) stay commentary: they are exactly the prose the path
275
+ * heuristic exists to reject, and each one that slipped through would become
276
+ * an "you claimed a file you never touched" flag on an honest report. */
277
+ const DOTTED_MODULE = /^[A-Za-z_]\w+(?:\.[A-Za-z_]\w+)+$/;
278
+
279
+ /** One-level brace expansion: `a/{b,c}.py` → [`a/b.py`, `a/c.py`]. Braces are
280
+ * how a report compresses adjacent files into one token; each comma-separated
281
+ * alternative is substituted into the skeleton. Nesting is deliberately
282
+ * unsupported — one level matches the spellings reports actually produce. */
283
+ function expandBraces(token: string): string[] {
284
+ const group = /\{[^{}]*\}/.exec(token);
285
+ if (group === null) return [token];
286
+ const alternatives = group[0].slice(1, -1).split(",").filter((a) => a.length > 0);
287
+ if (alternatives.length === 0) return [token];
288
+ const prefix = token.slice(0, group.index);
289
+ const suffix = token.slice(group.index + group[0].length);
290
+ return alternatives.map((alt) => prefix + alt + suffix);
291
+ }
292
+
269
293
  /** Every path-shaped token on the report's `changed:` line. An absent line and
270
294
  * a line naming nothing are the same answer: nothing was disclosed. */
271
295
  export function claimedPaths(report: string): string[] {
272
296
  const line = CHANGED_LINE.exec(report)?.[1] ?? "";
273
297
  const seen = new Set<string>();
274
- for (const raw of line.split(/[\s,;]+/)) {
298
+ // Split on whitespace and semicolons, and on commas *outside* a brace group:
299
+ // the comma in `a/{b,c}.py` is an alternative separator, not a token
300
+ // separator, so a brace-compressed token must survive whole (#224).
301
+ for (const raw of line.split(/[\s;]+|,(?![^{}]*\})/)) {
275
302
  const token = raw.replace(/^[`'"([*-]+/, "").replace(/[`'")\].,:;]+$/, "");
276
303
  if (token === "" || token === "none") continue;
277
304
  const normalised = token.replace(/^\.?\//, "");
278
- if (!CLAIMED_PATH.test(normalised)) continue;
279
- seen.add(normalised);
305
+ // Expansion happens here so every downstream consumer — both `covers`
306
+ // directions — sees plain paths: a brace token dies earlier at the
307
+ // CLAIMED_PATH filter if it is never opened up (#224).
308
+ for (const expanded of expandBraces(normalised)) {
309
+ if (!CLAIMED_PATH.test(expanded) && !DOTTED_MODULE.test(expanded)) continue;
310
+ seen.add(expanded);
311
+ }
280
312
  }
281
313
  return [...seen];
282
314
  }
@@ -305,6 +337,15 @@ function covers(claim: string, path: string): boolean {
305
337
  return pattern.test(path) || pattern.test(basename(path));
306
338
  }
307
339
  if (path.endsWith(`/${claim}`) || claim.endsWith(`/${path}`)) return true;
340
+ // A Python dotted module names a file by import path, not by location:
341
+ // `services.rule_purge` covers `backend/app/services/rule_purge.py`. Only
342
+ // consulted after the path rules, because `foo.bar` is also a valid filename
343
+ // and a literal match must win. Matching several changed files is fine — the
344
+ // check wants evidence the worker knew, not a unique index (#224).
345
+ if (DOTTED_MODULE.test(claim)) {
346
+ const fragment = `${claim.replaceAll(".", "/")}.py`;
347
+ if (path === fragment || path.endsWith(`/${fragment}`)) return true;
348
+ }
308
349
  const last = claim.slice(claim.lastIndexOf("/") + 1);
309
350
  return !last.includes(".") && path.startsWith(`${claim}/`);
310
351
  }
@@ -704,6 +745,10 @@ export function formatSettlementFlags(
704
745
  pooledHeading(diff.attempts),
705
746
  ];
706
747
  for (const flag of flags.slice(0, RENDERED_FLAGS)) {
748
+ if (flag.kind === "pr-adopted") {
749
+ lines.push(` ${flag.kind} — ${flag.detail}`);
750
+ continue;
751
+ }
707
752
  lines.push(
708
753
  ` ${flag.kind} ${flag.file}${flag.line === undefined ? "" : `:${flag.line}`} — ${flag.detail}` +
709
754
  (flag.unattributed === true ? " [unattributed: the dispatching issue never names this file]" : ""),
@@ -0,0 +1,59 @@
1
+ /**
2
+ * When the daily digest is due, in the zone the operator reads it (#229).
3
+ *
4
+ * Pure by construction: every fact arrives as a timestamp and the policy, and
5
+ * nothing here reads a clock or a file. The digest is "one per day" in the
6
+ * IANA zone it is configured with (host zone when none) — a UTC key would roll
7
+ * the digest over mid-evening for anyone west of Greenwich.
8
+ */
9
+
10
+ import type { ReportingPolicy } from "./types.ts";
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")}`;
23
+ }
24
+
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));
33
+ }
34
+
35
+ /**
36
+ * Whether the digest is due now.
37
+ *
38
+ * - `none` → never.
39
+ * - `per-tick` → always (the orchestrator sends it with every report).
40
+ * - `daily` without `at` → once per day in the configured zone.
41
+ * - `daily` with `at` → on a day it has not already run, once the local clock
42
+ * has passed `at`. A restart after `at` still finds today unsent → one
43
+ * catch-up; a fully missed day is skipped, never sent late.
44
+ */
45
+ export function digestDue(
46
+ policy: Pick<ReportingPolicy, "digest">,
47
+ lastDigestDayKey: string | undefined,
48
+ now: number,
49
+ ): boolean {
50
+ const { cadence, at, timezone } = policy.digest;
51
+ if (cadence === "none") return false;
52
+ if (cadence === "per-tick") return true;
53
+ // daily
54
+ if (at === undefined) {
55
+ return lastDigestDayKey !== localDayKey(now, timezone);
56
+ }
57
+ if (lastDigestDayKey === localDayKey(now, timezone)) return false;
58
+ return localClockAt(now, timezone) >= at;
59
+ }
package/src/escalate.ts CHANGED
@@ -213,6 +213,23 @@ export function createEscalator(
213
213
  if (e.tier === 2 && chatId) {
214
214
  const token = readTelegramToken();
215
215
  if (token) {
216
+ // The digest can own deferred delivery only while its orchestrator
217
+ // loop is alive. An urgent escalation says that loop is the failed
218
+ // component, so waiting for its digest would park the only warning
219
+ // behind the failure it reports (#246).
220
+ const category = e.category ?? "tier2";
221
+ const interruptOn = p.reporting?.interruptOn;
222
+ if (!e.urgent && 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
+ }
216
233
  // A send failure throws: `markNotified` stays uncalled so the next
217
234
  // poll retries instead of writing the event off as delivered. No
218
235
  // 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. */
@@ -144,6 +147,16 @@ export function providerCreditRefusal(error: {
144
147
  return error.message.split("\n")[0]?.trim() ?? error.message;
145
148
  }
146
149
 
150
+ /** Provider text that names a per-request stream fault. Deliberately narrow:
151
+ * a 429 is rate limiting and a 402 is credit — different remedies (#220). */
152
+ const TRANSIENT_FAULT_SIGNATURES = ["stream stalled"] as const;
153
+
154
+ export function providerTransientFault(error: { status?: number; message: string }): string | undefined {
155
+ const text = error.message.toLowerCase();
156
+ if (!TRANSIENT_FAULT_SIGNATURES.some((s) => text.includes(s))) return undefined;
157
+ return error.message.split("\n")[0]?.trim() ?? error.message;
158
+ }
159
+
147
160
  /**
148
161
  * Evidence that this run never started, or `undefined` when something did happen.
149
162
  *
@@ -211,6 +224,8 @@ const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
211
224
 
212
225
  export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
213
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 });
214
229
 
215
230
  // The PR landed while the row says otherwise. Whatever else is true about this
216
231
  // run, it succeeded, and the recovery is bookkeeping.
@@ -239,15 +254,27 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
239
254
  // first request produces a turn-0 row with no artifacts, which
240
255
  // `env-start-failure` would otherwise absorb and lose the cause.
241
256
  if (run.state === "failed" || run.state === "killed") {
242
- const credit =
243
- run.lastError === undefined
244
- ? undefined
245
- : providerCreditRefusal({ message: run.lastError });
257
+ const credit = providerError === undefined ? undefined : providerCreditRefusal(providerError);
246
258
  if (credit !== undefined) {
247
259
  return { cls: "provider-credit", recovery: "requeue", evidence: credit };
248
260
  }
249
261
  }
250
262
 
263
+ // A per-request stream fault: the provider aborted mid-stream — the session
264
+ // records "OpenAI responses stream stalled while waiting for the next event",
265
+ // but the attempt never produced a verdict and 0 tokens were billed. Not the
266
+ // work's fault, so it must not charge an implementation attempt; requeued, but
267
+ // bounded by PROVIDER_TRANSIENT_MAX_STRIKES so a provider that keeps aborting
268
+ // escalates to a human instead of looping (#220). Ahead of `neverStarted` for
269
+ // the same reason as the credit branch: a turn-0 stall would otherwise be
270
+ // absorbed by `env-start-failure` and lose its cause.
271
+ if (run.state === "failed" || run.state === "killed") {
272
+ const transient = providerError === undefined ? undefined : providerTransientFault(providerError);
273
+ if (transient !== undefined) {
274
+ return { cls: "provider-transient", recovery: "requeue", evidence: transient };
275
+ }
276
+ }
277
+
251
278
  // A run that never started is the most classifiable failure there is, and the
252
279
  // least deserving of an implementation attempt: the session did not get as far
253
280
  // as reading the issue. See {@link neverStarted} for the two shapes and why the
package/src/fleet.ts CHANGED
@@ -38,6 +38,7 @@ import { settlementFlagSummary } from "./diff-flags.ts";
38
38
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
39
39
  import { formatOpenReports } from "./reports.ts";
40
40
  import {
41
+ formatBaseChecks,
41
42
  formatDispatchSummary,
42
43
  formatReleaseGrants,
43
44
  formatSalvagedRuns,
@@ -1026,7 +1027,7 @@ export function formatFleetStatus(
1026
1027
  // a halt) answers "who stopped the fleet" without opening a file (#185). An
1027
1028
  // unparseable sentinel — paused but with no datable line 1 — is itself news:
1028
1029
  // it means a run admitted before an *unknown* pause cannot prove innocence
1029
- // (#174), so every mutation is refused.
1030
+ // (#174), so completion mutations fail closed while release gates remain usable.
1030
1031
  const dispatchLine =
1031
1032
  layers.dispatch === "paused"
1032
1033
  ? (() => {
@@ -1036,7 +1037,7 @@ export function formatFleetStatus(
1036
1037
  return `dispatch paused (source: ${prov.source}${reason})`;
1037
1038
  }
1038
1039
  return isPaused() && pausedAt() === undefined
1039
- ? "dispatch paused (unparseable sentinel — all mutations refused)"
1040
+ ? "dispatch paused (unparseable sentinel — new work and completion mutations refused; release gates remain available)"
1040
1041
  : "dispatch paused";
1041
1042
  })()
1042
1043
  : `dispatch ${layers.dispatch}`;
@@ -1110,6 +1111,9 @@ function formatProjectBody(s: StatusSnapshot): string {
1110
1111
  })`,
1111
1112
  ]),
1112
1113
  ` new worker turns ${s.caps.workerMaxTurns}`,
1114
+ ...s.turnOverrides.map(
1115
+ ({ issue, maxTurns }) => ` turn override #${issue} → ${maxTurns} (next attempt)`,
1116
+ ),
1113
1117
  ` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
1114
1118
  ` failed attempts ${s.caps.maxAttemptsPerIssue}`,
1115
1119
  ` continuations ${s.caps.maxContinuationsPerIssue}`,
@@ -1136,6 +1140,7 @@ function formatProjectBody(s: StatusSnapshot): string {
1136
1140
  if (flagged !== undefined) lines.push(` ${flagged}`);
1137
1141
  }
1138
1142
  }
1143
+ lines.push(...formatBaseChecks(s.baseChecks));
1139
1144
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
1140
1145
  lines.push(...formatOpenReports(s.openReports));
1141
1146
  if (s.liveWorkers > 0) {
@@ -1381,7 +1386,7 @@ export async function probeTelegramHealth(
1381
1386
  detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
1382
1387
  };
1383
1388
  }
1384
- return { kind: "ok", detail: `${username}; inbound configured; telegram_ask available` };
1389
+ return { kind: "ok", detail: `${username}; inbound configured; telegram_ask expected per config (mounted per turn by omp-telegram — a tick that finds it missing falls back to telegram_send and says so)` };
1385
1390
  }
1386
1391
 
1387
1392
  export function sessionDirForCwd(cwd: string): string {
package/src/gitops.ts CHANGED
@@ -19,7 +19,9 @@
19
19
 
20
20
  import { join } from "node:path";
21
21
 
22
+ import { parseChainSource, type ChainEntry } from "./chain-check.ts";
22
23
  import type { ProjectConfig, RepoTarget } from "./types.ts";
24
+ import { ensureMirror } from "./worktree.ts";
23
25
 
24
26
  /**
25
27
  * One process run, captured. Injected everywhere in this module so the
@@ -190,3 +192,50 @@ export async function openRunPr(
190
192
  }
191
193
  return { ok: true, url };
192
194
  }
195
+
196
+ // ------------------------------------------------- the migration-chain guard
197
+
198
+ /**
199
+ * The base branch's ordered migration chain, read at the tip — not from a
200
+ * checkout that might be stale, but from the live mirror `ensureMirror`
201
+ * refreshes every call, which is the same freshness contract the dispatch path
202
+ * relies on. Alembic fields only; files without a `revision` line are skipped.
203
+ */
204
+ export async function readBaseChain(
205
+ project: Pick<ProjectConfig, "mirrorRoot">,
206
+ repo: RepoTarget,
207
+ dir: string,
208
+ exec: Exec = spawnCaptured,
209
+ ): Promise<{ ok: true; entries: ChainEntry[] } | { ok: false; stderr: string }> {
210
+ try {
211
+ const mirror = await ensureMirror(repo, project.mirrorRoot);
212
+ const env = credentialedEnv();
213
+ const listed = await exec(
214
+ ["git", "--git-dir", mirror, "ls-tree", "-r", "--name-only", repo.defaultBranch, "--", dir],
215
+ { env },
216
+ );
217
+ if (listed.code !== 0) {
218
+ return {
219
+ ok: false,
220
+ stderr: scrubUserinfo(listed.stderr.trim() || listed.stdout.trim() || `git ls-tree exited ${String(listed.code)}`),
221
+ };
222
+ }
223
+ const entries: ChainEntry[] = [];
224
+ for (const rel of listed.stdout.split("\n")) {
225
+ const path = rel.trim();
226
+ if (path === "" || !path.endsWith(".py")) continue;
227
+ const shown = await exec(["git", "--git-dir", mirror, "show", `${repo.defaultBranch}:${path}`], { env });
228
+ if (shown.code !== 0) {
229
+ return {
230
+ ok: false,
231
+ stderr: scrubUserinfo(shown.stderr.trim() || `git show ${path} exited ${String(shown.code)}`),
232
+ };
233
+ }
234
+ const parsed = parseChainSource(path, shown.stdout);
235
+ if (parsed !== undefined) entries.push(parsed);
236
+ }
237
+ return { ok: true, entries };
238
+ } catch (err) {
239
+ return { ok: false, stderr: err instanceof Error ? err.message : String(err) };
240
+ }
241
+ }
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,
@@ -46,6 +46,12 @@ export interface AgentSessionLike {
46
46
  */
47
47
  on(event: string, cb: (e: unknown) => void): void;
48
48
  abort(): void;
49
+ /**
50
+ * Cooperative operator park: abort the active turn and resolve when the
51
+ * harness is idle (#238). Distinct from `abort()`, which is the
52
+ * fire-and-forget kill path and must stay cheap for cap kills.
53
+ */
54
+ park(): Promise<void>;
49
55
  /**
50
56
  * Absolute transcript path the harness opened, so a human — and the
51
57
  * arm/monitor tooling — can read what the worker actually did. `undefined`
@@ -93,7 +99,11 @@ interface OmpModule {
93
99
  interface RawSession {
94
100
  prompt(text: string, opts?: unknown): Promise<unknown>;
95
101
  subscribe(listener: (event: unknown) => void): unknown;
96
- abort(opts?: unknown): void;
102
+ abort(opts?: {
103
+ goalReason?: "interrupted" | "internal";
104
+ reason?: string;
105
+ preserveCompaction?: boolean;
106
+ }): Promise<void> | void;
97
107
  dispose?(opts?: unknown): Promise<unknown>;
98
108
  readonly sessionFile?: string;
99
109
  }
@@ -148,7 +158,7 @@ export async function createLocalSession(opts: {
148
158
  /** Install the release/deploy tool-call gate with these per-shape grants. */
149
159
  releaseGrants?: ResolvedGrants;
150
160
  /** Durable audit callback invoked only when that gate rejects a call. */
151
- onReleaseBlocked?: (shape: ReleaseShape) => void;
161
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
152
162
  /**
153
163
  * The conductor verb socket this session's mutation tools call (#126).
154
164
  *
@@ -262,6 +272,11 @@ export async function createLocalSession(opts: {
262
272
  abort() {
263
273
  raw.abort();
264
274
  },
275
+ park() {
276
+ return Promise.resolve(
277
+ raw.abort({ goalReason: "interrupted", reason: "operator pause" }),
278
+ ).then(() => undefined);
279
+ },
265
280
  // The path the session actually opened, never one we asked for: the
266
281
  // arm/monitor tooling reads this file as proof of activity, so a path
267
282
  // nothing ever writes to is worse than no path at all.
@@ -378,7 +393,7 @@ export interface CreateSessionOptions {
378
393
  resume?: boolean;
379
394
  role: SessionRole;
380
395
  releaseGrants?: ResolvedGrants;
381
- onReleaseBlocked?: (shape: ReleaseShape) => void;
396
+ onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
382
397
 
383
398
  /**
384
399
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -421,10 +436,10 @@ export interface CreateSessionOptions {
421
436
  * Start one omp coding session in a **child process** and return a proxy for it.
422
437
  *
423
438
  * The proxy is the whole of `omp-conductor`'s view of a session, and it is
424
- * deliberately thin: {@link AgentSessionLike} has five members, so there are
425
- * five things to forward and nothing else belongs here. `prompt` and `abort`
426
- * go out over a unix socket, harness events come back and are re-emitted to the
427
- * same `on()` subscribers the in-process version served, and `sessionFile` is
439
+ * deliberately thin: {@link AgentSessionLike} is the entire forwarded
440
+ * surface. `prompt`, `park`, and `abort` go out over a unix socket, harness
441
+ * events come back and are re-emitted to the same `on()` subscribers the
442
+ * in-process version served, and `sessionFile` is
428
443
  * whatever path the child reports the session actually opened — never one this
429
444
  * side invented.
430
445
  *
@@ -657,8 +672,20 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
657
672
  else waiter.reject(new Error(message.error ?? "session prompt failed"));
658
673
  break;
659
674
  }
675
+ case "park-result": {
676
+ const waiter = pending.get(message.id);
677
+ if (waiter === undefined) break;
678
+ pending.delete(message.id);
679
+ if (message.ok) waiter.resolve();
680
+ else waiter.reject(new Error(message.error ?? "session park failed"));
681
+ break;
682
+ }
660
683
  case "release-blocked":
661
- opts.onReleaseBlocked?.(message.shape);
684
+ opts.onReleaseBlocked?.(message.shape, {
685
+ tool: message.tool,
686
+ reason: message.reason,
687
+ args: message.args,
688
+ });
662
689
  break;
663
690
  }
664
691
  }
@@ -693,6 +720,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
693
720
  // would make this seam depend on a shape it deliberately does not name.
694
721
  return promise.then(() => undefined);
695
722
  },
723
+ park() {
724
+ promptSeq += 1;
725
+ const id = promptSeq;
726
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
727
+ pending.set(id, { resolve, reject });
728
+ write({ t: "park", id });
729
+ return promise.then(() => undefined);
730
+ },
696
731
  on(event, cb) {
697
732
  const list = handlers.get(event);
698
733
  if (list) list.push(cb);
@@ -62,23 +62,27 @@ import {
62
62
  } from "./setup.ts";
63
63
  import {
64
64
  recordReleaseBlock,
65
+ redactReleaseArgs,
65
66
  releaseDriftDigestLine,
66
67
  releaseRefusal,
67
68
  releaseShapeFromTool,
68
69
  type ReleaseDecision,
69
70
  } from "./release-policy.ts";
70
71
  import {
72
+ DEFAULT_REPORT_POLICY,
71
73
  DEFAULT_REPORT_SCOPE,
72
74
  DENIED_RELEASE_GRANTS,
73
75
  type DispatchSummary,
74
76
  type FrictionSignal,
75
77
  type ReportScope,
78
+ type ReportingPolicy,
76
79
  type ResolvedGrants,
77
80
  type Store,
78
81
  } from "./types.ts";
79
82
  import { formatDecisionDigest } from "./decisions.ts";
80
83
  import type { RunRecord } from "./types.ts";
81
84
  import { dbPath, openStore } from "./store.ts";
85
+ import { digestDue } from "./digest-schedule.ts";
82
86
 
83
87
  /** The activation file. Absent means "this is not an orchestrator session". */
84
88
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -386,7 +390,17 @@ export function queueDigestLine(
386
390
  return `Queue: empty — nothing carries "${queueLabel}". Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report why there is nothing to do.`;
387
391
  }
388
392
  if (summary.routed === 0) {
389
- return `Queue: ${summary.ready} ready but 0 routable each needs exactly one "${labelPrefix}<repo>" label before dispatch can ever see it.`;
393
+ // `ready` counts claimed (in-flight) issues too; route() drops those with a
394
+ // state label silently, so "0 routable" must not blanket-blame missing
395
+ // `repo:` labels (#228). Spare depth is what dispatch can actually claim.
396
+ const claimed = summary.claimed ?? 0;
397
+ const unroutable = summary.holds
398
+ .filter((h) => h.reason.startsWith("unroutable:"))
399
+ .reduce((n, h) => n + h.count, 0);
400
+ if (unroutable === 0) {
401
+ return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
402
+ }
403
+ return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label).`;
390
404
  }
391
405
  if (summary.routed >= groomBelow) return undefined;
392
406
  let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
@@ -399,11 +413,51 @@ export function queueDigestLine(
399
413
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
400
414
  material: "Report material events per your brief.",
401
415
  escalations:
402
- "Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
416
+ "Interrupt only for: tier2, fleet-stopped; everything else releases included waits for the daily digest.",
403
417
  decisions:
404
418
  "Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.",
405
419
  };
406
420
 
421
+ /**
422
+ * The reporting constraint appended to a default tick prompt (#229, #242).
423
+ *
424
+ * Material and decisions keep their legacy preset words. The escalations
425
+ * preset instead names the policy's actual interrupt categories, so the prompt
426
+ * cannot drift from the gate. Every daily cadence gets an explicit due state:
427
+ * model-timed digests need that instruction just as scheduled digests do.
428
+ */
429
+ export function tickReportingConstraint(
430
+ policy: ReportingPolicy | undefined,
431
+ digest: {
432
+ due: boolean;
433
+ cadence: ReportingPolicy["digest"]["cadence"];
434
+ at?: string;
435
+ timezone?: string;
436
+ },
437
+ held: number,
438
+ ): string {
439
+ const preset = policy?.scopePreset;
440
+ const allowed = policy?.interruptOn ?? [];
441
+ let base: string;
442
+ if (preset === "escalations") {
443
+ base = `Interrupt only for: ${allowed.join(", ")}; everything else — releases included — waits for the daily digest.`;
444
+ } else if (preset !== undefined) {
445
+ base = TICK_SCOPE_CONSTRAINTS[preset];
446
+ } else {
447
+ base =
448
+ allowed.length === 0
449
+ ? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
450
+ : `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
451
+ }
452
+ if (digest.cadence !== "daily") return base;
453
+ if (digest.due) {
454
+ return `${base} The daily digest is DUE now — compose it from this tick's accumulated events and the ${held} held notice(s) below, then send via omp-conductor report --kind digest.`;
455
+ }
456
+ return digest.at === undefined
457
+ ? `${base} The daily digest was already sent today; do not send another.`
458
+ : `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
459
+ }
460
+
407
461
  /**
408
462
  * The delivery clause, appended to every default tick prompt.
409
463
  *
@@ -572,6 +626,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
572
626
  */
573
627
  export function resolveTickScope(): {
574
628
  scope: ReportScope;
629
+ policy?: ReportingPolicy;
575
630
  briefPath?: string;
576
631
  policyPath?: string;
577
632
  projectName?: string;
@@ -580,7 +635,8 @@ export function resolveTickScope(): {
580
635
  try {
581
636
  const project = findProject(loadConfig());
582
637
  return {
583
- scope: project.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
638
+ scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
639
+ policy: project.reporting,
584
640
  briefPath: briefPathForProject(project),
585
641
  policyPath: policyPathForProject(project),
586
642
  projectName: project.name,
@@ -1327,7 +1383,31 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1327
1383
  pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
1328
1384
  }
1329
1385
  const bridged = approval?.kind === "ready" && approval.notifyMode === "always" && profile?.kind === "interactive";
1330
- content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
1386
+ let reportingConstraint = TICK_SCOPE_CONSTRAINTS[scope.scope];
1387
+ if (scope.projectName !== undefined) {
1388
+ const policy = scope.policy;
1389
+ const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
1390
+ const cadence = digestPolicy.cadence;
1391
+ const at = Date.now();
1392
+ const store = openStore(dbPath());
1393
+ try {
1394
+ const lastKey = store.lastDigestDedupeKey(scope.projectName);
1395
+ const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1396
+ reportingConstraint = tickReportingConstraint(
1397
+ policy,
1398
+ {
1399
+ due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
1400
+ cadence,
1401
+ at: digestPolicy.at,
1402
+ timezone: digestPolicy.timezone,
1403
+ },
1404
+ store.undigestedNotices(scope.projectName).length,
1405
+ );
1406
+ } finally {
1407
+ store.close();
1408
+ }
1409
+ }
1410
+ content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
1331
1411
  }
1332
1412
  let frictionStore: Store | undefined;
1333
1413
  let frictionSignals: FrictionSignal[] = [];
@@ -1613,9 +1693,17 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1613
1693
  // `createSession`; suppress this second copy only after this session has
1614
1694
  // proved it owns the external heartbeat.
1615
1695
  if (releaseAuthorityAccepted && !external) return undefined;
1696
+ // Before ownership is proved this session holds no grant at all, so a
1697
+ // covered shape still refuses with the deny-all wording.
1698
+ const decision = refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
1699
+ if (decision === undefined) return undefined;
1616
1700
  if (projectName !== undefined) {
1617
1701
  try {
1618
- recordReleaseBlock(projectName, "orchestrator", shape);
1702
+ recordReleaseBlock(projectName, "orchestrator", shape, {
1703
+ tool: event.toolName,
1704
+ reason: decision.reason,
1705
+ args: redactReleaseArgs(event.input),
1706
+ });
1619
1707
  } catch (err) {
1620
1708
  pi.logger.error(
1621
1709
  `[omp-conductor] could not record release-policy block: ${
@@ -1624,10 +1712,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1624
1712
  );
1625
1713
  }
1626
1714
  }
1627
- // Before ownership is proved this session holds no grant at all, so a
1628
- // covered shape still refuses — with the wording it would get from a
1629
- // deny-all map rather than a claim about a grant it cannot yet use.
1630
- return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
1715
+ return decision;
1631
1716
  });
1632
1717
  };
1633
1718
 
@@ -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. */