omp-conductor 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -144,6 +144,16 @@ export function providerCreditRefusal(error: {
144
144
  return error.message.split("\n")[0]?.trim() ?? error.message;
145
145
  }
146
146
 
147
+ /** Provider text that names a per-request stream fault. Deliberately narrow:
148
+ * a 429 is rate limiting and a 402 is credit — different remedies (#220). */
149
+ const TRANSIENT_FAULT_SIGNATURES = ["stream stalled"] as const;
150
+
151
+ export function providerTransientFault(error: { status?: number; message: string }): string | undefined {
152
+ const text = error.message.toLowerCase();
153
+ if (!TRANSIENT_FAULT_SIGNATURES.some((s) => text.includes(s))) return undefined;
154
+ return error.message.split("\n")[0]?.trim() ?? error.message;
155
+ }
156
+
147
157
  /**
148
158
  * Evidence that this run never started, or `undefined` when something did happen.
149
159
  *
@@ -248,6 +258,21 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
248
258
  }
249
259
  }
250
260
 
261
+ // A per-request stream fault: the provider aborted mid-stream — the session
262
+ // records "OpenAI responses stream stalled while waiting for the next event",
263
+ // but the attempt never produced a verdict and 0 tokens were billed. Not the
264
+ // work's fault, so it must not charge an implementation attempt; requeued, but
265
+ // bounded by PROVIDER_TRANSIENT_MAX_STRIKES so a provider that keeps aborting
266
+ // escalates to a human instead of looping (#220). Ahead of `neverStarted` for
267
+ // the same reason as the credit branch: a turn-0 stall would otherwise be
268
+ // absorbed by `env-start-failure` and lose its cause.
269
+ if (run.state === "failed" || run.state === "killed") {
270
+ const transient = run.lastError === undefined ? undefined : providerTransientFault({ message: run.lastError });
271
+ if (transient !== undefined) {
272
+ return { cls: "provider-transient", recovery: "requeue", evidence: transient };
273
+ }
274
+ }
275
+
251
276
  // A run that never started is the most classifiable failure there is, and the
252
277
  // least deserving of an implementation attempt: the session did not get as far
253
278
  // as reading the issue. See {@link neverStarted} for the two shapes and why the
package/src/fleet.ts CHANGED
@@ -1381,7 +1381,7 @@ export async function probeTelegramHealth(
1381
1381
  detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
1382
1382
  };
1383
1383
  }
1384
- return { kind: "ok", detail: `${username}; inbound configured; telegram_ask available` };
1384
+ 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
1385
  }
1386
1386
 
1387
1387
  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
@@ -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
  }
@@ -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.
@@ -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,6 +672,14 @@ 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
684
  opts.onReleaseBlocked?.(message.shape);
662
685
  break;
@@ -693,6 +716,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
693
716
  // would make this seam depend on a shape it deliberately does not name.
694
717
  return promise.then(() => undefined);
695
718
  },
719
+ park() {
720
+ promptSeq += 1;
721
+ const id = promptSeq;
722
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
723
+ pending.set(id, { resolve, reject });
724
+ write({ t: "park", id });
725
+ return promise.then(() => undefined);
726
+ },
696
727
  on(event, cb) {
697
728
  const list = handlers.get(event);
698
729
  if (list) list.push(cb);
@@ -68,17 +68,20 @@ import {
68
68
  type ReleaseDecision,
69
69
  } from "./release-policy.ts";
70
70
  import {
71
+ DEFAULT_REPORT_POLICY,
71
72
  DEFAULT_REPORT_SCOPE,
72
73
  DENIED_RELEASE_GRANTS,
73
74
  type DispatchSummary,
74
75
  type FrictionSignal,
75
76
  type ReportScope,
77
+ type ReportingPolicy,
76
78
  type ResolvedGrants,
77
79
  type Store,
78
80
  } from "./types.ts";
79
81
  import { formatDecisionDigest } from "./decisions.ts";
80
82
  import type { RunRecord } from "./types.ts";
81
83
  import { dbPath, openStore } from "./store.ts";
84
+ import { digestDue } from "./digest-schedule.ts";
82
85
 
83
86
  /** The activation file. Absent means "this is not an orchestrator session". */
84
87
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -386,7 +389,17 @@ export function queueDigestLine(
386
389
  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
390
  }
388
391
  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.`;
392
+ // `ready` counts claimed (in-flight) issues too; route() drops those with a
393
+ // state label silently, so "0 routable" must not blanket-blame missing
394
+ // `repo:` labels (#228). Spare depth is what dispatch can actually claim.
395
+ const claimed = summary.claimed ?? 0;
396
+ const unroutable = summary.holds
397
+ .filter((h) => h.reason.startsWith("unroutable:"))
398
+ .reduce((n, h) => n + h.count, 0);
399
+ if (unroutable === 0) {
400
+ 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.`;
401
+ }
402
+ return `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label).`;
390
403
  }
