omp-conductor 0.15.11 → 0.15.12

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/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
  }
@@ -9,10 +9,11 @@
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { findProject, loadConfig, resolveCaps } from "../config.ts";
11
11
  import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
12
- import { runGraphInstall, runHostInstall } from "../setup-install.ts";
12
+ import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
13
13
  import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
14
14
  import { telegramStateDir } from "../fleet.ts";
15
15
  import { terminalUi } from "../wizard-ui.ts";
16
+ import type { ConductorConfig, ProjectConfig } from "../types.ts";
16
17
 
17
18
  /**
18
19
  * The setup verb's own usage, printed for `setup --help` / `setup -h` before
@@ -25,18 +26,43 @@ and the staged host files behind one confirm.
25
26
 
26
27
  usage:
27
28
  omp-conductor setup [area] [--no-ai] [--project NAME]
29
+ omp-conductor setup host [NAME] [--project NAME]
30
+ omp-conductor setup graph [--no-seed] [--print] [--project NAME]
28
31
 
29
32
  Bare setup runs the full interview — or, when the project already exists,
30
33
  asks which area to amend. Naming an area positionally skips that chooser and
31
- amends only that area.
34
+ amends only that area. \`setup host\` and \`setup graph\` are install
35
+ subcommands, not areas: the NAME (or --project NAME) says which project to
36
+ install for.
32
37
 
33
38
  amend areas:
34
39
  ${AMEND_AREA_IDS.join(", ")}
35
40
 
36
41
  flags:
37
- --project NAME the project to configure or amend
42
+ --project NAME the project to configure or amend (or NAME positionally
43
+ for \`setup host\`)
38
44
  --no-ai ask every question, propose nothing (no AI repo reads)`;
39
45
 
46
+ /**
47
+ * Resolve the project an install subcommand (`setup host`, `setup graph`)
48
+ * installs for — the flag, or the positional form an operator naturally
49
+ * types on a multi-project host (`setup host conductor`). The pair may not
50
+ * disagree, and an unknown name fails loudly as "Unknown project …" rather
51
+ * than being silently ignored (#514).
52
+ */
53
+ export function setupInstallProject(
54
+ cfg: ConductorConfig,
55
+ projectFlag: string | undefined,
56
+ positionalName: string | undefined,
57
+ ): ProjectConfig {
58
+ if (projectFlag !== undefined && positionalName !== undefined && projectFlag !== positionalName) {
59
+ throw new Error(
60
+ `setup names two different projects (--project ${projectFlag} and ${positionalName}) — pass one`,
61
+ );
62
+ }
63
+ return findProject(cfg, projectFlag ?? positionalName);
64
+ }
65
+
40
66
  export async function setupCommand(ctx: CommandContext): Promise<void> {
41
67
  // Help first, and only in the first trailing position: a help request
42
68
  // must never open a UI, read config, probe GitHub or pause dispatch.
@@ -58,14 +84,38 @@ try {
58
84
  // is how `setup host` came to exit 2 as an "unknown setup area".
59
85
  if (positional === "host" || positional === "graph") {
60
86
  const cfg = loadConfig();
61
- const project = findProject(cfg, ctx.projectFlag);
62
- const outcome =
63
- positional === "host"
64
- ? await runHostInstall(project, resolveCaps(project, cfg.defaults), telegramStateDir(), ui)
65
- : await runGraphInstall(project, ui, {
66
- noSeed: ctx.argv.includes("--no-seed"),
67
- print: ctx.argv.includes("--print"),
68
- });
87
+ // `setup host <name>`: the project an operator would naturally type.
88
+ // The positional only ever names a project; anything else that does not
89
+ // look like a flag is refused by findProject below rather than being
90
+ // quietly ignored.
91
+ const positionalName =
92
+ positional === "host" && ctx.argv[2] !== undefined && !ctx.argv[2].startsWith("--")
93
+ ? ctx.argv[2]
94
+ : undefined;
95
+ let outcome: InstallOutcome;
96
+ if (positional === "host") {
97
+ // No `--project` and no positional → a host-global install (#530): the
98
+ // units are host-global, so a multi-project host installs them with no
99
+ // name and skips the per-project tail (tick config, brief link) with a
100
+ // named note; a name resolves and wires the tail.
101
+ const project =
102
+ ctx.projectFlag === undefined && positionalName === undefined
103
+ ? undefined
104
+ : setupInstallProject(cfg, ctx.projectFlag, positionalName);
105
+ outcome = await runHostInstall(
106
+ project,
107
+ project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
108
+ telegramStateDir(),
109
+ ui,
110
+ );
111
+ } else {
112
+ // `setup graph` is inherently per-project repositories; there is no
113
+ // host-global graph to install, so a project must resolve.
114
+ outcome = await runGraphInstall(findProject(cfg, ctx.projectFlag), ui, {
115
+ noSeed: ctx.argv.includes("--no-seed"),
116
+ print: ctx.argv.includes("--print"),
117
+ });
118
+ }
69
119
  // `staged` is a success on a host that has no systemd: the files are
70
120
  // real, only the enable step is impossible.
71
121
  if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
@@ -13,6 +13,15 @@ import type { CommandContext } from "./context.ts";
13
13
  import { rollbackFromJournal } from "../upgrade.ts";
14
14
 
15
15
  export async function upgradeRollbackCommand(ctx: CommandContext): Promise<void> {
16
+ // Nothing here varies by project (#514): the rollback restores every
17
+ // surface the failed install touched, from the host-wide journal. The
18
+ // flag only ever misled, so refuse it with a reason.
19
+ if (ctx.projectFlag !== undefined) {
20
+ process.stderr.write(
21
+ `omp-conductor: upgrade-rollback does not take a project — it restores the whole host from the upgrade journal\n`,
22
+ );
23
+ process.exit(2);
24
+ }
16
25
  const result = await rollbackFromJournal();
17
26
  process.stdout.write(
18
27
  `rolled back to omp-conductor@${result.restoredVersion} after the failed ` +
@@ -316,12 +316,21 @@ const projectSchema = z
316
316
  routing: routingSchema.optional(),
317
317
  caps: capsSchema.optional(),
318
318
  workerModel: z.unknown().optional(),
319
+ // The loader normalises both of these (trimmed entries / positive integer),
320
+ // so the schema admits any shape and lets the normaliser decide what is
321
+ // usable, exactly like `workerModel`.
322
+ modelFallbacks: z.unknown().optional(),
323
+ modelFallbackThreshold: z.unknown().optional(),
319
324
  escalation: escalationSchema.optional(),
320
325
  authority: authoritySchema.optional(),
321
326
  releasePolicy: releasePolicySchema.optional(),
322
327
  policy: projectPolicySchema.optional(),
323
328
  recoveryMerges: recoveryMergesSchema.optional(),
324
329
  reporting: reportingSchema.optional(),
330
+ // Hand-edited safety markers (commit SHAs or refs) a preserved continuation
331
+ // branch must contain before it may be reattached; the loader normalises
332
+ // them like `modelFallbacks`.
333
+ criticalBase: z.unknown().optional(),
325
334
  // Roots silently fall back to the default when unusable, so the schema
326
335
  // admits any shape and the normaliser picks the usable path.
327
336
  workspaceRoot: z.unknown().optional(),