omp-conductor 0.15.11 → 0.15.13

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 (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * ./daemon.ts and the background process lifecycle in ./lifecycle.ts.
9
9
  */
10
10
  import { userInfo } from "node:os";
11
- import { findProject, loadConfig } from "./config.ts";
11
+ import { loadConfig } from "./config.ts";
12
12
  import { AMEND_AREA_IDS } from "./setup.ts";
13
13
  import type { ProjectConfig } from "./types.ts";
14
14
  import { armCommand } from "./commands/arm.ts";
@@ -43,7 +43,12 @@ import { upgradeCommand } from "./commands/upgrade.ts";
43
43
  import { verbCommand } from "./commands/verb.ts";
44
44
  import { versionCommand } from "./commands/version.ts";
45
45
  import { workerCommand } from "./commands/worker.ts";
46
- import type { CommandContext, CommandHandler } from "./commands/context.ts";
46
+ import {
47
+ COMMAND_SCOPES,
48
+ resolveProjectsByScope,
49
+ type CommandContext,
50
+ type CommandHandler,
51
+ } from "./commands/context.ts";
47
52
 
48
53
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
49
54
 
@@ -55,7 +60,7 @@ usage:
55
60
  omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
56
61
  omp-conductor upgrade [--to VERSION] [--project NAME]
57
62
  omp-conductor upgrade-install --to VERSION [--project NAME]
58
- omp-conductor upgrade-rollback [--project NAME]
63
+ omp-conductor upgrade-rollback
59
64
  omp-conductor board [--project NAME] [--json]
60
65
  omp-conductor dashboard [--port N] [--host ADDR]
61
66
  omp-conductor status [--project NAME]
@@ -78,7 +83,7 @@ usage:
78
83
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
79
84
  omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
80
85
  omp-conductor report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]
81
- omp-conductor message --text TEXT [--project NAME]
86
+ omp-conductor message --text TEXT [--category CATEGORY] [--blocks TEXT] [--project NAME]
82
87
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
83
88
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
84
89
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
@@ -86,6 +91,7 @@ usage:
86
91
  omp-conductor intake "<text>" [--project NAME]
87
92
  omp-conductor intake list [--project NAME]
88
93
  omp-conductor intake dismiss <id> [--project NAME]
94
+ omp-conductor intake groomed <id> --issue <url> [--project NAME]
89
95
  omp-conductor help
90
96
 
91
97
  setup interview, then write config.json, the labels, the briefs and the
@@ -110,9 +116,10 @@ usage:
110
116
  dispatch loop in the background and wait until it answers GET
111
117
  /healthz on :8787 (override with --port). Refuses if one is running.
112
118
  stop stop the conductor: pause claiming, disarm ticks, then stop the
113
- dispatch daemon (systemctl-aware, so Restart=on-failure cannot bring
114
- it back). Pane stays up unless --pane is passed. To bounce the daemon
115
- without stopping the fleet, use restart.
119
+ dispatch daemon (systemctl-aware: an explicit stop is recorded by
120
+ systemd, so Restart=always does not undo it). Pane stays up unless
121
+ --pane is passed. To bounce the daemon without stopping the fleet,
122
+ use restart.
116
123
  stop --pane
117
124
  also stop the conductor agent's pane and pin herdr-conductor recovery
118
125
  off for that agent only — it does NOT stop herdr-fleet.service or any
@@ -222,9 +229,14 @@ usage:
222
229
  last wrote to the session. This is how a locally injected tick
223
230
  answers or asks something directly: telegram_send keeps the active
224
231
  topic only while it names no chat, and a tick has no active topic to