391
404
  if (summary.routed >= groomBelow) return undefined;
392
405
  let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
@@ -404,6 +417,37 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
404
417
  "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
418
  };
406
419
 
420
+ /**
421
+ * The reporting constraint appended to a default tick prompt (#229).
422
+ *
423
+ * A legacy `scopePreset` keeps its exact words — those are what the policy
424
+ * tests and the fleet already read — with a DUE/not-due clause appended only
425
+ * when the digest is actually scheduled (`daily` + `at`). An explicit
426
+ * non-preset policy derives one sentence naming its allowed interrupt
427
+ * categories and stating that everything else accumulates for the digest.
428
+ */
429
+ export function tickReportingConstraint(
430
+ policy: ReportingPolicy | undefined,
431
+ digest: { due: boolean; scheduled: boolean; at?: string; timezone?: string },
432
+ held: number,
433
+ ): string {
434
+ const preset = policy?.scopePreset;
435
+ let base: string;
436
+ if (preset !== undefined) {
437
+ base = TICK_SCOPE_CONSTRAINTS[preset];
438
+ } else {
439
+ const allowed = policy?.interruptOn ?? [];
440
+ base =
441
+ allowed.length === 0
442
+ ? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
443
+ : `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
444
+ }
445
+ if (!digest.scheduled) return base;
446
+ return digest.due
447
+ ? `${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.`
448
+ : `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
449
+ }
450
+
407
451
  /**
408
452
  * The delivery clause, appended to every default tick prompt.
409
453
  *
@@ -572,6 +616,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
572
616
  */
573
617
  export function resolveTickScope(): {
574
618
  scope: ReportScope;
619
+ policy?: ReportingPolicy;
575
620
  briefPath?: string;
576
621
  policyPath?: string;
577
622
  projectName?: string;
@@ -580,7 +625,8 @@ export function resolveTickScope(): {
580
625
  try {
581
626
  const project = findProject(loadConfig());
582
627
  return {
583
- scope: project.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
628
+ scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
629
+ policy: project.reporting,
584
630
  briefPath: briefPathForProject(project),
585
631
  policyPath: policyPathForProject(project),
586
632
  projectName: project.name,
@@ -1327,7 +1373,31 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1327
1373
  pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
1328
1374
  }
1329
1375
  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}`;
1376
+ let reportingConstraint = TICK_SCOPE_CONSTRAINTS[scope.scope];
1377
+ if (scope.projectName !== undefined) {
1378
+ const policy = scope.policy;
1379
+ const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
1380
+ const scheduled = digestPolicy.cadence === "daily" && digestPolicy.at !== undefined;
1381
+ const at = Date.now();
1382
+ const store = openStore(dbPath());
1383
+ try {
1384
+ const lastKey = store.lastDigestDedupeKey(scope.projectName);
1385
+ const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1386
+ reportingConstraint = tickReportingConstraint(
1387
+ policy,
1388
+ {
1389
+ due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
1390
+ scheduled,
1391
+ at: digestPolicy.at,
1392
+ timezone: digestPolicy.timezone,
1393
+ },
1394
+ store.undigestedNotices(scope.projectName).length,
1395
+ );
1396
+ } finally {
1397
+ store.close();
1398
+ }
1399
+ }
1400
+ content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
1331
1401
  }
1332
1402
  let frictionStore: Store | undefined;
1333
1403
  let frictionSignals: FrictionSignal[] = [];
package/src/plugin.ts CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  type ProjectPolicy,
92
92
  type ReleaseRequirement,
93
93
  type ReportScope,
94
+ type ReportScopeChoice,
94
95
  type ResolvedGrants,
95
96
  } from "./types.ts";
96
97
 
@@ -309,7 +310,7 @@ async function askGates(
309
310
  * means silence. The cursor starts on the current setting so Enter re-affirms
310
311
  * it, the same contract every other prompt here has.
311
312
  */
312
- async function askReportScope(ctx: CommandContext, current: ReportScope): Promise<ReportScope> {
313
+ async function askReportScope(ctx: CommandContext, current: ReportScopeChoice): Promise<ReportScopeChoice> {
313
314
  const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
314
315
  const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
315
316
  const picked = await ctx.ui.select("What should the orchestrator report unprompted?", options, {
@@ -898,7 +899,22 @@ const askEscalation: AreaAsker = async (ctx, a) => {
898
899
  };
899
900
 
900
901
  /** How loud the orchestrator is when nobody asked it anything. */
901
- const askReporting: AreaAsker = async (ctx, a) => ({ ...a, reportScope: await askReportScope(ctx, a.reportScope) });
902
+ const askReporting: AreaAsker = async (ctx, a) => {
903
+ 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,
910
+ );
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 };
915
+ }
916
+ return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
917
+ };
902
918
 
903
919
  /** The operator's own brief. Asked last in the full interview, because the
904
920
  * question quotes the path the rest of the answers derive. */
package/src/reports.ts CHANGED
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
+ import { localDayKey } from "./digest-schedule.ts";
40
41
  import type { Escalation, ProjectConfig, ReportRecord, Store } from "./types.ts";
41
42
 
42
43
  /**
@@ -120,13 +121,11 @@ export interface ReportOutboxDeps {
120
121
  /**
121
122
  * Local day, matching how a human reads "one daily digest" and how the
122
123
  * dispatcher's own `startOfToday` reads "today". A UTC key would roll the
123
- * digest over mid-evening for anyone west of Greenwich.
124
+ * digest over mid-evening for anyone west of Greenwich; the zone is the
125
+ * project's `reporting.digest.timezone` when set, else the host zone (#229).
124
126
  */
125
- export function digestDedupeKey(at: number): string {
126
- const d = new Date(at);
127
- const month = `${d.getMonth() + 1}`.padStart(2, "0");
128
- const day = `${d.getDate()}`.padStart(2, "0");
129
- return `digest:${d.getFullYear()}-${month}-${day}`;
127
+ export function digestDedupeKey(at: number, timezone?: string): string {
128
+ return `digest:${localDayKey(at, timezone)}`;
130
129
  }
131
130
 
132
131
  /** Exponential, capped. `attempts` is attempts *started*, so the first failure
@@ -9,14 +9,14 @@
9
9
  * fleet, and what makes a kill a real kill.
10
10
  *
11
11
  * This file is the far side: it loads the harness, runs the real session, and
12
- * speaks a five-verb protocol back over a unix socket to the
12
+ * speaks a small protocol back over a unix socket to the
13
13
  * {@link AgentSessionLike} proxy in `omp.ts`. It holds no conductor state, opens
14
14
  * no database, and reads no config — everything it needs arrives in
15
15
  * {@link SessionHostSpec}, so the child's inputs are data a caller can see
16
16
  * rather than ambient state it inherits.
17
17
  *
18
- * The protocol is deliberately tiny. `AgentSessionLike` has five members, so
19
- * there are five things to carry, and every one of them is data.
18
+ * The protocol deliberately mirrors the narrow session surface and carries
19
+ * only data.
20
20
  */
21
21
 
22
22
  import { connect } from "node:net";
@@ -53,6 +53,7 @@ export interface SessionHostSpec {
53
53
  /** Parent → child. */
54
54
  export type ParentToHost =
55
55
  | { t: "prompt"; id: number; text: string; opts?: Record<string, unknown> }
56
+ | { t: "park"; id: number }
56
57
  | { t: "abort" }
57
58
  | { t: "dispose" };
58
59
 
@@ -63,6 +64,7 @@ export type HostToParent =
63
64
  | { t: "event"; event: unknown }
64
65
  | { t: "session-file"; path: string }
65
66
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
67
+ | { t: "park-result"; id: number; ok: boolean; error?: string }
66
68
  | { t: "release-blocked"; shape: ReleaseShape };
67
69
 
68
70
  /**
@@ -240,6 +242,20 @@ export async function runSessionHost(
240
242
  );
241
243
  continue;
242
244
  }
245
+ if (message.t === "park") {
246
+ const id = message.id;
247
+ void live.park().then(
248
+ () => send({ t: "park-result", id, ok: true }),
249
+ (err: unknown) =>
250
+ send({
251
+ t: "park-result",
252
+ id,
253
+ ok: false,
254
+ error: err instanceof Error ? err.message : String(err),
255
+ }),
256
+ );
257
+ continue;
258
+ }
243
259
  if (message.t === "abort") {
244
260
  live.abort();
245
261
  continue;
package/src/setup.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  resolveCaps,
41
41
  resolvePolicy,
42
42
  resolveReleaseGrants,
43
+ SCOPE_PRESETS,
43
44
  stateDir,
44
45
  } from "./config.ts";
45
46
  import { graphProjectPath, graphRepos } from "./graph.ts";
@@ -61,6 +62,8 @@ import {
61
62
  type ProjectPolicy,
62
63
  type ReleaseRequirement,
63
64
  type ReportScope,
65
+ type ReportScopeChoice,
66
+ type ReportingPolicy,
64
67
  type RepoTarget,
65
68
  type ResolvedGrants,
66
69
  } from "./types.ts";
@@ -88,7 +91,9 @@ export interface SetupAnswers {
88
91
  telegramChatId?: string;
89
92
  fallbackToIssueComment: boolean;
90
93
  /** How loud the supervising orchestrator session should be. */
91
- reportScope: ReportScope;
94
+ reportScope: ReportScopeChoice;
95
+ /** The daily digest wall-clock (`HH:MM`) for the `quiet` choice, blank = model-timed. */
96
+ quietDigestAt?: string;
92
97
  /**
93
98
  * Mechanical gate for release/deploy-shaped tool calls, per shape. Always
94
99
  * complete: the wizard asks about every shape, so an answer object can never
@@ -175,7 +180,11 @@ export const SETUP_DEFAULTS = {
175
180
  * brief spells the same three options out — so "material" cannot come to mean
176
181
  * one thing in the dialog and another in the session that has to honour it.
177
182
  */
178
- export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string; description: string }[] = [
183
+ export const REPORT_SCOPE_CHOICES: readonly {
184
+ scope: ReportScopeChoice;
185
+ label: string;
186
+ description: string;
187
+ }[] = [
179
188
  {
180
189
  scope: "decisions",
181
190
  label: "Decisions interrupt, rest batches",
@@ -192,6 +201,12 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
192
201
  label: "Escalations only",
193
202
  description: "escalations when they happen, plus one daily digest — silent otherwise",
194
203
  },
204
+ {
205
+ scope: "quiet",
206
+ label: "Quiet (scheduled digest)",
207
+ description:
208
+ "interrupt only for escalations, fleet stops and confirmed failures; one daily rollup on your schedule",
209
+ },
195
210
  ];
196
211
 
197
212
  /**
@@ -204,7 +219,7 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
204
219
  * answers "an operator being asked the question for the first time", where the
205
220
  * recommended answer is the useful one — and they see it, and confirm it.
206
221
  */
207
- export const SETUP_DEFAULT_REPORT_SCOPE: ReportScope = REPORT_SCOPE_CHOICES[0]!.scope;
222
+ export const SETUP_DEFAULT_REPORT_SCOPE: ReportScopeChoice = REPORT_SCOPE_CHOICES[0]!.scope;
208
223
 
209
224
  /**
210
225
  * What each precondition value means to the operator being asked about it, in
@@ -560,8 +575,24 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
560
575
  // and a release require without anyone having to know a default (#129).
561
576
  policy: clonePolicy(a.policy),
562
577
  // Written out even when it is the default, so an operator amending the
563
- // volume has a line in the file to point at.
564
- reporting: { scope: a.reportScope },
578
+ // volume has a line in the file to point at. A `scope` answer is
579
+ // materialised as its preset policy (with `scopePreset` set) so the file
580
+ // and the prompt agree with no migration rule; `quiet` is the explicit
581
+ // form — a curated interrupt list and a scheduled digest (#229).
582
+ reporting:
583
+ a.reportScope === "quiet"
584
+ ? {
585
+ interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
586
+ digest: {
587
+ cadence: "daily",
588
+ ...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
589
+ },
590
+ }
591
+ : {
592
+ interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
593
+ digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
594
+ scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
595
+ },
565
596
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
566
597
  // uninstall, and neither can land in a repo the daemon then tries to commit.
567
598
  workspaceRoot: defaultWorkspaceRoot(),
@@ -667,7 +698,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
667
698
  releaseGrants: resolveReleaseGrants(p),
668
699
  policy: resolvePolicy(p),
669
700
  orchestratorMode: p.escalation.orchestrator,
670
- reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
701
+ reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
671
702
  writeOrchestratorBrief: false,
672
703
  };
673
704
 
@@ -704,7 +735,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
704
735
  QUEUE_LABEL: p.queueLabel,
705
736
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
706
737
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
707
- REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
738
+ REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
708
739
  POLICY_SOURCE: policySourceLine(p),
709
740
  };
710
741
  }
@@ -1234,7 +1265,7 @@ export const AMEND_AREAS: {
1234
1265
  name: "reporting scope",
1235
1266
  asks: "how much the orchestrator says unprompted",
1236
1267
  describe: (p) => {
1237
- const scope = p.reporting?.scope ?? DEFAULT_REPORT_SCOPE;
1268
+ const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
1238
1269
  const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
1239
1270
  return `${scope} — ${choice?.description ?? "unknown scope"}`;
1240
1271
  },
package/src/store.ts CHANGED
@@ -26,6 +26,8 @@ import type {
26
26
  FrictionKind,
27
27
  FrictionObservation,
28
28
  FrictionSignal,
29
+ HeldNotice,
30
+ HeldNoticeDraft,
29
31
  LabelOp,
30
32
  MergeLock,
31
33
  ReportDeliveryState,
@@ -332,6 +334,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
332
334
  CREATE INDEX IF NOT EXISTS reports_project_state
333
335
  ON reports (project, state, nextAttemptAt);
334
336
 
337
+ -- Interrupts a project's reporting.interruptOn policy deferred to the digest
338
+ -- (#229): an escalation that must not page the operator's phone now is held
339
+ -- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
340
+ -- digest pass and no longer owed. A table rather than a flag on reports: these
341
+ -- are escalations that never reached a send, distinct from reports the model
342
+ -- wrote on purpose.
343
+ CREATE TABLE IF NOT EXISTS held_notices (
344
+ id TEXT PRIMARY KEY,
345
+ project TEXT NOT NULL,
346
+ category TEXT NOT NULL,
347
+ summary TEXT NOT NULL,
348
+ detail TEXT NOT NULL,
349
+ createdAt INTEGER NOT NULL,
350
+ digestedAt INTEGER
351
+ );
352
+ CREATE INDEX IF NOT EXISTS held_notices_project_undigested
353
+ ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
354
+
335
355
  -- The action ledger for the conductor-owned mutation verbs (#126). Every
336
356
  -- decided call lands here, refusals included: the question an escalation asks
337
357
  -- is "what did this run try", and a table that only records what succeeded
@@ -902,12 +922,12 @@ export function openStore(dbPath: string): Store {
902
922
  const countFailures = db.query<{ n: number }, [string, number]>(
903
923
  `SELECT COUNT(*) AS n FROM runs
904
924
  WHERE project = ? AND issue = ? AND state = 'failed'
905
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit'))`,
925
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
906
926
  );
907
927
  const countContinuations = db.query<{ n: number }, [string, number]>(
908
928
  `SELECT COUNT(*) AS n FROM runs
909
929
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
910
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit'))`,
930
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
911
931
  );
912
932
  // How many times one issue reached a given class. Recovery uses it to bound a
913
933
  // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
@@ -1079,6 +1099,33 @@ export function openStore(dbPath: string): Store {
1079
1099
  ORDER BY createdAt ASC, rowid ASC
1080
1100
  LIMIT ?`,
1081
1101
  );
1102
+
1103
+ // Held notices (#229): escalations deferred to the digest by the project's
1104
+ // `reporting.interruptOn` policy. `digestedAt` NULL = still owed.
1105
+ const insertHeldNotice = db.query<unknown, SqlValue[]>(
1106
+ `INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
1107
+ VALUES (?, ?, ?, ?, ?, ?, NULL)`,
1108
+ );
1109
+ const selectUndigestedNotices = db.query<
1110
+ { id: string; category: string; summary: string; detail: string; createdAt: number },
1111
+ [string]
1112
+ >(
1113
+ `SELECT id, category, summary, detail, createdAt FROM held_notices
1114
+ WHERE project = ? AND digestedAt IS NULL
1115
+ ORDER BY createdAt ASC`,
1116
+ );
1117
+ const markNoticesDigestedRow = db.query<unknown, [number, string]>(
1118
+ `UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
1119
+ );
1120
+ // The newest digest dedupe key a project has actually run toward — an
1121
+ // `updatedAt`-newest row whose key is a digest prefix and that did not end in
1122
+ // failure. The CLI refuses an off-schedule digest by comparing this against
1123
+ // today's key under `digestDue` (#229).
1124
+ const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
1125
+ `SELECT dedupeKey AS key FROM reports
1126
+ WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
1127
+ ORDER BY createdAt DESC LIMIT 1`,
1128
+ );
1082
1129
  const claimReportRow = db.query<unknown, [string, number, string, number]>(
1083
1130
  `UPDATE reports
1084
1131
  SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
@@ -1445,6 +1492,30 @@ export function openStore(dbPath: string): Store {
1445
1492
  return row === null ? undefined : toDispatchSummary(row.summary);
1446
1493
  },
1447
1494
 
1495
+ lastDigestDedupeKey(project: string): string | undefined {
1496
+ const row = selectLastDigestKey.get(project);
1497
+ return row?.key ?? undefined;
1498
+ },
1499
+
1500
+ addHeldNotice(notice: HeldNoticeDraft): void {
1501
+ insertHeldNotice.run(
1502
+ crypto.randomUUID(),
1503
+ notice.project,
1504
+ notice.category,
1505
+ notice.summary,
1506
+ notice.detail,
1507
+ notice.createdAt,
1508
+ );
1509
+ },
1510
+
1511
+ undigestedNotices(project: string): HeldNotice[] {
1512
+ return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
1513
+ },
1514
+
1515
+ markNoticesDigested(project: string, at: number): void {
1516
+ markNoticesDigestedRow.run(at, project);
1517
+ },
1518
+
1448
1519
  recordFriction,
1449
1520
 
1450
1521
  recordGhRefusal,