omp-conductor 0.15.9 → 0.15.11
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/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/src/cli.ts
CHANGED
|
@@ -1,101 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
3
|
* Standalone entry point — the only operator surface after #309. Everything here
|
|
4
|
-
* is argument handling
|
|
4
|
+
* is argument handling, the shared usage text, and the registration table that
|
|
5
|
+
* dispatches each verb to its own module in ./commands/<verb>.ts; the per-verb
|
|
6
|
+
* bodies moved there by the module split (#462), so a new operator subcommand
|
|
7
|
+
* is one file plus one table line. The loop, the caps and the state live in
|
|
5
8
|
* ./daemon.ts and the background process lifecycle in ./lifecycle.ts.
|
|
6
9
|
*/
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
9
10
|
import { userInfo } from "node:os";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "./
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
} from "./
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
restartDaemon,
|
|
47
|
-
startDaemon,
|
|
48
|
-
stopDaemon,
|
|
49
|
-
writeRecord,
|
|
50
|
-
type RestartResult,
|
|
51
|
-
} from "./lifecycle.ts";
|
|
52
|
-
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
53
|
-
import {
|
|
54
|
-
deliverOperatorMessage,
|
|
55
|
-
digestDedupeKey,
|
|
56
|
-
type OperatorMessageOutcome,
|
|
57
|
-
} from "./reports.ts";
|
|
58
|
-
import { digestDue } from "./digest-schedule.ts";
|
|
59
|
-
import {
|
|
60
|
-
AMEND_AREA_IDS,
|
|
61
|
-
briefPathForProject,
|
|
62
|
-
policyPathForProject,
|
|
63
|
-
renderBriefForProject,
|
|
64
|
-
renderFloorForProject,
|
|
65
|
-
shippedBriefTemplate,
|
|
66
|
-
type AmendAreaId,
|
|
67
|
-
} from "./setup.ts";
|
|
68
|
-
import { runGraphInstall, runHostInstall } from "./setup-install.ts";
|
|
69
|
-
import { DEFAULT_PROBES, NO_PROBES, setup } from "./setup-wizard.ts";
|
|
70
|
-
import { terminalUi } from "./wizard-ui.ts";
|
|
71
|
-
import { dbPath, LIVE_STATES, openStore } from "./store.ts";
|
|
72
|
-
import { formatTranscriptLine } from "./transcript.ts";
|
|
73
|
-
import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
|
|
74
|
-
import { makeTracker } from "./tracker/github.ts";
|
|
75
|
-
import { githubVerbActions } from "./verbs/actions.ts";
|
|
76
|
-
import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
|
|
77
|
-
import { REPORT_KINDS, DEFAULT_REPORT_POLICY, VERB_NAMES } from "./types.ts";
|
|
78
|
-
import type { ProjectConfig, ReportKind } from "./types.ts";
|
|
79
|
-
import { formatUnblock, unblockIssue } from "./unblock.ts";
|
|
80
|
-
import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
|
|
81
|
-
|
|
82
|
-
const FRICTION_FEEDBACK_KINDS = {
|
|
83
|
-
"escalation-digest": "feedback:escalation-should-digest",
|
|
84
|
-
"report-noise": "feedback:report-noise",
|
|
85
|
-
"report-surprise": "feedback:report-surprise",
|
|
86
|
-
} as const;
|
|
87
|
-
|
|
88
|
-
type FrictionFeedbackName = keyof typeof FRICTION_FEEDBACK_KINDS;
|
|
89
|
-
|
|
90
|
-
function packageVersion(): string {
|
|
91
|
-
const parsed = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as {
|
|
92
|
-
version?: unknown;
|
|
93
|
-
};
|
|
94
|
-
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
95
|
-
throw new Error("installed package.json has no version");
|
|
96
|
-
}
|
|
97
|
-
return parsed.version;
|
|
98
|
-
}
|
|
11
|
+
import { findProject, loadConfig } from "./config.ts";
|
|
12
|
+
import { AMEND_AREA_IDS } from "./setup.ts";
|
|
13
|
+
import type { ProjectConfig } from "./types.ts";
|
|
14
|
+
import { armCommand } from "./commands/arm.ts";
|
|
15
|
+
import { boardCommand } from "./commands/board.ts";
|
|
16
|
+
import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
|
|
17
|
+
import { daemonCommand } from "./commands/daemon.ts";
|
|
18
|
+
import { dashboardCommand } from "./commands/dashboard.ts";
|
|
19
|
+
import { decisionCommand } from "./commands/decision.ts";
|
|
20
|
+
import { disarmCommand } from "./commands/disarm.ts";
|
|
21
|
+
import { doctorCommand } from "./commands/doctor.ts";
|
|
22
|
+
import { eventCommand } from "./commands/event.ts";
|
|
23
|
+
import { extendCommand } from "./commands/extend.ts";
|
|
24
|
+
import { frictionCommand } from "./commands/friction.ts";
|
|
25
|
+
import { helpCommand } from "./commands/help.ts";
|
|
26
|
+
import { holdCommand } from "./commands/hold.ts";
|
|
27
|
+
import { intakeCommand } from "./commands/intake.ts";
|
|
28
|
+
import { ledgerCommand } from "./commands/ledger.ts";
|
|
29
|
+
import { messageCommand } from "./commands/message.ts";
|
|
30
|
+
import { reportCommand } from "./commands/report.ts";
|
|
31
|
+
import { restartCommand } from "./commands/restart.ts";
|
|
32
|
+
import { resumeCommand } from "./commands/resume.ts";
|
|
33
|
+
import { setupCommand } from "./commands/setup.ts";
|
|
34
|
+
import { startCommand } from "./commands/start.ts";
|
|
35
|
+
import { statsCommand } from "./commands/stats.ts";
|
|
36
|
+
import { statusCommand } from "./commands/status.ts";
|
|
37
|
+
import { stopCommand } from "./commands/stop.ts";
|
|
38
|
+
import { tailCommand } from "./commands/tail.ts";
|
|
39
|
+
import { unblockCommand } from "./commands/unblock.ts";
|
|
40
|
+
import { upgradeInstallCommand } from "./commands/upgrade-install.ts";
|
|
41
|
+
import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts";
|
|
42
|
+
import { upgradeCommand } from "./commands/upgrade.ts";
|
|
43
|
+
import { verbCommand } from "./commands/verb.ts";
|
|
44
|
+
import { versionCommand } from "./commands/version.ts";
|
|
45
|
+
import { workerCommand } from "./commands/worker.ts";
|
|
46
|
+
import type { CommandContext, CommandHandler } from "./commands/context.ts";
|
|
99
47
|
|
|
100
48
|
const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
|
|
101
49
|
|
|
@@ -106,8 +54,13 @@ usage:
|
|
|
106
54
|
omp-conductor stop [--pane] [--project NAME | --all]
|
|
107
55
|
omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
|
|
108
56
|
omp-conductor upgrade [--to VERSION] [--project NAME]
|
|
57
|
+
omp-conductor upgrade-install --to VERSION [--project NAME]
|
|
58
|
+
omp-conductor upgrade-rollback [--project NAME]
|
|
109
59
|
omp-conductor board [--project NAME] [--json]
|
|
60
|
+
omp-conductor dashboard [--port N] [--host ADDR]
|
|
110
61
|
omp-conductor status [--project NAME]
|
|
62
|
+
omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
|
|
63
|
+
omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
|
|
111
64
|
omp-conductor ledger [--issue N] [--limit N] [--project NAME]
|
|
112
65
|
omp-conductor hold [--keep-ticks] [--project NAME | --all]
|
|
113
66
|
omp-conductor arm [--project NAME | --all]
|
|
@@ -124,12 +77,15 @@ usage:
|
|
|
124
77
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
125
78
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
126
79
|
omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
|
|
127
|
-
omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
|
|
80
|
+
omp-conductor report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]
|
|
128
81
|
omp-conductor message --text TEXT [--project NAME]
|
|
129
82
|
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
130
83
|
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
131
84
|
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
132
85
|
omp-conductor decision list [--project NAME]
|
|
86
|
+
omp-conductor intake "<text>" [--project NAME]
|
|
87
|
+
omp-conductor intake list [--project NAME]
|
|
88
|
+
omp-conductor intake dismiss <id> [--project NAME]
|
|
133
89
|
omp-conductor help
|
|
134
90
|
|
|
135
91
|
setup interview, then write config.json, the labels, the briefs and the
|
|
@@ -141,6 +97,15 @@ usage:
|
|
|
141
97
|
brief as one pinned release. Pauses only new claims, drains live
|
|
142
98
|
workers, reloads, verifies twice, and restores the prior dispatch
|
|
143
99
|
state. Run it from a shell outside the target Herdr session.
|
|
100
|
+
upgrade-install
|
|
101
|
+
the detached executor half of the fleet-installs-itself request:
|
|
102
|
+
the same transaction, journaled per surface, leaving verification
|
|
103
|
+
and dispatch restore to the first tick after the restart. Run by
|
|
104
|
+
the transient unit, never by hand in a Herdr session.
|
|
105
|
+
upgrade-rollback
|
|
106
|
+
the detached rollback unit: restore every surface the failed
|
|
107
|
+
upgrade-install touched, from the durable pre-install snapshot in
|
|
108
|
+
the upgrade journal, and report through the durable outbox.
|
|
144
109
|
start start the installed herdr-fleet.service when present, then run the
|
|
145
110
|
dispatch loop in the background and wait until it answers GET
|
|
146
111
|
/healthz on :8787 (override with --port). Refuses if one is running.
|
|
@@ -166,11 +131,34 @@ usage:
|
|
|
166
131
|
status layered fleet report: dispatch (running|paused|stopped), ticks and
|
|
167
132
|
next due time, pane, herdr, Telegram bot/API health, daemon, caps
|
|
168
133
|
and active runs.
|
|
134
|
+
stats what the fleet accomplished and at what cost, read wholly from the
|
|
135
|
+
local store: issues merged, merge rate, queue-to-merge lead time
|
|
136
|
+
(median/p90), attempts per merged issue, metered spend per merged
|
|
137
|
+
issue, the failure-class breakdown of what did not merge, and
|
|
138
|
+
tracked gh calls over the window. Continuation chains collapse into
|
|
139
|
+
one journey; $0.00 runs are reported as unmetered, never averaged in
|
|
140
|
+
as free. --json prints the stable {project, window, ghCalls, empty,
|
|
141
|
+
total, repos} shape.
|
|
142
|
+
doctor read-only deployment health, one finding per past failure mode:
|
|
143
|
+
gh auth/scopes, exact-case configured labels, systemd unit drift
|
|
144
|
+
and runtime-dir ownership, config + backup freshness, sqlite
|
|
145
|
+
integrity, spend telemetry, reporting timezones, Telegram health.
|
|
146
|
+
The only write anywhere is the self-identified probe message that
|
|
147
|
+
--probe-telegram sends through the report transport. Exit code 0
|
|
148
|
+
only when nothing failed; --json prints the stable CI shape. Run
|
|
149
|
+
it after install and after every upgrade.
|
|
169
150
|
board open the live keyboard-driven fleet board. It renders queue holds,
|
|
170
151
|
every run lifecycle stage, recent merges, spend and health; Enter
|
|
171
152
|
follows a selected transcript without leaving the board. --json (or
|
|
172
153
|
a non-interactive stdin/stdout) prints a one-shot JSON snapshot of
|
|
173
154
|
the same lanes instead.
|
|
155
|
+
dashboard
|
|
156
|
+
serve the fleet dashboard in a browser: the UI shell and GET
|
|
157
|
+
/api/projects, bearer-authenticated with the token minted 0600 at
|
|
158
|
+
<state>/dashboard-token on first start. Binds 127.0.0.1:8788 by
|
|
159
|
+
default; a non-loopback --host still starts but prints a warning
|
|
160
|
+
naming the token file. A separate process — never a route on the
|
|
161
|
+
daemon port.
|
|
174
162
|
ledger every conductor-verb call and how the daemon decided it: the verb,
|
|
175
163
|
the arguments, allow or refuse, the named refusal reason, and the
|
|
176
164
|
resulting sha. Sessions cannot push, open, merge, label or release
|
|
@@ -223,7 +211,12 @@ usage:
|
|
|
223
211
|
be a repeat. --kind digest is accepted at most once per local day,
|
|
224
212
|
decided from the ledger rather than from what you remember sending.
|
|
225
213
|
A digest associates the comma-separated --events and --notices rows
|
|
226
|
-
atomically; omitted rows stay owed.
|
|
214
|
+
atomically; omitted rows stay owed. The other --kind values (tier2,
|
|
215
|
+
decision-needed, fleet-stopped, confirmed-failure) declare the
|
|
216
|
+
report's interrupt category: the reporting policy decides between
|
|
217
|
+
sending now and holding, exactly as for a daemon escalation. An
|
|
218
|
+
identical retry is refused only while the earlier handoff is still
|
|
219
|
+
undelivered, and admitted again once it lands (#453).
|
|
227
220
|
message deliver one direct Telegram message to this project's own chat and
|
|
228
221
|
forum topic, resolved from config rather than from whichever chat
|
|
229
222
|
last wrote to the session. This is how a locally injected tick
|
|
@@ -243,6 +236,11 @@ usage:
|
|
|
243
236
|
decision resolve <id> --answer TEXT
|
|
244
237
|
decision withdraw <id> [--reason TEXT]
|
|
245
238
|
decision list
|
|
239
|
+
intake keep a raw idea durably before it becomes anything: record it now
|
|
240
|
+
with \`omp-conductor intake "<text>"\`, list what is still pending,
|
|
241
|
+
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.
|
|
246
244
|
friction record a bounded observation the daemon cannot classify itself:
|
|
247
245
|
an escalation that belonged in a digest, or a tick report that was
|
|
248
246
|
noise/surprising. Repeated observations feed the existing Learning
|
|
@@ -287,7 +285,7 @@ Stop the conductor:
|
|
|
287
285
|
stop --pane stop + pin conductor-pane recovery off
|
|
288
286
|
resume && arm clear pause and any pane pin, then prove inbound Telegram`;
|
|
289
287
|
|
|
290
|
-
/**
|
|
288
|
+
/** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
|
|
291
289
|
function flag(argv: string[], name: string): string | undefined {
|
|
292
290
|
const i = argv.indexOf(`--${name}`);
|
|
293
291
|
if (i >= 0) return argv[i + 1];
|
|
@@ -305,9 +303,19 @@ function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
|
|
|
305
303
|
return [];
|
|
306
304
|
}
|
|
307
305
|
const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
|
|
306
|
+
// The three shapes the runtime actually mints as ledger row ids: 12-hex
|
|
307
|
+
// material event ids, UUID held-notice ids (quiet-hours material holds,
|
|
308
|
+
// operator messages), and 64-hex sha256 held-notice ids (escalate and the
|
|
309
|
+
// orchestrator tick). Anything else is a typo; refusing it is what keeps a
|
|
310
|
+
// mistyped id from silently claiming nothing.
|
|
308
311
|
if (
|
|
309
312
|
ids.length === 0 ||
|
|
310
|
-
ids.some(
|
|
313
|
+
ids.some(
|
|
314
|
+
(id) =>
|
|
315
|
+
!/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{64})$/.test(
|
|
316
|
+
id,
|
|
317
|
+
),
|
|
318
|
+
)
|
|
311
319
|
) {
|
|
312
320
|
process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
|
|
313
321
|
process.exit(2);
|
|
@@ -352,53 +360,6 @@ function humanDuration(ms: number): string {
|
|
|
352
360
|
return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
|
|
353
361
|
}
|
|
354
362
|
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* The orchestrator half, and the one thing `status` has ever known about the
|
|
358
|
-
* supervising session: the stall marker its heartbeat writes when its own
|
|
359
|
-
* prompts stop being consumed (see {@link STALL_MARKER_FILE}).
|
|
360
|
-
*
|
|
361
|
-
* The marker is written in the *session's* cwd, which this process has no way
|
|
362
|
-
* to discover — so this reads the state directory, on the reference deploy's
|
|
363
|
-
* convention that the orchestrator session runs from exactly there. That makes
|
|
364
|
-
* the reading one-directional: a line printed here is proof of a wedge, and no
|
|
365
|
-
* line is proof of nothing at all. On a fleet whose session lives elsewhere the
|
|
366
|
-
* check is simply inert, which is why it never prints a reassuring "healthy".
|
|
367
|
-
*/
|
|
368
|
-
function stallLine(): string | undefined {
|
|
369
|
-
let raw: string;
|
|
370
|
-
try {
|
|
371
|
-
raw = readFileSync(join(stateDir(), STALL_MARKER_FILE), "utf8").trim();
|
|
372
|
-
} catch {
|
|
373
|
-
return undefined;
|
|
374
|
-
}
|
|
375
|
-
// "<ISO timestamp> <one-line diagnosis>". A file truncated by something else
|
|
376
|
-
// still gets reported: that the marker exists at all is the news.
|
|
377
|
-
const cut = raw.indexOf(" ");
|
|
378
|
-
const since = cut < 0 ? raw : raw.slice(0, cut);
|
|
379
|
-
const diagnosis = cut < 0 ? "" : ` — ${raw.slice(cut + 1)}`;
|
|
380
|
-
return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
|
|
385
|
-
* `status` prints it, so it has to say something true: a foreground daemon
|
|
386
|
-
* opened no log of its own — whoever started it owns its stdout, be that
|
|
387
|
-
* systemd's journal, a terminal, or a pane.
|
|
388
|
-
*/
|
|
389
|
-
const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
|
|
390
|
-
|
|
391
|
-
/** How often `tail` re-stats the transcript it is following. */
|
|
392
|
-
const TAIL_POLL_MS = 1_000;
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* How long the transcript must stay unchanged, after its run has left the live
|
|
396
|
-
* states, before `tail` calls it over. The state flips from the daemon's thread
|
|
397
|
-
* while the harness may still be flushing its last message, so exiting on the
|
|
398
|
-
* state alone truncates the ending an operator ran this command to watch.
|
|
399
|
-
*/
|
|
400
|
-
const TAIL_QUIET_MS = 5_000;
|
|
401
|
-
|
|
402
363
|
/**
|
|
403
364
|
* The `<issue>` positional, for the two verbs that take one. Exits 2 rather
|
|
404
365
|
* than following run #NaN or clearing the labels of issue #0; `verb` is named
|
|
@@ -414,86 +375,6 @@ function issueArg(verb: string, raw: string | undefined): number {
|
|
|
414
375
|
}
|
|
415
376
|
|
|
416
377
|
|
|
417
|
-
/**
|
|
418
|
-
* Follow one run's transcript the way `tail -f` follows a log.
|
|
419
|
-
*
|
|
420
|
-
* Reads from byte zero rather than from the end: attaching to a worker that is
|
|
421
|
-
* already ten turns in and then showing nothing until turn eleven is not
|
|
422
|
-
* watching the run. Polls `stat` instead of taking a file watcher because the
|
|
423
|
-
* transcript is a plain append-only file that may sit on a filesystem where
|
|
424
|
-
* change events are a polite fiction, and one stat a second costs nothing.
|
|
425
|
-
*
|
|
426
|
-
* SIGINT is deliberately left to its default, which is immediate exit. Nothing
|
|
427
|
-
* here is buffered, and a handler could only add a poll interval of latency to
|
|
428
|
-
* every Ctrl-C.
|
|
429
|
-
*/
|
|
430
|
-
async function tailRun(project: string, issue: number): Promise<void> {
|
|
431
|
-
// Read-only in practice: the store is opened WAL with a busy timeout, so this
|
|
432
|
-
// never contends with the daemon writing the same rows.
|
|
433
|
-
const store = openStore(dbPath());
|
|
434
|
-
try {
|
|
435
|
-
const run = store.latestRun(project, issue);
|
|
436
|
-
if (run === undefined) throw new Error(`no run recorded for #${issue}`);
|
|
437
|
-
const path = run.sessionFile;
|
|
438
|
-
// Claimed but not yet started, or an attempt whose session never opened one.
|
|
439
|
-
if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
|
|
440
|
-
|
|
441
|
-
const fd = openSync(path, "r");
|
|
442
|
-
try {
|
|
443
|
-
let offset = 0;
|
|
444
|
-
let pending = Buffer.alloc(0);
|
|
445
|
-
let lastChange = Date.now();
|
|
446
|
-
|
|
447
|
-
for (;;) {
|
|
448
|
-
let size = offset;
|
|
449
|
-
try {
|
|
450
|
-
size = statSync(path).size;
|
|
451
|
-
} catch {
|
|
452
|
-
// A transcript that vanishes mid-follow is not worth crashing over.
|
|
453
|
-
// The run's own state, below, is what decides when this command ends.
|
|
454
|
-
}
|
|
455
|
-
// Shorter than what we have already read means truncated or replaced;
|
|
456
|
-
// resuming from the old offset would read the middle of another file.
|
|
457
|
-
if (size < offset) {
|
|
458
|
-
offset = 0;
|
|
459
|
-
pending = Buffer.alloc(0);
|
|
460
|
-
}
|
|
461
|
-
if (size > offset) {
|
|
462
|
-
const chunk = Buffer.allocUnsafe(size - offset);
|
|
463
|
-
const read = readSync(fd, chunk, 0, chunk.length, offset);
|
|
464
|
-
offset += read;
|
|
465
|
-
// Split on newlines as bytes, not as text: a UTF-8 sequence straddling
|
|
466
|
-
// a read boundary would be mangled by decoding each chunk on its own.
|
|
467
|
-
pending = Buffer.concat([pending, chunk.subarray(0, read)]);
|
|
468
|
-
for (;;) {
|
|
469
|
-
const nl = pending.indexOf(0x0a);
|
|
470
|
-
if (nl < 0) break;
|
|
471
|
-
const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
|
|
472
|
-
pending = pending.subarray(nl + 1);
|
|
473
|
-
if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
|
|
474
|
-
}
|
|
475
|
-
if (read > 0) lastChange = Date.now();
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Re-read this exact run every poll — not `latestRun`, which would jump
|
|
479
|
-
// to a retry started meanwhile and report its state against the wrong
|
|
480
|
-
// transcript. The daemon writes the row from another process, so looking
|
|
481
|
-
// is the only way to notice the run finished.
|
|
482
|
-
const state = store.getRun(run.id)?.state ?? run.state;
|
|
483
|
-
if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
|
|
484
|
-
process.stdout.write(`run ended: ${state}\n`);
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
|
|
488
|
-
}
|
|
489
|
-
} finally {
|
|
490
|
-
closeSync(fd);
|
|
491
|
-
}
|
|
492
|
-
} finally {
|
|
493
|
-
store.close();
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
378
|
const argv = process.argv.slice(2);
|
|
498
379
|
const cmd = argv[0];
|
|
499
380
|
/**
|
|
@@ -503,6 +384,22 @@ const cmd = argv[0];
|
|
|
503
384
|
* `ProjectConfig` under that name.
|
|
504
385
|
*/
|
|
505
386
|
const projectFlag = flag(argv, "project");
|
|
387
|
+
/**
|
|
388
|
+
* The parsed essentials every command receives. Argument parsing lives above
|
|
389
|
+
* (and behind) this object; a command module reads what it needs and never
|
|
390
|
+
* imports this entry point, so no verb depends on another's edits here beyond
|
|
391
|
+
* the table line that names it.
|
|
392
|
+
*/
|
|
393
|
+
const ctx: CommandContext = {
|
|
394
|
+
argv,
|
|
395
|
+
projectFlag,
|
|
396
|
+
flag: (name) => flag(argv, name),
|
|
397
|
+
portFlag: () => portFlag(argv),
|
|
398
|
+
turnsFlag: () => turnsFlag(argv),
|
|
399
|
+
digestIdsFlag: (name) => digestIdsFlag(argv, name),
|
|
400
|
+
issueArg: (verb, raw) => issueArg(verb, raw),
|
|
401
|
+
targetProjects: () => targetProjects(),
|
|
402
|
+
};
|
|
506
403
|
|
|
507
404
|
function targetProjects(): ProjectConfig[] {
|
|
508
405
|
const cfg = loadConfig();
|
|
@@ -515,1177 +412,79 @@ function targetProjects(): ProjectConfig[] {
|
|
|
515
412
|
return [findProject(cfg, projectFlag)];
|
|
516
413
|
}
|
|
517
414
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const ui = terminalUi();
|
|
532
|
-
try {
|
|
533
|
-
// `host` and `graph` are install subcommands, checked BEFORE the amend
|
|
534
|
-
// areas. They are not areas — routing them through the area validation
|
|
535
|
-
// is how `setup host` came to exit 2 as an "unknown setup area".
|
|
536
|
-
if (positional === "host" || positional === "graph") {
|
|
537
|
-
const cfg = loadConfig();
|
|
538
|
-
const project = findProject(cfg, projectFlag);
|
|
539
|
-
const outcome =
|
|
540
|
-
positional === "host"
|
|
541
|
-
? await runHostInstall(project, resolveCaps(project, cfg.defaults), telegramStateDir(), ui)
|
|
542
|
-
: await runGraphInstall(project, ui, {
|
|
543
|
-
noSeed: argv.includes("--no-seed"),
|
|
544
|
-
print: argv.includes("--print"),
|
|
545
|
-
});
|
|
546
|
-
// `staged` is a success on a host that has no systemd: the files are
|
|
547
|
-
// real, only the enable step is impossible.
|
|
548
|
-
if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
|
|
549
|
-
break;
|
|
550
|
-
}
|
|
551
|
-
let area: AmendAreaId | undefined;
|
|
552
|
-
if (positional !== undefined) {
|
|
553
|
-
if (!(AMEND_AREA_IDS as readonly string[]).includes(positional)) {
|
|
554
|
-
process.stderr.write(
|
|
555
|
-
`omp-conductor: unknown setup argument "${positional}" — ` +
|
|
556
|
-
`install subcommands are host, graph; amend areas are ${AMEND_AREA_IDS.join(", ")}\n`,
|
|
557
|
-
);
|
|
558
|
-
process.exit(2);
|
|
559
|
-
}
|
|
560
|
-
area = positional as AmendAreaId;
|
|
561
|
-
}
|
|
562
|
-
// `--no-ai` is the interview with its reading half removed: every question
|
|
563
|
-
// is still asked, nothing is proposed. For a host with no omp peer, a
|
|
564
|
-
// private repo no probe can clone, or an operator who would rather type the
|
|
565
|
-
// gates than review a model's reading of their CI.
|
|
566
|
-
await setup(ui, projectFlag, area, argv.includes("--no-ai") ? NO_PROBES : DEFAULT_PROBES);
|
|
567
|
-
} finally {
|
|
568
|
-
ui.close();
|
|
569
|
-
}
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
case "upgrade": {
|
|
573
|
-
const result = await upgradeConductor({
|
|
574
|
-
version: flag(argv, "to"),
|
|
575
|
-
project: projectFlag,
|
|
576
|
-
});
|
|
577
|
-
process.stdout.write(
|
|
578
|
-
`${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
|
|
579
|
-
` Bun-global CLI omp-conductor@${result.version}\n` +
|
|
580
|
-
` omp plugin omp-conductor@${result.version}\n` +
|
|
581
|
-
` Herdr plugin herdr-conductor@${result.gitHead}\n` +
|
|
582
|
-
` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
|
|
583
|
-
` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
|
|
584
|
-
);
|
|
585
|
-
break;
|
|
586
|
-
}
|
|
587
|
-
case "daemon": {
|
|
588
|
-
// Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
|
|
589
|
-
// pidfile, which left a daemon started in the foreground (which is how
|
|
590
|
-
// systemd runs it) invisible twice over: `omp-conductor status` reported
|
|
591
|
-
// no daemon at all, and a `daemon --once` drill run beside it saw
|
|
592
|
-
// `livingDaemon() === undefined`, concluded nothing else was dispatching,
|
|
593
|
-
// and reconciled the live daemon's in-flight runs as orphans. Writing the
|
|
594
|
-
// record here closes both holes.
|
|
595
|
-
const once = argv.includes("--once");
|
|
596
|
-
const port = portFlag(argv);
|
|
597
|
-
const project = projectFlag;
|
|
598
|
-
|
|
599
|
-
// `--once` registers nothing, on purpose. It is precisely the single-tick
|
|
600
|
-
// drill the orphan guard exists to protect, so a drill that announced
|
|
601
|
-
// itself as the daemon would be the process that misleads the next reader
|
|
602
|
-
// — and would clear the real daemon's record on its way out.
|
|
603
|
-
if (once) {
|
|
604
|
-
await runDaemon({ once, port, project });
|
|
605
|
-
break;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const running = livingDaemon();
|
|
609
|
-
if (running !== undefined && running.pid !== process.pid) {
|
|
610
|
-
process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
|
|
611
|
-
process.exit(1);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// A living record that already names this pid was written by the `start`
|
|
615
|
-
// that spawned us, and it knows the log file our stdout is really going
|
|
616
|
-
// to. Replacing it with a guess would be a downgrade.
|
|
617
|
-
if (running === undefined) {
|
|
618
|
-
writeRecord({
|
|
619
|
-
pid: process.pid,
|
|
620
|
-
port: port ?? DEFAULT_PORT,
|
|
621
|
-
startedAt: Date.now(),
|
|
622
|
-
logFile: FOREGROUND_LOG,
|
|
623
|
-
...(project === undefined ? {} : { project }),
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
try {
|
|
628
|
-
await runDaemon({ once, port, project });
|
|
629
|
-
} finally {
|
|
630
|
-
// The record names a pid that is about to stop existing. Leaving it
|
|
631
|
-
// behind makes the next reader probe a ghost before believing us.
|
|
632
|
-
clearRecord();
|
|
633
|
-
}
|
|
634
|
-
break;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
case "start": {
|
|
638
|
-
const project = projectFlag;
|
|
639
|
-
const herdr = startHerdrFleet(project);
|
|
640
|
-
const rec = await startDaemon({ port: portFlag(argv), project });
|
|
641
|
-
process.stdout.write(
|
|
642
|
-
`started — pid ${rec.pid}, /healthz on :${rec.port}` +
|
|
643
|
-
`${rec.project === undefined ? "" : `, project ${rec.project}`}\n` +
|
|
644
|
-
`herdr ${herdr.kind === "active" ? `active (${herdr.unit})${herdr.recoveryReleased ? "; recovery pin cleared" : ""}` : `unmanaged (${herdr.reason})`}\n` +
|
|
645
|
-
`log ${rec.logFile}\n`,
|
|
646
|
-
);
|
|
647
|
-
break;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// `stop` absorbs the old `halt`: one word for "stop the conductor", instead of
|
|
651
|
-
// an operator holding in their head that `halt` was `hold` plus stopping the
|
|
652
|
-
// daemon while `stop` killed only the process. Bouncing the daemon is
|
|
653
|
-
// `restart`, which is what the removed kill-only `stop` was reached for.
|
|
654
|
-
case "stop": {
|
|
655
|
-
const withPane = argv.includes("--pane");
|
|
656
|
-
const targets = targetProjects().map((project) => {
|
|
657
|
-
// `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
|
|
658
|
-
// `hold` affordance. Assert the invariant instead of printing a path that
|
|
659
|
-
// might not exist.
|
|
660
|
-
const held = hold(project.name, "halt");
|
|
661
|
-
if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
|
|
662
|
-
return {
|
|
663
|
-
project,
|
|
664
|
-
hold: { ...held, disarmed: held.disarmed },
|
|
665
|
-
pin: withPane ? pinPaneHalt(project.name).path : undefined,
|
|
666
|
-
};
|
|
667
|
-
});
|
|
668
|
-
const stop = await stopDaemon();
|
|
669
|
-
const stopLine =
|
|
670
|
-
stop.kind === "not-running"
|
|
671
|
-
? "daemon was not running"
|
|
672
|
-
: `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
|
|
673
|
-
for (const target of targets) {
|
|
674
|
-
if (target.pin !== undefined) {
|
|
675
|
-
const pane = await stopConductorPane(target.project.name);
|
|
676
|
-
process.stdout.write(
|
|
677
|
-
`stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
|
|
678
|
-
`${stopLine}\n` +
|
|
679
|
-
`pane recovery pinned at ${target.pin}\n` +
|
|
680
|
-
`pane stop: ${pane.stopped} — ${pane.detail}\n` +
|
|
681
|
-
` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
|
|
682
|
-
` resume clears the pin when you want recovery again)\n`,
|
|
683
|
-
);
|
|
684
|
-
} else {
|
|
685
|
-
process.stdout.write(
|
|
686
|
-
`stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
|
|
687
|
-
`${stopLine}\n` +
|
|
688
|
-
`pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
case "restart": {
|
|
696
|
-
if (argv.includes("--now")) {
|
|
697
|
-
// Inherit the running daemon's port and project: a restart that quietly
|
|
698
|
-
// moved to the default port would leave every existing health check
|
|
699
|
-
// pointing at nothing. When the unit owns the live pid, restartDaemon
|
|
700
|
-
// goes through systemctl so the replacement stays supervised; a failed
|
|
701
|
-
// installed unit is reset and started through systemd the same way and
|
|
702
|
-
// is never replaced by an unmanaged daemon. Success is only reported
|
|
703
|
-
// after the manager is proven to own the reported pid.
|
|
704
|
-
const { previous, record, via } = await restartDaemon({
|
|
705
|
-
port: portFlag(argv),
|
|
706
|
-
project: projectFlag,
|
|
707
|
-
});
|
|
708
|
-
if (previous !== undefined) {
|
|
709
|
-
process.stdout.write(
|
|
710
|
-
`stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
process.stdout.write(
|
|
714
|
-
`started — pid ${record.pid}, /healthz on :${record.port}` +
|
|
715
|
-
`${record.project === undefined ? "" : `, project ${record.project}`}` +
|
|
716
|
-
`${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
|
|
717
|
-
);
|
|
718
|
-
break;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Default: drain the fleet before restarting — pause new claims, wait for
|
|
722
|
-
// live workers to reach 0/N (bounded by --timeout), restart, then restore
|
|
723
|
-
// the prior dispatch state. A timed-out drain restarts nothing and leaves
|
|
724
|
-
// dispatch paused, so it is safe to run while the daemon is wedged.
|
|
725
|
-
const timeoutRaw = flag(argv, "timeout");
|
|
726
|
-
const timeoutSeconds =
|
|
727
|
-
timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
|
|
728
|
-
const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
|
|
729
|
-
let result!: RestartResult;
|
|
730
|
-
const deps: UpgradeDeps = {
|
|
731
|
-
...DEFAULT_DEPS,
|
|
732
|
-
setPaused: (v, project) =>
|
|
733
|
-
setPaused(v, { source: "restart", reason: "restart, draining" }, project),
|
|
734
|
-
restartDaemon: async () => {
|
|
735
|
-
result = await restartDaemon({
|
|
736
|
-
port: portFlag(argv),
|
|
737
|
-
project: projectFlag,
|
|
738
|
-
});
|
|
739
|
-
},
|
|
740
|
-
};
|
|
741
|
-
try {
|
|
742
|
-
await drainAndRestart(deps, { project: projectFlag, timeoutMs });
|
|
743
|
-
} catch (err) {
|
|
744
|
-
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
745
|
-
process.exit(1);
|
|
746
|
-
}
|
|
747
|
-
if (result.previous !== undefined) {
|
|
748
|
-
process.stdout.write(
|
|
749
|
-
`stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
process.stdout.write(
|
|
753
|
-
`started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
|
|
754
|
-
`${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
|
|
755
|
-
`${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
|
|
756
|
-
);
|
|
757
|
-
break;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
case "status": {
|
|
761
|
-
const project = projectFlag;
|
|
762
|
-
const text = await renderStatus(project);
|
|
763
|
-
const stalled = stallLine();
|
|
764
|
-
process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
|
|
765
|
-
break;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* The action ledger: mediated conductor verbs plus durable per-issue budget
|
|
770
|
-
* changes. Its own command as well as a block in `status`, because the two
|
|
771
|
-
* questions are different sizes. `status` answers "what is pending or being
|
|
772
|
-
* refused now"; this answers "what did run 3 actually try, and what budget
|
|
773
|
-
* did the operator assign", which needs history rather than only live state.
|
|
774
|
-
*/
|
|
775
|
-
case "ledger": {
|
|
776
|
-
const cfg = loadConfig();
|
|
777
|
-
const p = findProject(cfg, projectFlag);
|
|
778
|
-
const issueFlag = flag(argv, "issue");
|
|
779
|
-
const issue = issueFlag === undefined ? undefined : issueArg("ledger", issueFlag);
|
|
780
|
-
const limitFlag = flag(argv, "limit");
|
|
781
|
-
const limit = limitFlag === undefined ? 50 : Number.parseInt(limitFlag, 10);
|
|
782
|
-
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
783
|
-
process.stderr.write(`omp-conductor: ledger --limit needs a positive integer, got "${limitFlag ?? ""}"\n`);
|
|
784
|
-
process.exitCode = 1;
|
|
785
|
-
break;
|
|
786
|
-
}
|
|
787
|
-
// Read-only, WAL, same as `tail`: this never contends with the daemon
|
|
788
|
-
// writing the rows it is printing.
|
|
789
|
-
const store = openStore(dbPath());
|
|
790
|
-
try {
|
|
791
|
-
const entries = store.verbLedger(p.name, {
|
|
792
|
-
...(issue === undefined ? {} : { issue }),
|
|
793
|
-
limit,
|
|
794
|
-
});
|
|
795
|
-
const overrides = store.turnOverrideLedger(p.name, {
|
|
796
|
-
...(issue === undefined ? {} : { issue }),
|
|
797
|
-
limit,
|
|
798
|
-
});
|
|
799
|
-
if (entries.length === 0 && overrides.length === 0) {
|
|
800
|
-
process.stdout.write(
|
|
801
|
-
`no conductor actions recorded for ${p.name}` +
|
|
802
|
-
`${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
|
|
803
|
-
);
|
|
804
|
-
break;
|
|
805
|
-
}
|
|
806
|
-
const blocks: string[] = [];
|
|
807
|
-
if (entries.length > 0) {
|
|
808
|
-
const refused = entries.filter((e) => e.decision === "refused").length;
|
|
809
|
-
blocks.push(
|
|
810
|
-
`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
|
|
811
|
-
entries.flatMap(formatVerbLedgerEntry).join("\n"),
|
|
812
|
-
);
|
|
813
|
-
}
|
|
814
|
-
if (overrides.length > 0) {
|
|
815
|
-
blocks.push(
|
|
816
|
-
`${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
|
|
817
|
-
overrides
|
|
818
|
-
.map(
|
|
819
|
-
(entry) =>
|
|
820
|
-
` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
|
|
821
|
-
`${entry.maxTurns} turns`,
|
|
822
|
-
)
|
|
823
|
-
.join("\n"),
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
process.stdout.write(`${blocks.join("\n\n")}\n`);
|
|
827
|
-
} finally {
|
|
828
|
-
store.close();
|
|
829
|
-
}
|
|
830
|
-
break;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
case "board": {
|
|
834
|
-
const project = projectFlag;
|
|
835
|
-
// Headless one-shot mode: `--json` asks for it explicitly, and a
|
|
836
|
-
// non-interactive stdin or stdout (pipe, redirect, cron, script) cannot
|
|
837
|
-
// host the key-driven board anyway. The TTY throw inside runBoard stays
|
|
838
|
-
// as a backstop for direct callers.
|
|
839
|
-
if (argv.includes("--json") || !process.stdout.isTTY || !process.stdin.isTTY) {
|
|
840
|
-
process.stdout.write(`${boardJson(await boardSnapshotOnce(project))}\n`);
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
await runBoard(project);
|
|
844
|
-
break;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
case "hold": {
|
|
848
|
-
const keepTicks = argv.includes("--keep-ticks");
|
|
849
|
-
for (const project of targetProjects()) {
|
|
850
|
-
const r = hold(project.name, "hold", keepTicks ? { keepTicks: true } : {});
|
|
851
|
-
process.stdout.write(
|
|
852
|
-
`held — claiming paused` +
|
|
853
|
-
`${r.wasPaused ? " (already paused)" : ""}` +
|
|
854
|
-
(r.disarmed === undefined
|
|
855
|
-
? "; ticks left armed — resume restores the fleet with no new arm challenge"
|
|
856
|
-
: `; ticks disarmed at ${r.disarmed.path}` +
|
|
857
|
-
`${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`) +
|
|
858
|
-
`\ndaemon and pane left running; stop to stop the daemon too\n`,
|
|
859
|
-
);
|
|
860
|
-
}
|
|
861
|
-
break;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Removed verbs keep a case rather than falling through to usage: an operator
|
|
865
|
-
// who typed the old word gets the new one, not a wall of help text. Exit 2 is
|
|
866
|
-
// the same code an unknown verb uses.
|
|
867
|
-
case "halt":
|
|
868
|
-
process.stderr.write('omp-conductor: "halt" is now "stop" — see help\n');
|
|
869
|
-
process.exit(2);
|
|
870
|
-
break;
|
|
871
|
-
|
|
872
|
-
case "pause":
|
|
873
|
-
process.stderr.write(
|
|
874
|
-
'omp-conductor: "pause" is gone — "hold" pauses claiming AND disarms ticks; see help\n',
|
|
875
|
-
);
|
|
876
|
-
process.exit(2);
|
|
877
|
-
break;
|
|
878
|
-
|
|
879
|
-
case "release-pane":
|
|
880
|
-
process.stderr.write('omp-conductor: "release-pane" is now part of "resume" — see help\n');
|
|
881
|
-
process.exit(2);
|
|
882
|
-
break;
|
|
883
|
-
|
|
884
|
-
case "graph-setup":
|
|
885
|
-
process.stderr.write('omp-conductor: "graph-setup" is now "setup graph" — see help\n');
|
|
886
|
-
process.exit(2);
|
|
887
|
-
break;
|
|
888
|
-
|
|
889
|
-
case "arm": {
|
|
890
|
-
for (const project of targetProjects()) {
|
|
891
|
-
process.stdout.write("arm: sending inbound Telegram challenge…\n");
|
|
892
|
-
const r = await armTicks(project.name);
|
|
893
|
-
process.stdout.write(
|
|
894
|
-
`ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
|
|
895
|
-
`marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
break;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
case "disarm": {
|
|
902
|
-
for (const project of targetProjects()) {
|
|
903
|
-
const r = disarmTicks(project.name);
|
|
904
|
-
process.stdout.write(
|
|
905
|
-
`disarmed — ticks will be skipped` +
|
|
906
|
-
`${r.wasArmed ? "" : " (was already disarmed)"}\n` +
|
|
907
|
-
`marker ${r.path}\n`,
|
|
908
|
-
);
|
|
909
|
-
}
|
|
910
|
-
break;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
case "tail": {
|
|
914
|
-
const issue = issueArg("tail", argv[1]);
|
|
915
|
-
await tailRun(findProject(loadConfig(), projectFlag).name, issue);
|
|
916
|
-
break;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
case "extend": {
|
|
920
|
-
const issue = issueArg("extend", argv[1]);
|
|
921
|
-
const maxTurns = turnsFlag(argv);
|
|
922
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
923
|
-
const daemon = livingDaemon();
|
|
924
|
-
if (daemon === undefined) throw new Error("daemon is not running");
|
|
925
|
-
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
926
|
-
throw new Error(
|
|
927
|
-
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
const response = await fetch(
|
|
931
|
-
`http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
|
|
932
|
-
{
|
|
933
|
-
method: "PUT",
|
|
934
|
-
headers: { "content-type": "application/json" },
|
|
935
|
-
body: JSON.stringify({ project: project.name, maxTurns }),
|
|
936
|
-
},
|
|
937
|
-
);
|
|
938
|
-
const payload = (await response.json()) as {
|
|
939
|
-
error?: unknown;
|
|
940
|
-
kind?: unknown;
|
|
941
|
-
runId?: unknown;
|
|
942
|
-
maxTurns?: unknown;
|
|
943
|
-
issue?: unknown;
|
|
944
|
-
nextAttemptMaxTurns?: unknown;
|
|
945
|
-
baseMaxTurns?: unknown;
|
|
946
|
-
};
|
|
947
|
-
if (!response.ok) {
|
|
948
|
-
throw new Error(
|
|
949
|
-
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
950
|
-
);
|
|
951
|
-
}
|
|
952
|
-
if (
|
|
953
|
-
payload.kind === "next-attempt" &&
|
|
954
|
-
payload.issue === issue &&
|
|
955
|
-
typeof payload.nextAttemptMaxTurns === "number" &&
|
|
956
|
-
typeof payload.baseMaxTurns === "number"
|
|
957
|
-
) {
|
|
958
|
-
process.stdout.write(
|
|
959
|
-
`#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
|
|
960
|
-
`(project base ${payload.baseMaxTurns})\n`,
|
|
961
|
-
);
|
|
962
|
-
break;
|
|
963
|
-
}
|
|
964
|
-
if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
|
|
965
|
-
throw new Error("daemon returned an invalid turn-extension response");
|
|
966
|
-
}
|
|
967
|
-
process.stdout.write(
|
|
968
|
-
`#${issue} turn ceiling extended to ${payload.maxTurns} (run ${payload.runId})\n`,
|
|
969
|
-
);
|
|
970
|
-
break;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
case "worker": {
|
|
974
|
-
const sub = argv[1];
|
|
975
|
-
if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
|
|
976
|
-
process.stderr.write(
|
|
977
|
-
"omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
|
|
978
|
-
);
|
|
979
|
-
process.exit(2);
|
|
980
|
-
}
|
|
981
|
-
const issue = issueArg("worker", argv[2]);
|
|
982
|
-
const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
|
|
983
|
-
const reason = rawReason?.trim().replace(/\s+/g, " ");
|
|
984
|
-
if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
|
|
985
|
-
process.stderr.write(
|
|
986
|
-
"omp-conductor: worker stop needs --reason with 1-500 characters\n",
|
|
987
|
-
);
|
|
988
|
-
process.exit(2);
|
|
989
|
-
}
|
|
990
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
991
|
-
const daemon = livingDaemon();
|
|
992
|
-
if (daemon === undefined) throw new Error("daemon is not running");
|
|
993
|
-
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
994
|
-
throw new Error(
|
|
995
|
-
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
996
|
-
);
|
|
997
|
-
}
|
|
998
|
-
const response = await fetch(
|
|
999
|
-
`http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
|
|
1000
|
-
{
|
|
1001
|
-
method: "PUT",
|
|
1002
|
-
headers: { "content-type": "application/json" },
|
|
1003
|
-
body: JSON.stringify({
|
|
1004
|
-
project: project.name,
|
|
1005
|
-
...(reason === undefined ? {} : { reason }),
|
|
1006
|
-
}),
|
|
1007
|
-
},
|
|
1008
|
-
);
|
|
1009
|
-
const payload = (await response.json()) as {
|
|
1010
|
-
error?: unknown;
|
|
1011
|
-
runId?: unknown;
|
|
1012
|
-
phase?: unknown;
|
|
1013
|
-
outcome?: unknown;
|
|
1014
|
-
state?: unknown;
|
|
1015
|
-
reason?: unknown;
|
|
1016
|
-
salvageSha?: unknown;
|
|
1017
|
-
salvageError?: unknown;
|
|
1018
|
-
worktree?: unknown;
|
|
1019
|
-
};
|
|
1020
|
-
if (!response.ok) {
|
|
1021
|
-
throw new Error(
|
|
1022
|
-
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
1023
|
-
);
|
|
1024
|
-
}
|
|
1025
|
-
if (sub === "stop") {
|
|
1026
|
-
if (
|
|
1027
|
-
typeof payload.runId !== "string" ||
|
|
1028
|
-
typeof payload.state !== "string" ||
|
|
1029
|
-
(payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
|
|
1030
|
-
) {
|
|
1031
|
-
throw new Error("daemon returned an invalid worker-stop response");
|
|
1032
|
-
}
|
|
1033
|
-
if (payload.outcome === "already-terminal") {
|
|
1034
|
-
process.stdout.write(
|
|
1035
|
-
`#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
|
|
1036
|
-
);
|
|
1037
|
-
break;
|
|
1038
|
-
}
|
|
1039
|
-
if (typeof payload.reason !== "string") {
|
|
1040
|
-
throw new Error("daemon returned an invalid worker-stop response");
|
|
1041
|
-
}
|
|
1042
|
-
process.stdout.write(
|
|
1043
|
-
`#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
|
|
1044
|
-
);
|
|
1045
|
-
if (typeof payload.salvageSha === "string") {
|
|
1046
|
-
process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
|
|
1047
|
-
}
|
|
1048
|
-
if (typeof payload.salvageError === "string") {
|
|
1049
|
-
process.stdout.write(
|
|
1050
|
-
`WIP SALVAGE FAILED: ${payload.salvageError}\n` +
|
|
1051
|
-
`worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
1054
|
-
break;
|
|
1055
|
-
}
|
|
1056
|
-
if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
|
|
1057
|
-
throw new Error("daemon returned an invalid worker-control response");
|
|
1058
|
-
}
|
|
1059
|
-
process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
|
|
1060
|
-
break;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
case "unblock": {
|
|
1064
|
-
const issue = issueArg("unblock", argv[1]);
|
|
1065
|
-
const cfg = loadConfig();
|
|
1066
|
-
const project = findProject(cfg, projectFlag);
|
|
1067
|
-
const store = openStore(dbPath());
|
|
1068
|
-
try {
|
|
1069
|
-
const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
|
|
1070
|
-
force: argv.includes("--force"),
|
|
1071
|
-
requeue: !argv.includes("--no-requeue"),
|
|
1072
|
-
});
|
|
1073
|
-
process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
|
|
1074
|
-
// A refusal must not read as success to a script or a board keypress.
|
|
1075
|
-
if (outcome.refused !== undefined) process.exitCode = 3;
|
|
1076
|
-
} finally {
|
|
1077
|
-
store.close();
|
|
1078
|
-
}
|
|
1079
|
-
break;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
/**
|
|
1083
|
-
* The external-orchestrator half of the verbs (#167): run any
|
|
1084
|
-
* `conductor_*` verb from the CLI, through the daemon's own checks and
|
|
1085
|
-
* ledger. An orchestrator that merges, labels or releases through this
|
|
1086
|
-
* gets exactly the same refusals a session would and writes the same
|
|
1087
|
-
* ledger rows — the raw `gh pr merge` a session reaches for instead is
|
|
1088
|
-
* invisible to both.
|
|
1089
|
-
*
|
|
1090
|
-
* Worker-only verbs (conductor_push, conductor_pr_create) refuse with
|
|
1091
|
-
* `role-not-allowed` — correct, and deliberately not special-cased here.
|
|
1092
|
-
*
|
|
1093
|
-
* Accepted limitation: the daemon's in-process one-merge-per-project slot
|
|
1094
|
-
* does not span the daemon and a concurrent CLI merge. The execution-time
|
|
1095
|
-
* head re-read (`--match-head-commit` in prMergeVerb) is the cross-process
|
|
1096
|
-
* guard, so a CLI merge races a daemon merge exactly as two daemon merges
|
|
1097
|
-
* would.
|
|
1098
|
-
*/
|
|
1099
|
-
case "verb": {
|
|
1100
|
-
const name = argv[1];
|
|
1101
|
-
if (name === undefined || !VERB_NAMES.some((n) => n === name)) {
|
|
1102
|
-
process.stderr.write(
|
|
1103
|
-
`omp-conductor: unknown verb "${name ?? ""}". Known verbs: ${VERB_NAMES.join(", ")}\n`,
|
|
1104
|
-
);
|
|
1105
|
-
process.exit(2);
|
|
1106
|
-
}
|
|
1107
|
-
const cfg = loadConfig();
|
|
1108
|
-
const project = findProject(cfg, projectFlag);
|
|
1109
|
-
const args: Record<string, string> = {};
|
|
1110
|
-
for (let i = 2; i < argv.length; i++) {
|
|
1111
|
-
const token = argv[i];
|
|
1112
|
-
if (token === "--arg") {
|
|
1113
|
-
const pair = argv[i + 1];
|
|
1114
|
-
if (pair === undefined || !pair.includes("=")) {
|
|
1115
|
-
process.stderr.write(`omp-conductor: verb --arg needs k=v, got "${pair ?? ""}"\n`);
|
|
1116
|
-
process.exit(2);
|
|
1117
|
-
}
|
|
1118
|
-
const eq = pair.indexOf("=");
|
|
1119
|
-
args[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
1120
|
-
i++;
|
|
1121
|
-
} else if (token === "--project" || token?.startsWith("--project=") === true) {
|
|
1122
|
-
// Consumed by `projectFlag` above; both spellings skip here.
|
|
1123
|
-
if (token === "--project") i++;
|
|
1124
|
-
} else {
|
|
1125
|
-
process.stderr.write(
|
|
1126
|
-
`omp-conductor: verb: unexpected argument "${token ?? ""}" (pass verb arguments as --arg k=v)\n`,
|
|
1127
|
-
);
|
|
1128
|
-
process.exit(2);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
const store = openStore(dbPath());
|
|
1132
|
-
try {
|
|
1133
|
-
const channel: VerbChannel = {
|
|
1134
|
-
kind: "orchestrator",
|
|
1135
|
-
path: "cli",
|
|
1136
|
-
project: project.name,
|
|
1137
|
-
role: "orchestrator",
|
|
1138
|
-
};
|
|
1139
|
-
const reply = await handleVerbCall(
|
|
1140
|
-
{
|
|
1141
|
-
project: () => findProject(loadConfig(), project.name),
|
|
1142
|
-
store,
|
|
1143
|
-
tracker: makeTracker(project),
|
|
1144
|
-
actions: githubVerbActions(project),
|
|
1145
|
-
fleetStop: () =>
|
|
1146
|
-
isPaused(project.name)
|
|
1147
|
-
? "claiming is paused for this fleet (omp-conductor hold or stop)"
|
|
1148
|
-
: undefined,
|
|
1149
|
-
pausedAt: () => pausedAt(project.name),
|
|
1150
|
-
log: (m) => process.stderr.write(`${m}\n`),
|
|
1151
|
-
now: () => Date.now(),
|
|
1152
|
-
chain: { readBaseChain },
|
|
1153
|
-
},
|
|
1154
|
-
channel,
|
|
1155
|
-
{ verb: name, args },
|
|
1156
|
-
);
|
|
1157
|
-
process.stdout.write(`${reply.text}\n`);
|
|
1158
|
-
// A refusal must not read as success to a script calling this.
|
|
1159
|
-
if (!reply.ok) process.exitCode = 3;
|
|
1160
|
-
} finally {
|
|
1161
|
-
store.close();
|
|
1162
|
-
}
|
|
1163
|
-
break;
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
case "event": {
|
|
1167
|
-
if (argv[1] !== "record") {
|
|
1168
|
-
process.stderr.write("omp-conductor: event needs the record subcommand\n");
|
|
1169
|
-
process.exit(2);
|
|
1170
|
-
}
|
|
1171
|
-
const category = flag(argv, "category");
|
|
1172
|
-
const summary = flag(argv, "summary")?.replace(/\s+/g, " ").trim();
|
|
1173
|
-
const evidence = flag(argv, "evidence")?.replace(/\s+/g, " ").trim();
|
|
1174
|
-
if (
|
|
1175
|
-
category === undefined ||
|
|
1176
|
-
!/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
|
|
1177
|
-
summary === undefined ||
|
|
1178
|
-
summary.length === 0 ||
|
|
1179
|
-
summary.length > 240 ||
|
|
1180
|
-
summary.startsWith("--") ||
|
|
1181
|
-
evidence === undefined ||
|
|
1182
|
-
evidence.length === 0 ||
|
|
1183
|
-
evidence.length > 500 ||
|
|
1184
|
-
evidence.startsWith("--")
|
|
1185
|
-
) {
|
|
1186
|
-
process.stderr.write(
|
|
1187
|
-
"omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
|
|
1188
|
-
);
|
|
1189
|
-
process.exit(2);
|
|
1190
|
-
}
|
|
1191
|
-
const rawOccurredAt = flag(argv, "occurred-at");
|
|
1192
|
-
const recordedAt = Date.now();
|
|
1193
|
-
const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
|
|
1194
|
-
if (!Number.isFinite(occurredAt)) {
|
|
1195
|
-
process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
|
|
1196
|
-
process.exit(2);
|
|
1197
|
-
}
|
|
1198
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
1199
|
-
const store = openStore(dbPath());
|
|
1200
|
-
try {
|
|
1201
|
-
const event = store.recordMaterialEvent({
|
|
1202
|
-
project: project.name,
|
|
1203
|
-
category,
|
|
1204
|
-
summary,
|
|
1205
|
-
evidence,
|
|
1206
|
-
occurredAt,
|
|
1207
|
-
recordedAt,
|
|
1208
|
-
});
|
|
1209
|
-
process.stdout.write(
|
|
1210
|
-
`event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
|
|
1211
|
-
);
|
|
1212
|
-
} finally {
|
|
1213
|
-
store.close();
|
|
1214
|
-
}
|
|
1215
|
-
break;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
/**
|
|
1219
|
-
* The handover point. Authorship stays with the model; from here the daemon
|
|
1220
|
-
* owns delivery, so "I sent the report" stops being a claim the model makes
|
|
1221
|
-
* about a tool call it may never have run and becomes a row anyone can read
|
|
1222
|
-
* back out of the store (#123).
|
|
1223
|
-
*/
|
|
1224
|
-
case "report": {
|
|
1225
|
-
const rawText = flag(argv, "text");
|
|
1226
|
-
const body = rawText?.trim();
|
|
1227
|
-
if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
|
|
1228
|
-
process.stderr.write("omp-conductor: report needs --text with the rendered report\n");
|
|
1229
|
-
process.exit(2);
|
|
1230
|
-
}
|
|
1231
|
-
const rawKind = flag(argv, "kind") ?? "material";
|
|
1232
|
-
// Fail closed on an unknown kind rather than defaulting to `material`: a
|
|
1233
|
-
// typo'd `--kind diggest` that silently became a material report would
|
|
1234
|
-
// bypass the daily-digest guard, which is the one thing the kind is for.
|
|
1235
|
-
if (!(REPORT_KINDS as readonly string[]).includes(rawKind)) {
|
|
1236
|
-
process.stderr.write(
|
|
1237
|
-
`omp-conductor: report --kind must be one of: ${REPORT_KINDS.join(", ")}\n`,
|
|
1238
|
-
);
|
|
1239
|
-
process.exit(2);
|
|
1240
|
-
}
|
|
1241
|
-
const kind = rawKind as ReportKind;
|
|
1242
|
-
if (kind !== "digest" && (argv.includes("--events") || argv.includes("--notices"))) {
|
|
1243
|
-
process.stderr.write(
|
|
1244
|
-
"omp-conductor: report --events and --notices are valid only with --kind digest\n",
|
|
1245
|
-
);
|
|
1246
|
-
process.exit(2);
|
|
1247
|
-
}
|
|
1248
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
1249
|
-
const store = openStore(dbPath());
|
|
1250
|
-
try {
|
|
1251
|
-
const at = Date.now();
|
|
1252
|
-
// #229: the policy decides what may interrupt the phone. A kind the
|
|
1253
|
-
// policy defers is refused here rather than silently turning into a
|
|
1254
|
-
// page, or a digest going out off-schedule.
|
|
1255
|
-
const policy = project.reporting;
|
|
1256
|
-
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
1257
|
-
if (kind === "digest") {
|
|
1258
|
-
if (digestPolicy.cadence === "none") {
|
|
1259
|
-
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
1260
|
-
process.exit(2);
|
|
1261
|
-
}
|
|
1262
|
-
if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
|
|
1263
|
-
const lastKey = store.lastDigestDedupeKey(project.name);
|
|
1264
|
-
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
1265
|
-
if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
|
|
1266
|
-
process.stderr.write(
|
|
1267
|
-
`omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
|
|
1268
|
-
`${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
|
|
1269
|
-
);
|
|
1270
|
-
process.exit(2);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
if (kind === "material") {
|
|
1275
|
-
const disposition = interruptDisposition(policy, "material", at);
|
|
1276
|
-
if (disposition === "digest") {
|
|
1277
|
-
process.stderr.write(
|
|
1278
|
-
"omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
|
|
1279
|
-
);
|
|
1280
|
-
process.exit(2);
|
|
1281
|
-
}
|
|
1282
|
-
if (disposition === "availability") {
|
|
1283
|
-
const noticeId = randomUUID();
|
|
1284
|
-
store.addHeldNotice({
|
|
1285
|
-
id: noticeId,
|
|
1286
|
-
project: project.name,
|
|
1287
|
-
category: "material",
|
|
1288
|
-
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
1289
|
-
detail: body,
|
|
1290
|
-
createdAt: at,
|
|
1291
|
-
releaseOnAvailable: true,
|
|
1292
|
-
});
|
|
1293
|
-
process.stdout.write(
|
|
1294
|
-
`held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
|
|
1295
|
-
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
1296
|
-
);
|
|
1297
|
-
break;
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
const draft = {
|
|
1301
|
-
project: project.name,
|
|
1302
|
-
kind,
|
|
1303
|
-
body,
|
|
1304
|
-
// Only a daily digest is at-most-once. Per-tick digests and material
|
|
1305
|
-
// reports describe new outcomes each time, so they carry no daily key.
|
|
1306
|
-
...(kind === "digest" && digestPolicy.cadence === "daily"
|
|
1307
|
-
? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
|
|
1308
|
-
: {}),
|
|
1309
|
-
at,
|
|
1310
|
-
};
|
|
1311
|
-
const { report, deduped } =
|
|
1312
|
-
kind === "digest"
|
|
1313
|
-
? store.enqueueDigestReport(
|
|
1314
|
-
draft,
|
|
1315
|
-
digestIdsFlag(argv, "events"),
|
|
1316
|
-
digestIdsFlag(argv, "notices"),
|
|
1317
|
-
)
|
|
1318
|
-
: store.enqueueReport(draft);
|
|
1319
|
-
process.stdout.write(
|
|
1320
|
-
deduped
|
|
1321
|
-
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|
|
1322
|
-
"the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
|
|
1323
|
-
: `report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
1324
|
-
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
1325
|
-
);
|
|
1326
|
-
} finally {
|
|
1327
|
-
store.close();
|
|
1328
|
-
}
|
|
1329
|
-
break;
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/**
|
|
1333
|
-
* One direct message to the operator, on the project's own Telegram target.
|
|
1334
|
-
*
|
|
1335
|
-
* `telegram_send` inherits the chat of the last inbound message, and its
|
|
1336
|
-
* `thread_id` defaults to the active topic *only* while `chat_id` is
|
|
1337
|
-
* omitted — so a locally injected tick, which has no inbound message to
|
|
1338
|
-
* inherit from, had no way to reach a project's forum topic and its answers
|
|
1339
|
-
* landed in the main chat (#366). This resolves chat and topic from the
|
|
1340
|
-
* project config instead of from session memory, and applies the same
|
|
1341
|
-
* availability decision the autonomous tick gate applies, so the CLI is not
|
|
1342
|
-
* a way around the operator's own interrupt policy.
|
|
1343
|
-
*/
|
|
1344
|
-
case "message": {
|
|
1345
|
-
const rawText = flag(argv, "text");
|
|
1346
|
-
const body = rawText?.trim();
|
|
1347
|
-
if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
|
|
1348
|
-
process.stderr.write("omp-conductor: message needs --text with the message to deliver\n");
|
|
1349
|
-
process.exit(2);
|
|
1350
|
-
}
|
|
1351
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
1352
|
-
const store = openStore(dbPath());
|
|
1353
|
-
let outcome: OperatorMessageOutcome;
|
|
1354
|
-
try {
|
|
1355
|
-
outcome = await deliverOperatorMessage(project, body, {
|
|
1356
|
-
store,
|
|
1357
|
-
at: Date.now(),
|
|
1358
|
-
noticeId: randomUUID(),
|
|
1359
|
-
});
|
|
1360
|
-
} catch (err) {
|
|
1361
|
-
process.stderr.write(
|
|
1362
|
-
`omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
1363
|
-
);
|
|
1364
|
-
process.exit(2);
|
|
1365
|
-
} finally {
|
|
1366
|
-
store.close();
|
|
1367
|
-
}
|
|
1368
|
-
process.stdout.write(
|
|
1369
|
-
outcome.kind === "sent"
|
|
1370
|
-
? // Not "into the topic": a stale topic degrades to the flat chat with
|
|
1371
|
-
// its own warning on stderr, and this line must not contradict it.
|
|
1372
|
-
`message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
|
|
1373
|
-
: `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
1374
|
-
`${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
|
|
1375
|
-
"nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
|
|
1376
|
-
);
|
|
1377
|
-
break;
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
case "decision": {
|
|
1381
|
-
const sub = argv[1];
|
|
1382
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
1383
|
-
const store = openStore(dbPath());
|
|
1384
|
-
try {
|
|
1385
|
-
if (sub === "open") {
|
|
1386
|
-
const question = flag(argv, "question")?.trim();
|
|
1387
|
-
if (question === undefined || question.length === 0 || question.startsWith("--")) {
|
|
1388
|
-
process.stderr.write(
|
|
1389
|
-
"omp-conductor: decision open needs --question with the question you sent\n",
|
|
1390
|
-
);
|
|
1391
|
-
process.exit(2);
|
|
1392
|
-
}
|
|
1393
|
-
const condition = flag(argv, "resolves-when")?.trim();
|
|
1394
|
-
if (condition !== undefined && parseCondition(condition) === undefined) {
|
|
1395
|
-
process.stderr.write(
|
|
1396
|
-
`omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
|
|
1397
|
-
);
|
|
1398
|
-
process.exit(2);
|
|
1399
|
-
}
|
|
1400
|
-
const blocks = flag(argv, "blocks")?.trim();
|
|
1401
|
-
const decision = store.createDecision({
|
|
1402
|
-
project: project.name,
|
|
1403
|
-
question,
|
|
1404
|
-
...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
|
|
1405
|
-
...(condition === undefined ? {} : { condition }),
|
|
1406
|
-
at: Date.now(),
|
|
1407
|
-
});
|
|
1408
|
-
process.stdout.write(
|
|
1409
|
-
`decision ${decision.id} recorded — resolve with: omp-conductor decision resolve ${decision.id} --answer "..."\n`,
|
|
1410
|
-
);
|
|
1411
|
-
break;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
if (sub === "resolve" || sub === "withdraw") {
|
|
1415
|
-
const id = argv[2];
|
|
1416
|
-
if (id === undefined || id.startsWith("--")) {
|
|
1417
|
-
process.stderr.write(`omp-conductor: decision ${sub} needs the decision id\n`);
|
|
1418
|
-
process.exit(2);
|
|
1419
|
-
}
|
|
1420
|
-
const answered = sub === "resolve";
|
|
1421
|
-
const text = answered ? flag(argv, "answer")?.trim() : flag(argv, "reason")?.trim();
|
|
1422
|
-
if (answered && (text === undefined || text.length === 0)) {
|
|
1423
|
-
process.stderr.write("omp-conductor: decision resolve needs --answer with what was decided\n");
|
|
1424
|
-
process.exit(2);
|
|
1425
|
-
}
|
|
1426
|
-
const ok = store.resolveDecision(
|
|
1427
|
-
id,
|
|
1428
|
-
answered ? "answered" : "withdrawn",
|
|
1429
|
-
text === undefined || text.length === 0 ? "withdrawn" : text,
|
|
1430
|
-
Date.now(),
|
|
1431
|
-
);
|
|
1432
|
-
if (!ok) {
|
|
1433
|
-
// A decision that is not open is a different mistake from an id that
|
|
1434
|
-
// never existed, and the operator can only act on one of them.
|
|
1435
|
-
process.stderr.write(
|
|
1436
|
-
`omp-conductor: no open decision ${id} for ${project.name} — it was answered, withdrawn or expired, or the id is wrong\n`,
|
|
1437
|
-
);
|
|
1438
|
-
process.exit(1);
|
|
1439
|
-
}
|
|
1440
|
-
process.stdout.write(`decision ${id} ${answered ? "answered" : "withdrawn"}\n`);
|
|
1441
|
-
break;
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
if (sub === "list" || sub === undefined) {
|
|
1445
|
-
const open = store.openDecisions(project.name);
|
|
1446
|
-
if (open.length === 0) {
|
|
1447
|
-
process.stdout.write("no open decisions\n");
|
|
1448
|
-
break;
|
|
1449
|
-
}
|
|
1450
|
-
const now = Date.now();
|
|
1451
|
-
for (const d of open) {
|
|
1452
|
-
const condition =
|
|
1453
|
-
d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
|
|
1454
|
-
const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
|
|
1455
|
-
process.stdout.write(
|
|
1456
|
-
`${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
|
|
1457
|
-
);
|
|
1458
|
-
}
|
|
1459
|
-
break;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
process.stderr.write(
|
|
1463
|
-
`omp-conductor: unknown decision subcommand "${sub}" — expected open, resolve, withdraw or list\n`,
|
|
1464
|
-
);
|
|
1465
|
-
process.exit(2);
|
|
1466
|
-
} finally {
|
|
1467
|
-
store.close();
|
|
1468
|
-
}
|
|
1469
|
-
break;
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
case "friction": {
|
|
1473
|
-
const name = argv[1] as FrictionFeedbackName | undefined;
|
|
1474
|
-
if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
|
|
1475
|
-
process.stderr.write(
|
|
1476
|
-
"omp-conductor: friction needs one of: escalation-digest, report-noise, report-surprise\n",
|
|
1477
|
-
);
|
|
1478
|
-
process.exit(2);
|
|
1479
|
-
}
|
|
1480
|
-
const rawDetail = flag(argv, "detail");
|
|
1481
|
-
const detail = rawDetail?.replace(/\s+/g, " ").trim();
|
|
1482
|
-
if (
|
|
1483
|
-
detail === undefined ||
|
|
1484
|
-
detail.length === 0 ||
|
|
1485
|
-
detail.length > 160 ||
|
|
1486
|
-
rawDetail?.startsWith("--") === true
|
|
1487
|
-
) {
|
|
1488
|
-
process.stderr.write("omp-conductor: friction needs --detail with 1-160 characters\n");
|
|
1489
|
-
process.exit(2);
|
|
1490
|
-
}
|
|
1491
|
-
const issueText = flag(argv, "issue");
|
|
1492
|
-
const issue = issueText === undefined ? undefined : issueArg("friction --issue", issueText);
|
|
1493
|
-
const project = findProject(loadConfig(), projectFlag);
|
|
1494
|
-
const store = openStore(dbPath());
|
|
1495
|
-
try {
|
|
1496
|
-
store.recordFriction(project.name, {
|
|
1497
|
-
kind: FRICTION_FEEDBACK_KINDS[name],
|
|
1498
|
-
occurrences: 1,
|
|
1499
|
-
...(issue === undefined ? {} : { issue }),
|
|
1500
|
-
sample: detail,
|
|
1501
|
-
at: Date.now(),
|
|
1502
|
-
});
|
|
1503
|
-
} finally {
|
|
1504
|
-
store.close();
|
|
1505
|
-
}
|
|
1506
|
-
process.stdout.write(`friction recorded for ${project.name}: ${name} — ${detail}\n`);
|
|
1507
|
-
break;
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
case "resume": {
|
|
1511
|
-
for (const project of targetProjects()) {
|
|
1512
|
-
releaseHold(project.name);
|
|
1513
|
-
const pin = clearPaneHaltIfResolvable(project.name);
|
|
1514
|
-
process.stdout.write(
|
|
1515
|
-
"resumed — claiming allowed on the next tick\n" +
|
|
1516
|
-
(pin.wasHalted
|
|
1517
|
-
? `pane recovery pin cleared — ${pin.path}\n`
|
|
1518
|
-
: "no pane recovery pin to clear\n") +
|
|
1519
|
-
"ticks stay disarmed — run `omp-conductor arm`\n",
|
|
1520
|
-
);
|
|
1521
|
-
}
|
|
1522
|
-
break;
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
case "brief-upgrade": {
|
|
1526
|
-
const override = flag(argv, "file");
|
|
1527
|
-
let project: ProjectConfig | undefined;
|
|
1528
|
-
let path: string;
|
|
1529
|
-
if (override === undefined) {
|
|
1530
|
-
project = findProject(loadConfig(), projectFlag);
|
|
1531
|
-
path = briefPathForProject(project);
|
|
1532
|
-
} else {
|
|
1533
|
-
path = override;
|
|
1534
|
-
try {
|
|
1535
|
-
project = findProject(loadConfig(), projectFlag);
|
|
1536
|
-
} catch {
|
|
1537
|
-
project = undefined;
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
const workspaceRoot = project?.workspaceRoot ?? dirname(path);
|
|
1542
|
-
const rendered =
|
|
1543
|
-
project === undefined ? shippedBriefTemplate() : renderBriefForProject(project);
|
|
1544
|
-
const floor = project === undefined ? shippedBriefTemplate() : renderFloorForProject(project);
|
|
1545
|
-
const layout = inspectBriefLayout(workspaceRoot, rendered);
|
|
1546
|
-
|
|
1547
|
-
if (argv.includes("--retrofit")) {
|
|
1548
|
-
let live: string;
|
|
1549
|
-
try {
|
|
1550
|
-
live = readFileSync(path, "utf8");
|
|
1551
|
-
} catch {
|
|
1552
|
-
process.stderr.write(`omp-conductor: no brief at ${path}.\n`);
|
|
1553
|
-
process.exit(1);
|
|
1554
|
-
}
|
|
1555
|
-
const result = proposeRetrofit(live);
|
|
1556
|
-
if (result.kind === "no-cut") {
|
|
1557
|
-
process.stdout.write(
|
|
1558
|
-
`brief ${path}\n\nNo Releases / Project context / Reporting / Amendments heading found — cannot classify a cut.\n`,
|
|
1559
|
-
);
|
|
1560
|
-
process.exit(1);
|
|
1561
|
-
}
|
|
1562
|
-
if (result.kind === "interleaved") {
|
|
1563
|
-
process.stdout.write(`${formatRetrofitRefusal(path, result)}\n`);
|
|
1564
|
-
process.exit(1);
|
|
1565
|
-
}
|
|
1566
|
-
process.stdout.write(`${formatRetrofitProposal(path, result.proposal)}\n`);
|
|
1567
|
-
if (argv.includes("--apply")) {
|
|
1568
|
-
const backup = applyRetrofit(path, result.proposal);
|
|
1569
|
-
process.stdout.write(`\napplied retrofit — previous brief kept at ${backup}\n`);
|
|
1570
|
-
}
|
|
1571
|
-
break;
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
if (argv.includes("--migrate")) {
|
|
1575
|
-
if (layout.kind === "overlay") {
|
|
1576
|
-
if (!argv.includes("--apply")) {
|
|
1577
|
-
process.stdout.write(
|
|
1578
|
-
`${formatBriefReport(path, layout, [])}\n\n` +
|
|
1579
|
-
"POLICY.md already present. --migrate --apply will strip any leading\n" +
|
|
1580
|
-
"banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
|
|
1581
|
-
);
|
|
1582
|
-
break;
|
|
1583
|
-
}
|
|
1584
|
-
if (project === undefined) {
|
|
1585
|
-
process.stderr.write(
|
|
1586
|
-
"omp-conductor: repairing an overlay needs --project (or a config) so the floor renders.\n",
|
|
1587
|
-
);
|
|
1588
|
-
process.exit(1);
|
|
1589
|
-
}
|
|
1590
|
-
const repaired = repairPolicyBannerCrumbs({
|
|
1591
|
-
orchestratorPath: layout.orchestratorPath,
|
|
1592
|
-
policyPath: layout.policyPath,
|
|
1593
|
-
floor: renderFloorForProject(project),
|
|
1594
|
-
});
|
|
1595
|
-
if (repaired === undefined) {
|
|
1596
|
-
process.stdout.write(
|
|
1597
|
-
`${formatBriefReport(path, layout, [])}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
|
|
1598
|
-
);
|
|
1599
|
-
} else {
|
|
1600
|
-
process.stdout.write(`${formatMigrateResult(repaired)}\n`);
|
|
1601
|
-
}
|
|
1602
|
-
break;
|
|
1603
|
-
}
|
|
1604
|
-
if (layout.kind === "missing") {
|
|
1605
|
-
process.stderr.write(`omp-conductor: no brief at ${path} to migrate.\n`);
|
|
1606
|
-
process.exit(1);
|
|
1607
|
-
}
|
|
1608
|
-
if (layout.kind === "legacy-handwritten") {
|
|
1609
|
-
process.stdout.write(`${formatBriefReport(path, layout, layout.missing)}\n`);
|
|
1610
|
-
process.exit(1);
|
|
1611
|
-
}
|
|
1612
|
-
if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
|
|
1613
|
-
process.stderr.write(
|
|
1614
|
-
"omp-conductor: --migrate needs --project (or a config) so the floor renders without {{PLACEHOLDER}}s.\n",
|
|
1615
|
-
);
|
|
1616
|
-
process.exit(1);
|
|
1617
|
-
}
|
|
1618
|
-
if (!argv.includes("--apply")) {
|
|
1619
|
-
process.stdout.write(
|
|
1620
|
-
[
|
|
1621
|
-
`migrate ${layout.orchestratorPath}`,
|
|
1622
|
-
"",
|
|
1623
|
-
"Would write POLICY.md from the owned half below YOURS TO EDIT,",
|
|
1624
|
-
"then recompose ORCHESTRATOR.md from the package floor + that policy.",
|
|
1625
|
-
"",
|
|
1626
|
-
"Apply: omp-conductor brief-upgrade --migrate --apply",
|
|
1627
|
-
].join("\n") + "\n",
|
|
1628
|
-
);
|
|
1629
|
-
break;
|
|
1630
|
-
}
|
|
1631
|
-
const policyPath = project ? policyPathForProject(project) : join(workspaceRoot, "POLICY.md");
|
|
1632
|
-
const result = migrateToPolicy({
|
|
1633
|
-
orchestratorPath: layout.orchestratorPath,
|
|
1634
|
-
policyPath,
|
|
1635
|
-
floor: project ? renderFloorForProject(project) : floor,
|
|
1636
|
-
owned: layout.owned,
|
|
1637
|
-
});
|
|
1638
|
-
process.stdout.write(`${formatMigrateResult(result)}\n`);
|
|
1639
|
-
break;
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
// No `--migrate` / `--retrofit`: report the layout and what to run. The
|
|
1643
|
-
// legacy single-file merge that used to live here is gone (#131) — a bare
|
|
1644
|
-
// `--apply` now says so rather than silently doing nothing.
|
|
1645
|
-
if (argv.includes("--apply")) {
|
|
1646
|
-
process.stderr.write(
|
|
1647
|
-
"brief-upgrade: the legacy single-file merge was removed in 0.4.3 — use --migrate --apply (bannered) or --retrofit --apply (hand-written)\n",
|
|
1648
|
-
);
|
|
1649
|
-
process.exit(2);
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
let missing: string[] = [];
|
|
1653
|
-
if (layout.kind !== "overlay" && layout.kind !== "missing") {
|
|
1654
|
-
let live: string;
|
|
1655
|
-
try {
|
|
1656
|
-
live = readFileSync(path, "utf8");
|
|
1657
|
-
} catch {
|
|
1658
|
-
process.stderr.write(
|
|
1659
|
-
`omp-conductor: no brief at ${path}` +
|
|
1660
|
-
`${override === undefined ? " — run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md." : "."}\n`,
|
|
1661
|
-
);
|
|
1662
|
-
process.exit(1);
|
|
1663
|
-
}
|
|
1664
|
-
missing = missingSections(live, rendered);
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
process.stdout.write(`${formatBriefReport(path, layout, missing)}\n`);
|
|
1668
|
-
if (layout.kind !== "overlay" && project === undefined) {
|
|
1669
|
-
process.stdout.write(
|
|
1670
|
-
"\nnote: no conductor config resolved on this host, so the template's\n" +
|
|
1671
|
-
"{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
|
|
1672
|
-
);
|
|
1673
|
-
}
|
|
1674
|
-
break;
|
|
1675
|
-
}
|
|
415
|
+
/**
|
|
416
|
+
* Removed verbs keep an entry rather than falling through to usage: an operator
|
|
417
|
+
* who typed the old word gets the new one, not a wall of help text. Exit 2 is
|
|
418
|
+
* the same code an unknown verb uses. Each is a two-line pointer with its own
|
|
419
|
+
* fixed text and nothing to grow, so they stay as entries here instead of a
|
|
420
|
+
* file per alias — the #462 acceptance carve-out for two-line cases.
|
|
421
|
+
*/
|
|
422
|
+
const REMOVED: Record<string, string> = {
|
|
423
|
+
halt: '"halt" is now "stop" — see help',
|
|
424
|
+
pause: '"pause" is gone — "hold" pauses claiming AND disarms ticks; see help',
|
|
425
|
+
"release-pane": '"release-pane" is now part of "resume" — see help',
|
|
426
|
+
"graph-setup": '"graph-setup" is now "setup graph" — see help',
|
|
427
|
+
};
|
|
1676
428
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
429
|
+
/**
|
|
430
|
+
* The registration table — verb → handler. Adding an operator subcommand is one
|
|
431
|
+
* module in ./commands/ plus one line here; nothing else about dispatch
|
|
432
|
+
* changes. Version and help are tiny enough that their handlers are single
|
|
433
|
+
* statements, but they still ride the table so every surface is nameable.
|
|
434
|
+
*/
|
|
435
|
+
const COMMANDS: Record<string, CommandHandler> = {
|
|
436
|
+
"--version": () => versionCommand(),
|
|
437
|
+
"-V": () => versionCommand(),
|
|
438
|
+
version: () => versionCommand(),
|
|
439
|
+
setup: () => setupCommand(ctx),
|
|
440
|
+
upgrade: () => upgradeCommand(ctx),
|
|
441
|
+
"upgrade-install": () => upgradeInstallCommand(ctx),
|
|
442
|
+
"upgrade-rollback": () => upgradeRollbackCommand(ctx),
|
|
443
|
+
daemon: () => daemonCommand(ctx),
|
|
444
|
+
start: () => startCommand(ctx),
|
|
445
|
+
stop: () => stopCommand(ctx),
|
|
446
|
+
restart: () => restartCommand(ctx),
|
|
447
|
+
status: () => statusCommand(ctx),
|
|
448
|
+
stats: () => statsCommand(ctx),
|
|
449
|
+
doctor: () => doctorCommand(ctx),
|
|
450
|
+
ledger: () => ledgerCommand(ctx),
|
|
451
|
+
board: () => boardCommand(ctx),
|
|
452
|
+
dashboard: () => dashboardCommand(ctx),
|
|
453
|
+
hold: () => holdCommand(ctx),
|
|
454
|
+
arm: () => armCommand(ctx),
|
|
455
|
+
disarm: () => disarmCommand(ctx),
|
|
456
|
+
tail: () => tailCommand(ctx),
|
|
457
|
+
extend: () => extendCommand(ctx),
|
|
458
|
+
worker: () => workerCommand(ctx),
|
|
459
|
+
unblock: () => unblockCommand(ctx),
|
|
460
|
+
verb: () => verbCommand(ctx),
|
|
461
|
+
event: () => eventCommand(ctx),
|
|
462
|
+
report: () => reportCommand(ctx),
|
|
463
|
+
message: () => messageCommand(ctx),
|
|
464
|
+
decision: () => decisionCommand(ctx),
|
|
465
|
+
intake: () => intakeCommand(ctx),
|
|
466
|
+
friction: () => frictionCommand(ctx),
|
|
467
|
+
resume: () => resumeCommand(ctx),
|
|
468
|
+
"brief-upgrade": () => briefUpgradeCommand(ctx),
|
|
469
|
+
help: () => helpCommand(USAGE),
|
|
470
|
+
"--help": () => helpCommand(USAGE),
|
|
471
|
+
"-h": () => helpCommand(USAGE),
|
|
472
|
+
};
|
|
1682
473
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
474
|
+
try {
|
|
475
|
+
const removed = cmd === undefined ? undefined : REMOVED[cmd];
|
|
476
|
+
if (removed !== undefined) {
|
|
477
|
+
process.stderr.write(`omp-conductor: ${removed}\n`);
|
|
478
|
+
process.exit(2);
|
|
479
|
+
}
|
|
480
|
+
const handler = cmd === undefined ? undefined : COMMANDS[cmd];
|
|
481
|
+
if (handler === undefined) {
|
|
482
|
+
process.stderr.write(
|
|
483
|
+
`${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
|
|
484
|
+
);
|
|
485
|
+
process.exit(2);
|
|
1688
486
|
}
|
|
487
|
+
await handler(ctx);
|
|
1689
488
|
} catch (err) {
|
|
1690
489
|
// Config, lifecycle and `gh` errors are written to be read by a human, so
|
|
1691
490
|
// surface the message rather than a stack.
|