225
- keep. Text beginning "QUESTION:" carries the decision category; the
226
- operator's availability policy still decides between sending now and
227
- holding a notice, exactly as it does for an autonomous tick.
232
+ keep. A question — text beginning "QUESTION:", or a --category that
233
+ is not material records an open decision row before delivery
234
+ (parked on silence: still pending in every tick until answered or
235
+ the seven-day expiry, so no one has to remember a separate
236
+ \`decision open\`), and --category carries the escalation category
237
+ directly instead of a text prefix. The operator's availability
238
+ policy still decides between sending now and holding a notice,
239
+ exactly as it does for an autonomous tick.
228
240
  decision record, list and close the questions you have put to your operator.
229
241
  A question that lives only in a session's context is lost to the next
230
242
  compaction, so \`decision open\` writes it down and every tick's prompt
@@ -239,8 +251,9 @@ usage:
239
251
  intake keep a raw idea durably before it becomes anything: record it now
240
252
  with \`omp-conductor intake "<text>"\`, list what is still pending,
241
253
  dismiss what turned out to be nothing. Backed by the sqlite store,
242
- not a session, so ideas survive restarts; grooming one into an issue
243
- happens behind the scenes, not here.
254
+ not a session, so ideas survive restarts; the orchestrator grooms
255
+ one into an issue as its #300 duty and records that provenance with
256
+ \`omp-conductor intake groomed <id> --issue <url>\`.
244
257
  friction record a bounded observation the daemon cannot classify itself:
245
258
  an escalation that belonged in a digest, or a tick report that was
246
259
  noise/surprising. Repeated observations feed the existing Learning
@@ -249,8 +262,11 @@ usage:
249
262
  and exits. This is what \`start\` launches.
250
263
  resume clear pause and any pane-recovery pin. Does NOT re-arm: run arm after
251
264
  an inbound Telegram proof to bring ticks back. Use --all for every project.
252
- setup host
265
+ setup host [NAME]
253
266
  re-stage the systemd unit and run the install behind one confirm.
267
+ The units are host-global; NAME (or --project NAME) says which
268
+ project's per-project tail (tick config, brief link) to write, and
269
+ is required on a host with several configured projects.
254
270
  setup graph
255
271
  set up the code-graph indexes workers query instead of grepping, end
256
272
  to end: check prerequisites, clone any missing index-only clone as
@@ -402,14 +418,10 @@ const ctx: CommandContext = {
402
418
  };
403
419
 
404
420
  function targetProjects(): ProjectConfig[] {
405
- const cfg = loadConfig();
406
- if (argv.includes("--all")) return cfg.projects;
407
- if (projectFlag === undefined && cfg.projects.length > 1) {
408
- throw new Error(
409
- `config has ${cfg.projects.length} projects; use --project NAME or --all`,
410
- );
411
- }
412
- return [findProject(cfg, projectFlag)];
421
+ // The fleet resolution, delegated to the scope machinery (#514) so the
422
+ // arm/disarm/hold/resume/stop "every project, or the named one" shape is
423
+ // the same code the classification describes it as.
424
+ return resolveProjectsByScope(loadConfig(), "fleet", projectFlag, argv.includes("--all"));
413
425
  }
414
426
 
415
427
  /**
@@ -484,6 +496,14 @@ try {
484
496
  );
485
497
  process.exit(2);
486
498
  }
499
+ // A "none"-scope verb does no per-project work, so --project cannot change
500
+ // what it does: refuse it with a reason instead of silently ignoring it
501
+ // (#514). Host-scoped verbs keep the flag because each one has a documented
502
+ // per-project tail (or already refuses to narrow, #389).
503
+ if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
504
+ process.stderr.write(`omp-conductor: ${cmd} does not take a project — it needs no --project\n`);
505
+ process.exit(2);
506
+ }
487
507
  await handler(ctx);
488
508
  } catch (err) {
489
509
  // Config, lifecycle and `gh` errors are written to be read by a human, so
@@ -1,4 +1,5 @@
1
- import type { ProjectConfig } from "../types.ts";
1
+ import { findProject } from "../config.ts";
2
+ import type { ConductorConfig, ProjectConfig } from "../types.ts";
2
3
 
3
4
  /**
4
5
  * What a command handler may read about the invocation it was dispatched for.
@@ -47,3 +48,103 @@ export interface CommandContext {
47
48
 
48
49
  /** One operator subcommand: what the registration table maps a verb to. */
49
50
  export type CommandHandler = (ctx: CommandContext) => Promise<void> | void;
51
+
52
+ /**
53
+ * The project scope a command's work is bound to, declared once per verb
54
+ * (#514). Every command in `omp/src/commands/` carries one, and the
55
+ * resolution a command performs must follow it rather than defaulting to
56
+ * `findProject`:
57
+ *
58
+ * - `"project"` — the verb acts on exactly one configured project. It
59
+ * resolves through {@link findProject}, so a multi-project config demands
60
+ * `--project NAME` (ledger, stats, event, report, decision, …).
61
+ * - `"host"` — the verb acts on the one shared host (units, daemon, package
62
+ * surfaces), which no configured project can make ambiguous. It never
63
+ * resolves a project; `--project` is either refused (upgrade/restart
64
+ * refuse to narrow the shared multi-project daemon, #389) or accepted only
65
+ * for the documented per-project tail it affects (start's /healthz check,
66
+ * the `daemon --once` drill).
67
+ * - `"fleet"` — the verb acts on every configured project by default and
68
+ * narrows with `--project NAME` / `--all`, the `targetProjects` shape
69
+ * (arm, disarm, hold, resume, stop, status, board).
70
+ * - `"none"` — the verb takes no project at all (dashboard, help, version).
71
+ */
72
+ export type CommandScope = "project" | "host" | "fleet" | "none";
73
+
74
+ /**
75
+ * The explicit scope of every registered command, keyed by the exact strings
76
+ * the CLI accepts (aliases included). `cli.test.ts` asserts each module in
77
+ * `commands/` is classified and that each scope resolves the way the table
78
+ * below says; REFERENCE.md's CLI table carries the same classification in
79
+ * human form.
80
+ */
81
+ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
82
+ // project — exactly one project; findProject demands --project when several
83
+ "brief-upgrade": "project",
84
+ decision: "project",
85
+ event: "project",
86
+ extend: "project",
87
+ friction: "project",
88
+ intake: "project",
89
+ ledger: "project",
90
+ message: "project",
91
+ report: "project",
92
+ setup: "project", // the wizard and `setup graph` configure one project
93
+ stats: "project",
94
+ tail: "project",
95
+ unblock: "project",
96
+ verb: "project",
97
+ worker: "project",
98
+ // host — the shared daemon/package surfaces; never resolves a project
99
+ daemon: "host",
100
+ restart: "host",
101
+ start: "host",
102
+ upgrade: "host",
103
+ "upgrade-install": "host",
104
+ "upgrade-rollback": "host",
105
+ // fleet — every project by default, --project narrows
106
+ arm: "fleet",
107
+ board: "fleet",
108
+ disarm: "fleet",
109
+ doctor: "fleet",
110
+ hold: "fleet",
111
+ resume: "fleet",
112
+ status: "fleet",
113
+ stop: "fleet",
114
+ // none — no project at all
115
+ dashboard: "none",
116
+ help: "none",
117
+ "--help": "none",
118
+ "-h": "none",
119
+ version: "none",
120
+ "--version": "none",
121
+ "-V": "none",
122
+ };
123
+
124
+ /**
125
+ * Resolve the projects one command's work covers, from its declared scope
126
+ * (#514) — the single mechanism every scope falls out of, so a host-scoped
127
+ * command can never inherit `findProject`'s ambiguity by accident.
128
+ *
129
+ * `"host"`/`"none"` resolve to nothing. `"fleet"` returns every configured
130
+ * project with `all`, the named one, or — with neither — the sole configured
131
+ * project, refusing a multi-project config exactly as `targetProjects` always
132
+ * has. `"project"` returns exactly one, via {@link findProject}.
133
+ */
134
+ export function resolveProjectsByScope(
135
+ cfg: ConductorConfig,
136
+ scope: CommandScope,
137
+ name: string | undefined,
138
+ all: boolean,
139
+ ): ProjectConfig[] {
140
+ if (scope === "host" || scope === "none") return [];
141
+ if (scope === "fleet") {
142
+ if (all) return [...cfg.projects];
143
+ if (name === undefined && cfg.projects.length > 1) {
144
+ throw new Error(
145
+ `config has ${cfg.projects.length} projects; use --project NAME or --all`,
146
+ );
147
+ }
148
+ }
149
+ return [findProject(cfg, name)];
150
+ }
@@ -42,8 +42,10 @@ flags:
42
42
  contract
43
43
  --probe-telegram the ONE opt-in side effect: send one self-identified
44
44
  test message through the report transport
45
- --project NAME the project to check (required only when the config
46
- has more than one)
45
+ --project NAME narrow to one project. With no name, the host-wide
46
+ facts are checked once and every configured project's
47
+ per-project facts (labels, timezone, telegram) are
48
+ checked, each named.
47
49
 
48
50
  Exit code is 0 only when nothing failed; warnings do not fail the run.`;
49
51
 
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * The store is the point of the verb: an idea captured at 02:00 is still there
5
5
  * after a restart, and `status` counts what is still pending so a backlog
6
- * cannot quietly empty itself. This surface only records, lists and dismisses;
7
- * turning an idea into an issue is #300, which uses `resolveIntake`'s
8
- * `groomed` path.
6
+ * cannot quietly empty itself. This surface records, lists, dismisses and
7
+ * once the orchestrator has groomed an idea into an issue (#300) marks the
8
+ * provenance of that grooming via `resolveIntake`'s `groomed` path.
9
9
  */
10
10
 
11
11
  import type { CommandContext } from "./context.ts";
@@ -18,15 +18,18 @@ usage:
18
18
  omp-conductor intake "<text>" [--project NAME]
19
19
  omp-conductor intake list [--project NAME]
20
20
  omp-conductor intake dismiss <id> [--project NAME]
21
+ omp-conductor intake groomed <id> --issue <url> [--project NAME]
21
22
 
22
23
  Captures one raw idea into the local store and prints its id. list shows what
23
24
  is still pending (id, age, text), oldest first; dismiss drops one by id. The
24
25
  capture is durable — it lives in the sqlite store, not in a session — so it
25
- survives daemon restarts, and an idea can later be groomed into an issue.`;
26
+ survives daemon restarts. The orchestrator files the idea as an issue and then
27
+ marks that provenance with groomed: an id already resolved is a no-op with a
28
+ message, never an error, because ticks retry.`;
26
29
 
27
30
  /** Flags the intake surface understands. `--project` is consumed by
28
31
  * {@link CommandContext.projectFlag}; the value token stays in argv. */
29
- const INTAKE_FLAGS: Record<string, true> = { "--project": true };
32
+ const INTAKE_FLAGS: Record<string, true> = { "--project": true, "--issue": true };
30
33
 
31
34
  /** Every remaining token after the subverb must be one of the known flags
32
35
  * (or its value), otherwise the operator typo'd something that would be
@@ -89,6 +92,24 @@ export async function intakeCommand(ctx: CommandContext): Promise<void> {
89
92
  return;
90
93
  }
91
94
 
95
+ if (sub === "groomed") {
96
+ assertKnownArgs(ctx, 3);
97
+ const id = ctx.argv[2];
98
+ const url = ctx.flag("issue")?.trim();
99
+ if (id === undefined || id.startsWith("--") || url === undefined || url.length === 0) {
100
+ process.stderr.write("omp-conductor: intake groomed needs the idea id and --issue <url>\n");
101
+ process.exit(2);
102
+ }
103
+ if (!store.resolveIntake(id, "groomed", url)) {
104
+ // Ticks retry: an id that is already groomed (or unknown) is a no-op
105
+ // with a message, not an error, so a re-filed tick never fails here.
106
+ process.stdout.write(`intake ${id} already resolved or unknown — nothing to do\n`);
107
+ return;
108
+ }
109
+ process.stdout.write(`intake ${id} groomed → ${url}\n`);
110
+ return;
111
+ }
112
+
92
113
  if (sub !== undefined && sub.startsWith("--") && INTAKE_FLAGS[sub] !== true) {
93
114
  process.stderr.write(`omp-conductor: intake: unexpected argument "${sub}"\n`);
94
115
  process.exit(2);
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * `message` — deliver one direct Telegram message to the project's own chat and forum topic, applying the same availability policy as a tick.
3
3
  *
4
+ * A question-shaped message — text that begins with the floor's `QUESTION:`
5
+ * marker, or an explicitly declared escalation `--category` that is not
6
+ * `material` — is an ask, not a send: the row is recorded before anything is
7
+ * delivered (#520), so "asked once, no reply" is a durable open decision row
8
+ * re-surfaced in every tick until answered or the seven-day expiry, and the
9
+ * caller never has to remember a separate `decision open`.
10
+ *
4
11
  * Moved out of cli.ts's switch by the per-verb module split (#462);
5
12
  * only the case wrapper, the injected `ctx` lookups and the imports
6
13
  * changed from the original bodies.
@@ -9,40 +16,81 @@
9
16
  import type { CommandContext } from "./context.ts";
10
17
  import { randomUUID } from "node:crypto";
11
18
  import { findProject, loadConfig } from "../config.ts";
12
- import { deliverOperatorMessage, type OperatorMessageOutcome } from "../reports.ts";
19
+ import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
13
20
  import { dbPath, openStore } from "../store.ts";
21
+ import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
22
+
23
+ /** The floor's "this needs an answer" marker, as every other classifier reads it. */
24
+ const QUESTION_MARKER = /^\s*QUESTION:\s/i;
14
25
 
15
26
  export async function messageCommand(ctx: CommandContext): Promise<void> {
16
- const rawText = ctx.flag("text");
17
- const body = rawText?.trim();
18
- if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
19
- process.stderr.write("omp-conductor: message needs --text with the message to deliver\n");
20
- process.exit(2);
21
- }
22
- const project = findProject(loadConfig(), ctx.projectFlag);
23
- const store = openStore(dbPath());
24
- let outcome: OperatorMessageOutcome;
25
- try {
26
- outcome = await deliverOperatorMessage(project, body, {
27
- store,
28
- at: Date.now(),
29
- noticeId: randomUUID(),
30
- });
31
- } catch (err) {
32
- process.stderr.write(
33
- `omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`,
27
+ const rawText = ctx.flag("text");
28
+ const body = rawText?.trim();
29
+ if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
30
+ process.stderr.write("omp-conductor: message needs --text with the message to deliver\n");
31
+ process.exit(2);
32
+ }
33
+ const categoryRaw = ctx.flag("category");
34
+ if (
35
+ categoryRaw !== undefined &&
36
+ !(INTERRUPT_CATEGORIES as readonly string[]).includes(categoryRaw)
37
+ ) {
38
+ process.stderr.write(
39
+ `omp-conductor: message category must be one of ${INTERRUPT_CATEGORIES.join(", ")} — a question's category is part of the ask, never a text prefix\n`,
40
+ );
41
+ process.exit(2);
42
+ }
43
+ const category = categoryRaw as InterruptCategory | undefined;
44
+ const blocks = ctx.flag("blocks")?.trim();
45
+
46
+ const project = findProject(loadConfig(), ctx.projectFlag);
47
+ const store = openStore(dbPath());
48
+ // The durable row exists before the delivery attempt, exactly like the
49
+ // `conductor_ask` tool: a crash at any point leaves a recorded question, so
50
+ // the "asked, awaiting the operator" half of the ask cannot be lost. Silence
51
+ // means the row stays open — re-surfaced in every tick until answered or the
52
+ // seven-day expiry; it never records an approval.
53
+ const effectiveCategory = category ?? operatorMessageCategory(body);
54
+ const isAsk = QUESTION_MARKER.test(body) || effectiveCategory !== "material";
55
+ let row: DecisionRecord | undefined;
56
+ let outcome: OperatorMessageOutcome;
57
+ try {
58
+ if (isAsk) {
59
+ row = store.createDecision({
60
+ project: project.name,
61
+ // The row archives the question, not the delivery prefix.
62
+ question: body.replace(QUESTION_MARKER, "").trim(),
63
+ ...(blocks === undefined ? {} : { blocks }),
64
+ at: Date.now(),
65
+ });
66
+ }
67
+ outcome = await deliverOperatorMessage(project, body, {
68
+ store,
69
+ at: Date.now(),
70
+ noticeId: randomUUID(),
71
+ ...(category === undefined ? {} : { category }),
72
+ });
73
+ } catch (err) {
74
+ process.stderr.write(
75
+ `omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`,
76
+ );
77
+ process.exit(2);
78
+ } finally {
79
+ store.close();
80
+ }
81
+ if (row !== undefined) {
82
+ process.stdout.write(
83
+ `decision ${row.id} recorded — parked on silence: the question stays open until answered or the seven-day expiry. ` +
84
+ `Resolve it later with: omp-conductor decision resolve ${row.id} --answer "..."\n`,
85
+ );
86
+ }
87
+ process.stdout.write(
88
+ outcome.kind === "sent"
89
+ ? // Not "into the topic": a stale topic degrades to the flat chat with
90
+ // its own warning on stderr, and this line must not contradict it.
91
+ `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
92
+ : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
93
+ `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
94
+ "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
34
95
  );
35
- process.exit(2);
36
- } finally {
37
- store.close();
38
- }
39
- process.stdout.write(
40
- outcome.kind === "sent"
41
- ? // Not "into the topic": a stale topic degrades to the flat chat with
42
- // its own warning on stderr, and this line must not contradict it.
43
- `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
44
- : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
45
- `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
46
- "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
47
- );
48
96
  }
@@ -13,7 +13,14 @@ import { findProject, loadConfig } from "../config.ts";
13
13
  import { digestDue } from "../digest-schedule.ts";
14
14
  import { dbPath, openStore } from "../store.ts";
15
15
  import { digestDedupeKey } from "../reports.ts";
16
- import { DEFAULT_REPORT_POLICY, REPORT_KINDS, type InterruptCategory, type ReportKind } from "../types.ts";
16
+ import {
17
+ DEFAULT_REPORT_POLICY,
18
+ INTERRUPT_CATEGORIES,
19
+ REPORT_KINDS,
20
+ SESSION_ROLE_ENV,
21
+ type InterruptCategory,
22
+ type ReportKind,
23
+ } from "../types.ts";
17
24
 
18
25
  export async function reportCommand(ctx: CommandContext): Promise<void> {
19
26
  const rawText = ctx.flag("text");
@@ -39,6 +46,23 @@ if (kind !== "digest" && (ctx.argv.includes("--events") || ctx.argv.includes("--
39
46
  );
40
47
  process.exit(2);
41
48
  }
49
+ // Escalation tiers are not a worker session's to claim, like every other
50
+ // privileged surface (#508). The daemon stamps the session role on spawned
51
+ // sessions; a value outside the closed vocabulary is refused, never assumed.
52
+ // A bare CLI run (operator's shell) has the env absent and stays the
53
+ // orchestrator surface, so this refuses precisely the worker who would
54
+ // otherwise page the operator from inside a worktree.
55
+ const sessionRole = process.env[SESSION_ROLE_ENV];
56
+ if (
57
+ sessionRole === "worker" &&
58
+ INTERRUPT_CATEGORIES.some((category) => category !== "material" && category === kind)
59
+ ) {
60
+ process.stderr.write(
61
+ `omp-conductor: report: escalation tier is not a worker session's to claim; ` +
62
+ `only the operator or orchestrator may hand off a ${kind} report\n`,
63
+ );
64
+ process.exit(3);
65
+ }
42
66
  const project = findProject(loadConfig(), ctx.projectFlag);
43
67
  const store = openStore(dbPath());
44
68
  try {
@@ -73,8 +97,20 @@ try {
73
97
  const category: InterruptCategory = kind;
74
98
  const disposition = interruptDisposition(policy, category, at);
75
99
  if (disposition === "digest") {
100
+ // Name the flags that would have worked rather than only the digest:
101
+ // the categories this policy actually pages are how a handoff like a
102
+ // tier-2 install notice gets through, and there is no other spelling to
103
+ // guess at (#508).
104
+ const interruptable = INTERRUPT_CATEGORIES.filter(
105
+ (candidate) => interruptDisposition(policy, candidate, at) === "interrupt",
106
+ );
107
+ const flags =
108
+ interruptable.length > 0
109
+ ? `hand it off as a category this policy pages (--kind ${interruptable.join("|")}) or fold it ` +
110
+ "into the next digest (--kind digest)"
111
+ : "fold it into the next digest (--kind digest)";
76
112
  process.stderr.write(
77
- `omp-conductor: report: ${category} updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n`,
113
+ `omp-conductor: report: ${category} updates are digest-only under this reporting policy; ${flags}\n`,
78
114
  );
79
115
  process.exit(2);
80
116
  }