omp-conductor 0.13.0 → 0.15.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.
Files changed (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The one place conductor runs a command as root.
3
+ *
4
+ * Every host-provisioning step used to be *printed* as a `sudo …` line for the
5
+ * operator to retype, which is not caution — it is an audit trail nobody keeps
6
+ * and a transcription error nobody catches. This module keeps the audit and
7
+ * removes the retyping: the caller composes structured steps, this renders all
8
+ * of them with their exact argv, takes **one** confirm, and then runs them.
9
+ *
10
+ * Three invariants, each of which a convenient shortcut would break:
11
+ *
12
+ * - **The CLI never escalates itself.** Only the individual steps below are
13
+ * privileged. `sudo omp-conductor setup host` would resolve the config,
14
+ * `$OMP_CONDUCTOR_HOME`, `homedir()` and the unit's own `User=` as root and
15
+ * bake the wrong account into a unit for a fleet that runs as somebody
16
+ * else — a timer that goes green while writing state no worker can read.
17
+ * {@link assertNoSelfExec} refuses to build such a step at all.
18
+ * - **One confirm, everything named.** A confirm that authorises commands it
19
+ * did not print is not consent, so the render happens first and lists the
20
+ * exact argv — including which steps deliberately run *without* `sudo`.
21
+ * - **A failure hands the work back.** The first non-`bestEffort` failure
22
+ * stops the batch and prints the failed title, its stderr, and every
23
+ * not-yet-run command verbatim, so an operator can finish by hand from the
24
+ * terminal they are already looking at.
25
+ */
26
+
27
+ import type { WizardUi } from "./wizard-ui.ts";
28
+
29
+ export interface PrivilegedStep {
30
+ /** One line naming what this step accomplishes, shown in the plan. */
31
+ title: string;
32
+ /** The exact argv. No shell: no quoting rules, no word splitting, no `&&`. */
33
+ argv: readonly string[];
34
+ /**
35
+ * A failure here is reported and the batch continues. For steps whose failure
36
+ * is not a broken install — reloading a systemd that was never running, say.
37
+ */
38
+ bestEffort?: boolean;
39
+ /**
40
+ * Runs as the invoking account with **no** `sudo` prefix, while still
41
+ * appearing in the same plan under the same confirm.
42
+ *
43
+ * `setup graph`'s index-only clones are the case this exists for: a
44
+ * root-owned clone under the fleet user's cache is precisely the failure the
45
+ * escalation guard exists to prevent, but the operator must still see the
46
+ * clone and the timer install as one plan they approve once.
47
+ */
48
+ unprivileged?: boolean;
49
+ }
50
+
51
+ /** One spawn the runner needs. Injectable so tests never touch a real `sudo`. */
52
+ export interface PrivilegedSpawn {
53
+ argv: readonly string[];
54
+ /**
55
+ * `sudo -v` needs the terminal for its password prompt, so its stdio is
56
+ * inherited whole and nothing is captured.
57
+ */
58
+ interactive: boolean;
59
+ }
60
+
61
+ export interface PrivilegedResult {
62
+ exitCode: number;
63
+ /** Captured stderr, or `""` for an interactive spawn that owned the terminal. */
64
+ stderr: string;
65
+ }
66
+
67
+ export interface PrivilegedDeps {
68
+ spawn(request: PrivilegedSpawn): Promise<PrivilegedResult>;
69
+ /** `undefined` on a platform without uids, which is never privileged here. */
70
+ getuid(): number | undefined;
71
+ }
72
+
73
+ export type PrivilegedOutcome =
74
+ /** The confirm was answered no, or dismissed. Nothing ran. */
75
+ | { kind: "declined" }
76
+ /** Every step ran; `bestEffort` failures are listed but did not stop the batch. */
77
+ | { kind: "completed"; softFailures: { step: PrivilegedStep; stderr: string }[] }
78
+ /** A required step failed. `remaining` are the steps that never ran. */
79
+ | {
80
+ kind: "failed";
81
+ step: PrivilegedStep;
82
+ exitCode: number;
83
+ stderr: string;
84
+ remaining: readonly PrivilegedStep[];
85
+ };
86
+
87
+ export interface RunPrivilegedOptions {
88
+ deps?: PrivilegedDeps;
89
+ /** Confirm title. Defaults to a generic one; callers name their verb. */
90
+ title?: string;
91
+ /** Extra lines shown above the step list — what this batch is for. */
92
+ preamble?: readonly string[];
93
+ }
94
+
95
+ /**
96
+ * Shell-quoted rendering of one step, exactly as an operator would type it.
97
+ *
98
+ * The steps are argv precisely so nothing here is load-bearing at execution
99
+ * time; this is for the plan, the failure remainder and the print-only modes.
100
+ */
101
+ export function formatStep(step: PrivilegedStep, asRoot = false): string {
102
+ const prefix = step.unprivileged || asRoot ? [] : ["sudo"];
103
+ return [...prefix, ...step.argv].map(shellQuote).join(" ");
104
+ }
105
+
106
+ function shellQuote(value: string): string {
107
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
108
+ }
109
+
110
+ /**
111
+ * Refuses to build a step that re-runs conductor itself.
112
+ *
113
+ * Called on every step before anything is rendered, because the failure it
114
+ * prevents is invisible: `sudo omp-conductor …` exits 0 and leaves a unit
115
+ * naming the wrong account. Cheap, and it turns a future refactor's mistake
116
+ * into a test failure rather than a broken fleet.
117
+ */
118
+ function assertNoSelfExec(steps: readonly PrivilegedStep[]): void {
119
+ for (const step of steps) {
120
+ const offender = step.argv.find((a) => {
121
+ const base = a.split("/").pop() ?? a;
122
+ return base === "omp-conductor" || base === "cli.ts";
123
+ });
124
+ if (offender !== undefined) {
125
+ throw new Error(
126
+ `privileged step "${step.title}" would re-exec conductor (${offender}) — ` +
127
+ "only individual structured steps are ever privileged",
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ async function spawnReal(request: PrivilegedSpawn): Promise<PrivilegedResult> {
134
+ const proc = Bun.spawn(request.argv as string[], {
135
+ // stdin is inherited even for captured steps: a `sudo` whose timestamp
136
+ // expired mid-batch must be able to prompt rather than fail on a closed fd.
137
+ stdin: "inherit",
138
+ // stdout is inherited so a long step (`systemctl start` on a oneshot that
139
+ // indexes for minutes) is visible while it runs instead of after it.
140
+ stdout: "inherit",
141
+ stderr: request.interactive ? "inherit" : "pipe",
142
+ });
143
+ const [exitCode, stderr] = await Promise.all([
144
+ proc.exited,
145
+ request.interactive ? Promise.resolve("") : new Response(proc.stderr).text(),
146
+ ]);
147
+ return { exitCode, stderr };
148
+ }
149
+
150
+ export const DEFAULT_PRIVILEGED_DEPS: PrivilegedDeps = {
151
+ spawn: spawnReal,
152
+ getuid: () => process.getuid?.(),
153
+ };
154
+
155
+ /**
156
+ * Renders `steps`, takes one confirm, then runs them in order.
157
+ *
158
+ * As root, every step runs directly: there is nothing to escalate, and a `sudo`
159
+ * prefix on a root-run fleet is an extra dependency for no gain. Otherwise one
160
+ * `sudo -v` primes the credential with the terminal it needs, and each
161
+ * privileged step then runs as `sudo -- <argv>` — `--` so a step whose first
162
+ * argument begins with a dash cannot be read as a `sudo` flag.
163
+ */
164
+ export async function runPrivileged(
165
+ steps: readonly PrivilegedStep[],
166
+ ui: WizardUi,
167
+ options: RunPrivilegedOptions = {},
168
+ ): Promise<PrivilegedOutcome> {
169
+ assertNoSelfExec(steps);
170
+ const deps = options.deps ?? DEFAULT_PRIVILEGED_DEPS;
171
+ const asRoot = deps.getuid() === 0;
172
+ const needsSudo = !asRoot && steps.some((s) => s.unprivileged !== true);
173
+
174
+ ui.notify(
175
+ [
176
+ ...(options.preamble ?? []),
177
+ ...(options.preamble === undefined || options.preamble.length === 0 ? [] : [""]),
178
+ ...steps.flatMap((step, index) => [
179
+ `${index + 1}. ${step.title}${step.unprivileged === true && !asRoot ? " (as you, not root)" : ""}`,
180
+ ` ${formatStep(step, asRoot)}`,
181
+ ]),
182
+ "",
183
+ asRoot
184
+ ? "Running as root — these run directly, with no sudo."
185
+ : needsSudo
186
+ ? "sudo will ask for your password once, before the first step."
187
+ : "None of these need root.",
188
+ "Nothing has been run yet.",
189
+ ].join("\n"),
190
+ "info",
191
+ );
192
+
193
+ const go = await ui.confirm(
194
+ options.title ?? "Run these steps now?",
195
+ `${steps.length} step(s), in the order shown. Anything that fails stops the rest and prints what is left.`,
196
+ );
197
+ // `undefined` is a dismissal rather than a "no", but for a batch that has not
198
+ // started they mean the same thing: run nothing.
199
+ if (go !== true) return { kind: "declined" };
200
+
201
+ if (needsSudo) {
202
+ const primed = await deps.spawn({ argv: ["sudo", "-v"], interactive: true });
203
+ if (primed.exitCode !== 0) {
204
+ return {
205
+ kind: "failed",
206
+ step: { title: "authenticate with sudo", argv: ["sudo", "-v"] },
207
+ exitCode: primed.exitCode,
208
+ stderr: primed.stderr,
209
+ remaining: steps,
210
+ };
211
+ }
212
+ }
213
+
214
+ const softFailures: { step: PrivilegedStep; stderr: string }[] = [];
215
+ for (const [index, step] of steps.entries()) {
216
+ ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info");
217
+ const argv =
218
+ step.unprivileged === true || asRoot ? step.argv : ["sudo", "--", ...step.argv];
219
+ const result = await deps.spawn({ argv, interactive: false });
220
+ if (result.exitCode === 0) continue;
221
+ if (step.bestEffort === true) {
222
+ softFailures.push({ step, stderr: result.stderr });
223
+ ui.notify(` continued past a best-effort failure: ${firstLine(result.stderr)}`, "warning");
224
+ continue;
225
+ }
226
+ const remaining = steps.slice(index + 1);
227
+ ui.notify(formatFailure(step, result, remaining, asRoot), "error");
228
+ return { kind: "failed", step, exitCode: result.exitCode, stderr: result.stderr, remaining };
229
+ }
230
+ return { kind: "completed", softFailures };
231
+ }
232
+
233
+ function firstLine(stderr: string): string {
234
+ const line = stderr.trim().split("\n")[0];
235
+ return line === undefined || line === "" ? "no stderr" : line;
236
+ }
237
+
238
+ /**
239
+ * What the operator is left holding when a step fails: the step, why, and the
240
+ * rest of the batch verbatim so finishing by hand is a paste rather than a
241
+ * reconstruction.
242
+ */
243
+ export function formatFailure(
244
+ step: PrivilegedStep,
245
+ result: PrivilegedResult,
246
+ remaining: readonly PrivilegedStep[],
247
+ asRoot = false,
248
+ ): string {
249
+ return [
250
+ `Stopped: ${step.title} exited ${result.exitCode}.`,
251
+ ` ${formatStep(step, asRoot)}`,
252
+ ...(result.stderr.trim() === ""
253
+ ? [" (no stderr — its output is above)"]
254
+ : result.stderr.trimEnd().split("\n").map((l) => ` ${l}`)),
255
+ ...(remaining.length === 0
256
+ ? ["", "Nothing was left to run."]
257
+ : [
258
+ "",
259
+ `${remaining.length} step(s) did NOT run. To finish by hand:`,
260
+ "",
261
+ ...remaining.map((s) => ` ${formatStep(s, asRoot)}`),
262
+ ]),
263
+ ].join("\n");
264
+ }
package/src/reports.ts CHANGED
@@ -36,9 +36,21 @@
36
36
  * conclusion leaves something for the next pass to pick up.
37
37
  */
38
38
 
39
+ import { availabilityDisposition, interruptDisposition } from "./availability.ts";
39
40
  import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
41
  import { localDayKey } from "./digest-schedule.ts";
41
- import type { Escalation, ProjectConfig, ReportRecord, Store } from "./types.ts";
42
+ import {
43
+ DIGEST_BACKLOG_LIMIT,
44
+ INTERRUPT_CATEGORIES,
45
+ type DigestBacklog,
46
+ type Escalation,
47
+ type HeldNotice,
48
+ type InterruptCategory,
49
+ type ProjectConfig,
50
+ type ReportEnqueue,
51
+ type ReportRecord,
52
+ type Store,
53
+ } from "./types.ts";
42
54
 
43
55
  /**
44
56
  * Attempts before a report is written off. Six attempts across the backoff
@@ -85,6 +97,8 @@ export type ReportSend = (text: string) => Promise<number | undefined>;
85
97
  /** What one pass did, by report id. Returned for the daemon's log and the tests. */
86
98
  export interface ReportDeliveryPass {
87
99
  delivered: string[];
100
+ /** Material reports preserved without sending after policy or availability changed. */
101
+ deferred: string[];
88
102
  /** Known-failed, back in `pending` behind a backoff. */
89
103
  requeued: string[];
90
104
  /** The attempt ended without an answer. Left `sending` and flagged as a
@@ -108,7 +122,8 @@ export interface ReportOutbox {
108
122
  }
109
123
 
110
124
  export interface ReportOutboxDeps {
111
- project: ProjectConfig;
125
+ /** A provider lets a resident daemon apply config edits at the next tick. */
126
+ project: ProjectConfig | (() => ProjectConfig);
112
127
  store: Store;
113
128
  /** A report nobody can deliver escalates through this. Optional so a unit
114
129
  * test can exercise delivery without wiring an escalator. */
@@ -116,6 +131,8 @@ export interface ReportOutboxDeps {
116
131
  send?: ReportSend;
117
132
  now?: () => number;
118
133
  log?: (msg: string) => void;
134
+ /** False while the resident daemon cannot validate live delivery policy. */
135
+ deliveryAllowed?: () => boolean;
119
136
  }
120
137
 
121
138
  /**
@@ -200,6 +217,20 @@ export function formatOpenReports(
200
217
  return lines;
201
218
  }
202
219
 
220
+ /** Durable rows still owed to a future digest. Always rendered in `status`: a
221
+ * quiet line proves the accumulator is empty, while a non-zero line makes loss
222
+ * or backlog visible without asking the session what it remembers. */
223
+ export function formatDigestBacklog(backlog: DigestBacklog, now: number = Date.now()): string[] {
224
+ const age = (at: number | undefined): string =>
225
+ at === undefined ? "" : ` (oldest ${humanAge(Math.max(0, now - at))})`;
226
+ return [
227
+ "",
228
+ "digest backlog",
229
+ ` material events ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
230
+ ` held escalations ${backlog.heldNoticeCount}${age(backlog.heldNoticeOldestAt)}`,
231
+ ];
232
+ }
233
+
203
234
  function openReportDetail(r: ReportRecord, now: number): string {
204
235
  const flat = r.lastError?.replace(/\s+/g, " ").trim() ?? "";
205
236
  const error =
@@ -253,14 +284,119 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
253
284
  "definitive",
254
285
  );
255
286
  }
256
- return await sendTelegram(token, chatId, text);
287
+ return await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
288
+ };
289
+ }
290
+
291
+ /** Keep the mechanical catch-up comfortably inside Telegram's report wrapper. */
292
+ const AVAILABILITY_REPORT_BODY_LIMIT = 3_200;
293
+ const AVAILABILITY_REPORT_KEY_PREFIX = "availability/";
294
+
295
+ interface AvailabilityReportMarker {
296
+ categories: InterruptCategory[];
297
+ urgent: boolean;
298
+ }
299
+
300
+ function availabilityReportMarker(report: ReportRecord): AvailabilityReportMarker | undefined {
301
+ const key = report.dedupeKey;
302
+ if (key === undefined || !key.startsWith(AVAILABILITY_REPORT_KEY_PREFIX)) return undefined;
303
+ const parts = key.slice(AVAILABILITY_REPORT_KEY_PREFIX.length).split("/");
304
+ const urgent = parts[0] === "urgent";
305
+ const encoded = (urgent ? parts[1] : parts[0]) ?? "";
306
+ return {
307
+ urgent,
308
+ categories: encoded
309
+ .split(",")
310
+ .filter((category): category is InterruptCategory =>
311
+ INTERRUPT_CATEGORIES.includes(category as InterruptCategory),
312
+ ),
313
+ };
314
+ }
315
+
316
+ function availabilityNoticeLine(notice: HeldNotice): string {
317
+ const flat = (text: string, limit: number): string => {
318
+ const value = text.replace(/\s+/g, " ").trim();
319
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
257
320
  };
321
+ return (
322
+ `- [${notice.category}] ${flat(notice.summary, 180)} ` +
323
+ `(${new Date(notice.createdAt).toISOString()}) — ${flat(notice.detail, 240)}`
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Hand availability-held notices to the existing durable outbox when the live
329
+ * policy permits them again. Association and report creation are one SQLite
330
+ * transaction, so daemon downtime or a send failure cannot lose a notice.
331
+ */
332
+ export function enqueueAvailableHeldNotices(
333
+ project: ProjectConfig,
334
+ store: Store,
335
+ now: number,
336
+ ): ReportEnqueue | undefined {
337
+ // The Store arbitrates the due-digest lease and this catch-up in the same
338
+ // SQLite transaction. A preflight here would reopen the snapshot race.
339
+ const categories = INTERRUPT_CATEGORIES.filter(
340
+ (category) => interruptDisposition(project.reporting, category, now) === "interrupt",
341
+ );
342
+ const urgentCategories = INTERRUPT_CATEGORIES.filter(
343
+ (category) =>
344
+ availabilityDisposition(project.reporting?.availability, category, now) === "interrupt",
345
+ );
346
+ const urgent = store.undigestedNotices(
347
+ project.name,
348
+ DIGEST_BACKLOG_LIMIT,
349
+ true,
350
+ urgentCategories,
351
+ true,
352
+ );
353
+ const eligible =
354
+ urgent.length > 0
355
+ ? urgent
356
+ : store.undigestedNotices(
357
+ project.name,
358
+ DIGEST_BACKLOG_LIMIT,
359
+ true,
360
+ categories,
361
+ false,
362
+ );
363
+ if (eligible.length === 0) return undefined;
364
+
365
+ const lines = [
366
+ "Working-hours catch-up",
367
+ "These interruptions were held durably while the operator was outside the configured availability window:",
368
+ ];
369
+ const selected: HeldNotice[] = [];
370
+ for (const notice of eligible) {
371
+ const line = availabilityNoticeLine(notice);
372
+ const next = [...lines, line].join("\n");
373
+ if (selected.length > 0 && next.length > AVAILABILITY_REPORT_BODY_LIMIT) break;
374
+ lines.push(line);
375
+ selected.push(notice);
376
+ }
377
+
378
+ const selectedCategories = INTERRUPT_CATEGORIES.filter((category) =>
379
+ selected.some((notice) => notice.category === category),
380
+ );
381
+ const urgentMarker = selected[0]?.urgent === true ? "urgent/" : "";
382
+ return store.enqueueAvailabilityReport(
383
+ {
384
+ project: project.name,
385
+ kind: "digest",
386
+ body: lines.join("\n"),
387
+ dedupeKey: `${AVAILABILITY_REPORT_KEY_PREFIX}${urgentMarker}${selectedCategories.join(",")}/${selected[0]!.id}`,
388
+ at: now,
389
+ },
390
+ selected.map((notice) => notice.id),
391
+ );
258
392
  }
259
393
 
260
394
  export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
261
- const { project, store } = deps;
395
+ const { store } = deps;
396
+ const currentProject =
397
+ typeof deps.project === "function" ? deps.project : (): ProjectConfig => deps.project as ProjectConfig;
262
398
  const now = deps.now ?? Date.now;
263
- const send = deps.send ?? telegramReportSend(project);
399
+ const injectedSend = deps.send;
264
400
  const log = deps.log ?? ((): void => {});
265
401
 
266
402
  /**
@@ -279,6 +415,7 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
279
415
  * undeliverable report pages exactly once and never every five minutes.
280
416
  */
281
417
  const pageUndeliverable = async (r: ReportRecord, error: string): Promise<void> => {
418
+ const project = currentProject();
282
419
  if (deps.escalate === undefined) return;
283
420
  try {
284
421
  await deps.escalate({
@@ -318,6 +455,64 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
318
455
  };
319
456
 
320
457
  const attempt = async (r: ReportRecord, pass: ReportDeliveryPass): Promise<void> => {
458
+ const project = currentProject();
459
+ if (deps.deliveryAllowed?.() === false) {
460
+ pass.deferred.push(r.id);
461
+ return;
462
+ }
463
+ const availabilityMarker = availabilityReportMarker(r);
464
+ if (availabilityMarker !== undefined) {
465
+ if (availabilityMarker.categories.length === 0) {
466
+ pass.deferred.push(r.id);
467
+ log(`availability catch-up ${r.id} has no valid category marker and was held fail-closed`);
468
+ return;
469
+ }
470
+ const dispositions = availabilityMarker.categories.map((category) =>
471
+ availabilityMarker.urgent
472
+ ? availabilityDisposition(project.reporting?.availability, category, now())
473
+ : interruptDisposition(project.reporting, category, now()),
474
+ );
475
+ if (dispositions.some((disposition) => disposition !== "interrupt")) {
476
+ if (store.releasePendingAvailabilityReport(r.id, project.name, r.ambiguous)) {
477
+ const reason = dispositions.includes("digest")
478
+ ? "reporting policy changed"
479
+ : "the availability window closed";
480
+ log(`availability catch-up ${r.id} returned to the digest because ${reason}`);
481
+ }
482
+ pass.deferred.push(r.id);
483
+ return;
484
+ }
485
+ }
486
+ if (r.kind === "material") {
487
+ const at = now();
488
+ const disposition = interruptDisposition(project.reporting, "material", at);
489
+ if (disposition !== "interrupt") {
490
+ const firstLine = r.body.split("\n", 1)[0]!;
491
+ const deferred = store.deferPendingReportToNotice(r.id, {
492
+ id: r.id,
493
+ project: project.name,
494
+ category: "material",
495
+ summary: (
496
+ r.ambiguous ? `POSSIBLE REPEAT of report ${r.id}: ${firstLine}` : firstLine
497
+ ).slice(0, 240),
498
+ detail: r.ambiguous
499
+ ? `This report may already have reached Telegram before its outcome was lost.\n\n${r.body}`
500
+ : r.body,
501
+ createdAt: at,
502
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
503
+ });
504
+ if (deferred) {
505
+ pass.deferred.push(r.id);
506
+ log(
507
+ `report ${r.id} preserved for ${
508
+ disposition === "availability" ? "the next availability window" : "a digest"
509
+ } after reporting policy changed`,
510
+ );
511
+ return;
512
+ }
513
+ }
514
+ }
515
+ const send = injectedSend ?? telegramReportSend(project);
321
516
  const attemptId = crypto.randomUUID();
322
517
  // Losing this race is ordinary: another pass, or another daemon, already
323
518
  // owns the attempt. Returning is what keeps one report from being in flight
@@ -400,12 +595,14 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
400
595
 
401
596
  return {
402
597
  recover(staleAt: number): ReportRecord[] {
403
- return store.recoverSendingReports(project.name, staleAt, now());
598
+ return store.recoverSendingReports(currentProject().name, staleAt, now());
404
599
  },
405
600
 
406
601
  async deliverDue(): Promise<ReportDeliveryPass> {
602
+ const project = currentProject();
407
603
  const pass: ReportDeliveryPass = {
408
604
  delivered: [],
605
+ deferred: [],
409
606
  requeued: [],
410
607
  uncertain: [],
411
608
  failed: [],
@@ -49,6 +49,8 @@ export interface SessionHostSpec {
49
49
  * Absent, the verbs are registered and every one of them fails closed.
50
50
  */
51
51
  verbSocketPath?: string;
52
+ /** Deny every tool but reading and searching (#307) — the setup probes. */
53
+ readOnly?: boolean;
52
54
  }
53
55
 
54
56
  /** Parent → child. */
@@ -184,6 +186,7 @@ export async function runSessionHost(
184
186
  ...(spec.resume === undefined ? {} : { resume: spec.resume }),
185
187
  ...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
186
188
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
189
+ ...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
187
190
  // The release audit lives in the daemon's state directory, which this
188
191
  // process may not be able to write and must not be trusted to. It
189
192
  // becomes a message; the parent performs the durable write.