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.
- package/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- 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 {
|
|
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
|
|
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
|
|
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
|
|
114
|
-
it
|
|
115
|
-
without stopping the fleet,
|
|
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.
|
|
226
|
-
|
|
227
|
-
|
|
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;
|
|
243
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
package/src/commands/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
+
}
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
|
46
|
-
|
|
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
|
|
package/src/commands/intake.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
|
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);
|
package/src/commands/message.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
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
|
}
|
package/src/commands/report.ts
CHANGED
|
@@ -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 {
|
|
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;
|
|
113
|
+
`omp-conductor: report: ${category} updates are digest-only under this reporting policy; ${flags}\n`,
|
|
78
114
|
);
|
|
79
115
|
process.exit(2);
|
|
80
116
|
}
|