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/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, {
@@ -793,7 +794,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
793
794
  const tuneCaps = await ctx.ui.confirm(
794
795
  "Caps",
795
796
  `Defaults: ${workersDefault} workers, ` +
796
- `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
797
+ `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
798
+ `${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` +
797
799
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
798
800
  `${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
799
801
  `${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
@@ -820,7 +822,32 @@ const askCaps: AreaAsker = async (ctx, a) => {
820
822
  "Spend ceiling per rolling day (USD) — blank = no spend cap",
821
823
  caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
822
824
  );
823
- caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
825
+ const workerMaxTurns = await askNumber(
826
+ ctx,
827
+ "Turn ceiling per worker",
828
+ caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
829
+ );
830
+ caps.workerMaxTurns = workerMaxTurns;
831
+ const turnCeilingFallback = Math.max(
832
+ workerMaxTurns,
833
+ caps.workerMaxTurnsCeiling ??
834
+ Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
835
+ );
836
+ const workerMaxTurnsCeiling = await askNumber(
837
+ ctx,
838
+ "Maximum turn ceiling for one issue",
839
+ turnCeilingFallback,
840
+ );
841
+ if (workerMaxTurnsCeiling < workerMaxTurns) {
842
+ ctx.ui.notify(
843
+ `Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
844
+ `${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
845
+ "warning",
846
+ );
847
+ caps.workerMaxTurnsCeiling = turnCeilingFallback;
848
+ } else {
849
+ caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
850
+ }
824
851
  caps.workerWallClockMs = await askNumber(
825
852
  ctx,
826
853
  "Wall-clock ceiling per worker (ms)",
@@ -898,7 +925,22 @@ const askEscalation: AreaAsker = async (ctx, a) => {
898
925
  };
899
926
 
900
927
  /** How loud the orchestrator is when nobody asked it anything. */
901
- const askReporting: AreaAsker = async (ctx, a) => ({ ...a, reportScope: await askReportScope(ctx, a.reportScope) });
928
+ const askReporting: AreaAsker = async (ctx, a) => {
929
+ const reportScope = await askReportScope(ctx, a.reportScope);
930
+ if (reportScope !== "quiet") return { ...a, reportScope };
931
+ // `quiet` picks the explicit form, whose only free parameter is when the
932
+ // daily rollup happens. Blank = whenever the orchestrator composes it.
933
+ const at = await ctx.ui.input(
934
+ "Daily rollup time, 24h HH:MM (blank = whenever the orchestrator composes it):",
935
+ a.quietDigestAt,
936
+ );
937
+ const trimmed = at?.trim() ?? "";
938
+ if (trimmed !== "" && !/^([01]\d|2[0-3]):[0-5]\d$/.test(trimmed)) {
939
+ ctx.ui.notify(`"${trimmed}" is not a 24h HH:MM time — leaving the digest model-timed.`, "warning");
940
+ return { ...a, reportScope };
941
+ }
942
+ return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
943
+ };
902
944
 
903
945
  /** The operator's own brief. Asked last in the full interview, because the
904
946
  * question quotes the path the rest of the answers derive. */
@@ -36,11 +36,19 @@ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
36
36
 
37
37
  export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
38
38
 
39
- export interface ReleaseBlock {
39
+ export interface ReleaseBlockContext {
40
+ tool: string;
41
+ reason: string;
42
+ args: Record<string, string>;
43
+ }
44
+
45
+ export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
40
46
  project: string;
41
47
  source: "worker" | "orchestrator";
42
48
  shape: ReleaseShape;
43
49
  at: string;
50
+ issue?: number;
51
+ runId?: string;
44
52
  }
45
53
 
46
54
  export type ReleaseDecision = { block: true; reason: string };
@@ -271,6 +279,32 @@ export function releaseDecision(
271
279
  return decision === undefined ? undefined : { shape, decision };
272
280
  }
273
281
 
282
+ const RELEASE_ARG_ALLOWLIST = new Set([
283
+ "repo",
284
+ "stack",
285
+ "artefact",
286
+ "artifact",
287
+ "environment",
288
+ "tag",
289
+ "version",
290
+ "image",
291
+ "prUrl",
292
+ "branch",
293
+ "service",
294
+ ]);
295
+
296
+ /** Keep only explicitly safe release-target strings; redact every other value. */
297
+ export function redactReleaseArgs(input: Record<string, unknown>): Record<string, string> {
298
+ const redacted: Record<string, string> = {};
299
+ for (const [key, value] of Object.entries(input)) {
300
+ redacted[key] =
301
+ RELEASE_ARG_ALLOWLIST.has(key) && typeof value === "string"
302
+ ? value.slice(0, 120)
303
+ : "(redacted)";
304
+ }
305
+ return redacted;
306
+ }
307
+
274
308
  interface ReleasePolicyPi {
275
309
  on(
276
310
  event: "tool_call",
@@ -285,14 +319,18 @@ interface ReleasePolicyPi {
285
319
  export function releasePolicyTripwire(
286
320
  grants: ResolvedGrants,
287
321
  role: SessionRole,
288
- onBlocked: (shape: ReleaseShape) => void = () => {},
322
+ onBlocked: (shape: ReleaseShape, context: ReleaseBlockContext) => void = () => {},
289
323
  ): (pi: ReleasePolicyPi) => void {
290
324
  return (pi) => {
291
325
  pi.on("tool_call", (event) => {
292
326
  const blocked = releaseDecision(grants, role, event.toolName, event.input);
293
327
  if (blocked === undefined) return undefined;
294
328
  try {
295
- onBlocked(blocked.shape);
329
+ onBlocked(blocked.shape, {
330
+ tool: event.toolName,
331
+ reason: blocked.decision.reason,
332
+ args: redactReleaseArgs(event.input),
333
+ });
296
334
  } catch {
297
335
  // Audit is evidence, not the gate. A full disk must not turn a deny into allow.
298
336
  }
@@ -305,11 +343,22 @@ export function recordReleaseBlock(
305
343
  project: string,
306
344
  source: ReleaseBlock["source"],
307
345
  shape: ReleaseShape,
346
+ details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
308
347
  root = stateDir(),
309
348
  now = new Date(),
310
349
  ): void {
311
350
  mkdirSync(root, { recursive: true });
312
- const record: ReleaseBlock = { project, source, shape, at: now.toISOString() };
351
+ // Redact again at the durable boundary. Callers normally pass the tripwire's
352
+ // already-safe context, but no alternate recorder path gets to rely on that.
353
+ const { args, ...attribution } = details;
354
+ const record: ReleaseBlock = {
355
+ project,
356
+ source,
357
+ shape,
358
+ ...attribution,
359
+ ...(args === undefined ? {} : { args: redactReleaseArgs(args) }),
360
+ at: now.toISOString(),
361
+ };
313
362
  appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
314
363
  }
315
364
 
@@ -354,12 +403,23 @@ export function releaseDriftToday(
354
403
  return latest === undefined ? undefined : { count, latest };
355
404
  }
356
405
 
357
- export function releaseDriftDigestLine(project: string, root = stateDir(), now = new Date()): string | undefined {
406
+ export function releaseDriftDigestLine(
407
+ project: string,
408
+ root = stateDir(),
409
+ now = new Date(),
410
+ ): string | undefined {
358
411
  const drift = releaseDriftToday(project, root, now);
359
412
  if (drift === undefined) return undefined;
413
+ const latest = drift.latest;
414
+ const attribution = [
415
+ `${latest.source} ${latest.shape} at ${latest.at}`,
416
+ ...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
417
+ ...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
418
+ ...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
419
+ ].join(", ");
360
420
  return (
361
421
  `Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
362
- `(latest: ${drift.latest.source} ${drift.latest.shape} at ${drift.latest.at}). ` +
422
+ `(latest: ${attribution}). ` +
363
423
  "Include this divergence from releasePolicy=none in today's digest."
364
424
  );
365
425
  }
package/src/reports.ts CHANGED
@@ -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,19 +9,20 @@
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";
23
23
 
24
24
  import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
25
+ import type { ReleaseBlockContext } from "./release-policy.ts";
25
26
 
26
27
  import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
27
28
 
@@ -53,6 +54,7 @@ export interface SessionHostSpec {
53
54
  /** Parent → child. */
54
55
  export type ParentToHost =
55
56
  | { t: "prompt"; id: number; text: string; opts?: Record<string, unknown> }
57
+ | { t: "park"; id: number }
56
58
  | { t: "abort" }
57
59
  | { t: "dispose" };
58
60
 
@@ -63,7 +65,8 @@ export type HostToParent =
63
65
  | { t: "event"; event: unknown }
64
66
  | { t: "session-file"; path: string }
65
67
  | { t: "prompt-result"; id: number; ok: boolean; error?: string }
66
- | { t: "release-blocked"; shape: ReleaseShape };
68
+ | { t: "park-result"; id: number; ok: boolean; error?: string }
69
+ | ({ t: "release-blocked"; shape: ReleaseShape } & ReleaseBlockContext);
67
70
 
68
71
  /**
69
72
  * Depth at which a harness event stops being copied for the wire.
@@ -184,8 +187,8 @@ export async function runSessionHost(
184
187
  // The release audit lives in the daemon's state directory, which this
185
188
  // process may not be able to write and must not be trusted to. It
186
189
  // becomes a message; the parent performs the durable write.
187
- onReleaseBlocked: (shape) => {
188
- send({ t: "release-blocked", shape });
190
+ onReleaseBlocked: (shape, context) => {
191
+ send({ t: "release-blocked", shape, ...context });
189
192
  },
190
193
  });
191
194
  } catch (err) {
@@ -240,6 +243,20 @@ export async function runSessionHost(
240
243
  );
241
244
  continue;
242
245
  }
246
+ if (message.t === "park") {
247
+ const id = message.id;
248
+ void live.park().then(
249
+ () => send({ t: "park-result", id, ok: true }),
250
+ (err: unknown) =>
251
+ send({
252
+ t: "park-result",
253
+ id,
254
+ ok: false,
255
+ error: err instanceof Error ? err.message : String(err),
256
+ }),
257
+ );
258
+ continue;
259
+ }
243
260
  if (message.t === "abort") {
244
261
  live.abort();
245
262
  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
@@ -236,6 +251,7 @@ export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]:
236
251
  "runs-settled": "every run this release covers actually merged, not merely reached a green PR",
237
252
  "no-open-prs": "no pull request is still open against the branch being released",
238
253
  "queue-drained": "nothing still carries the queue label",
254
+ "base-branch-green": "the newest observed post-merge base-branch workflows are green",
239
255
  "epic-children-closed": "the epic this release closes has no open children",
240
256
  };
241
257
 
@@ -560,8 +576,24 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
560
576
  // and a release require without anyone having to know a default (#129).
561
577
  policy: clonePolicy(a.policy),
562
578
  // 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 },
579
+ // volume has a line in the file to point at. A `scope` answer is
580
+ // materialised as its preset policy (with `scopePreset` set) so the file
581
+ // and the prompt agree with no migration rule; `quiet` is the explicit
582
+ // form — a curated interrupt list and a scheduled digest (#229).
583
+ reporting:
584
+ a.reportScope === "quiet"
585
+ ? {
586
+ interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
587
+ digest: {
588
+ cadence: "daily",
589
+ ...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
590
+ },
591
+ }
592
+ : {
593
+ interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
594
+ digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
595
+ scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
596
+ },
565
597
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
566
598
  // uninstall, and neither can land in a repo the daemon then tries to commit.
567
599
  workspaceRoot: defaultWorkspaceRoot(),
@@ -667,7 +699,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
667
699
  releaseGrants: resolveReleaseGrants(p),
668
700
  policy: resolvePolicy(p),
669
701
  orchestratorMode: p.escalation.orchestrator,
670
- reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
702
+ reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
671
703
  writeOrchestratorBrief: false,
672
704
  };
673
705
 
@@ -704,7 +736,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
704
736
  QUEUE_LABEL: p.queueLabel,
705
737
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
706
738
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
707
- REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
739
+ REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
708
740
  POLICY_SOURCE: policySourceLine(p),
709
741
  };
710
742
  }
@@ -1160,14 +1192,15 @@ export const AMEND_AREAS: {
1160
1192
  // The model rides with the caps because it is the other per-worker knob, and
1161
1193
  // an area no menu offers is a setting only a full re-interview can reach.
1162
1194
  name: "caps & worker model",
1163
- asks: "concurrency, spend, turns, wall clock, failed attempts, continuations — then the worker model",
1195
+ asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
1164
1196
  describe: (p) => {
1165
1197
  const c = resolveCaps(p, DEFAULT_CAPS);
1166
1198
  const answered = Object.keys(p.caps).length > 0;
1167
1199
  const spend =
1168
1200
  c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
1169
1201
  return (
1170
- `${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ${c.workerMaxTurns} turns, ` +
1202
+ `${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ` +
1203
+ `${c.workerMaxTurns} base/${c.workerMaxTurnsCeiling} max turns, ` +
1171
1204
  `${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
1172
1205
  `${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
1173
1206
  `${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +
@@ -1234,7 +1267,7 @@ export const AMEND_AREAS: {
1234
1267
  name: "reporting scope",
1235
1268
  asks: "how much the orchestrator says unprompted",
1236
1269
  describe: (p) => {
1237
- const scope = p.reporting?.scope ?? DEFAULT_REPORT_SCOPE;
1270
+ const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
1238
1271
  const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
1239
1272
  return `${scope} — ${choice?.description ?? "unknown scope"}`;
1240
1273
  },