omp-conductor 0.9.1 → 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.
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
  }
@@ -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
+ // A category this policy defers does not page now: it is held here so
217
+ // the digest is the delivery authority for it (#229). A missing
218
+ // `reporting` block means the default (page everything); an explicit
219
+ // list decides each escalation by its category, defaulting `tier2`.
220
+ const category = e.category ?? "tier2";
221
+ const interruptOn = p.reporting?.interruptOn;
222
+ if (interruptOn !== undefined && !interruptOn.includes(category)) {
223
+ store.addHeldNotice({
224
+ project: p.name,
225
+ category,
226
+ summary: e.summary,
227
+ detail: text,
228
+ createdAt: Date.now(),
229
+ });
230
+ store.markNotified(key);
231
+ return;
232
+ }
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
@@ -113,6 +113,47 @@ export function startFailure(lastError: string | undefined): string | undefined
113
113
  return hit === undefined ? undefined : lastError.split("\n")[0]?.trim();
114
114
  }
115
115
 
116
+ /**
117
+ * Provider messages that mean "this key is out of money", matched on the text
118
+ * the provider itself prints.
119
+ */
120
+ const CREDIT_REFUSAL_SIGNATURES = [
121
+ "requires more credits",
122
+ "insufficient credits",
123
+ "insufficient_quota",
124
+ "exceeded your current quota",
125
+ "credit balance is too low",
126
+ ] as const;
127
+
128
+ /**
129
+ * Evidence that a model provider refused this run for credit, or `undefined`
130
+ * when it did not.
131
+ *
132
+ * Two independent signals, either sufficient: HTTP 402, which means exactly
133
+ * this and nothing else, and the refusal text providers print when a key is out
134
+ * of allowance without using 402. Deliberately narrow — a generic 429 is rate
135
+ * limiting, which is a different failure with a different remedy.
136
+ */
137
+ export function providerCreditRefusal(error: {
138
+ status?: number;
139
+ message: string;
140
+ }): string | undefined {
141
+ const text = error.message.toLowerCase();
142
+ const named = CREDIT_REFUSAL_SIGNATURES.some((signature) => text.includes(signature));
143
+ if (error.status !== 402 && !named) return undefined;
144
+ return error.message.split("\n")[0]?.trim() ?? error.message;
145
+ }
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
+
116
157
  /**
117
158
  * Evidence that this run never started, or `undefined` when something did happen.
118
159
  *
@@ -201,6 +242,37 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
201
242
  };
202
243
  }
203
244
 
245
+ // A billing state, not an implementation failure. Its own class because it is
246
+ // the one outage an operator fixes with a card rather than a diagnosis, and
247
+ // because burying a self-describing provider error in `unknown` erodes what
248
+ // `unknown` means (#220). Ahead of `neverStarted` on purpose: a 402 on the
249
+ // first request produces a turn-0 row with no artifacts, which
250
+ // `env-start-failure` would otherwise absorb and lose the cause.
251
+ if (run.state === "failed" || run.state === "killed") {
252
+ const credit =
253
+ run.lastError === undefined
254
+ ? undefined
255
+ : providerCreditRefusal({ message: run.lastError });
256
+ if (credit !== undefined) {
257
+ return { cls: "provider-credit", recovery: "requeue", evidence: credit };
258
+ }
259
+ }
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
+
204
276
  // A run that never started is the most classifiable failure there is, and the
205
277
  // least deserving of an implementation attempt: the session did not get as far
206
278
  // as reading the issue. See {@link neverStarted} for the two shapes and why the
package/src/fleet.ts CHANGED
@@ -1062,6 +1062,7 @@ export function formatFleetStatus(
1062
1062
  function formatProjectBody(s: StatusSnapshot): string {
1063
1063
  const lines = [
1064
1064
  `project ${s.project}${s.paused ? " (PAUSED)" : ""}`,
1065
+ ...(s.pauseReason === undefined ? [] : [`paused ${s.pauseReason}`]),
1065
1066
  `config ${s.configPath}`,
1066
1067
  `state ${s.stateDir}`,
1067
1068
  "",
@@ -1380,7 +1381,7 @@ export async function probeTelegramHealth(
1380
1381
  detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
1381
1382
  };
1382
1383
  }
1383
- 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)` };
1384
1385
  }
1385
1386
 
1386
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;