omp-conductor 0.15.10 → 0.15.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -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 +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- 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 +150 -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 +100 -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 +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -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 +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
package/src/cli.ts
CHANGED
|
@@ -1,101 +1,54 @@
|
|
|
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 {
|
|
11
|
+
import { 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";
|
|
12
46
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
inspectBriefLayout,
|
|
19
|
-
migrateToPolicy,
|
|
20
|
-
missingSections,
|
|
21
|
-
proposeRetrofit,
|
|
22
|
-
repairPolicyBannerCrumbs,
|
|
23
|
-
} from "./brief-upgrade.ts";
|
|
24
|
-
import { interruptDisposition } from "./availability.ts";
|
|
25
|
-
import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
26
|
-
import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
|
|
27
|
-
import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
|
|
28
|
-
import {
|
|
29
|
-
armTicks,
|
|
30
|
-
clearPaneHaltIfResolvable,
|
|
31
|
-
disarmTicks,
|
|
32
|
-
hold,
|
|
33
|
-
pinPaneHalt,
|
|
34
|
-
releaseHold,
|
|
35
|
-
renderStatus,
|
|
36
|
-
startHerdrFleet,
|
|
37
|
-
stopConductorPane,
|
|
38
|
-
telegramStateDir,
|
|
39
|
-
} from "./fleet.ts";
|
|
40
|
-
import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
|
|
41
|
-
import { readBaseChain } from "./gitops.ts";
|
|
42
|
-
import {
|
|
43
|
-
clearRecord,
|
|
44
|
-
DEFAULT_PORT,
|
|
45
|
-
livingDaemon,
|
|
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
|
-
}
|
|
47
|
+
COMMAND_SCOPES,
|
|
48
|
+
resolveProjectsByScope,
|
|
49
|
+
type CommandContext,
|
|
50
|
+
type CommandHandler,
|
|
51
|
+
} from "./commands/context.ts";
|
|
99
52
|
|
|
100
53
|
const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
|
|
101
54
|
|
|
@@ -106,8 +59,13 @@ usage:
|
|
|
106
59
|
omp-conductor stop [--pane] [--project NAME | --all]
|
|
107
60
|
omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
|
|
108
61
|
omp-conductor upgrade [--to VERSION] [--project NAME]
|
|
62
|
+
omp-conductor upgrade-install --to VERSION [--project NAME]
|
|
63
|
+
omp-conductor upgrade-rollback
|
|
109
64
|
omp-conductor board [--project NAME] [--json]
|
|
65
|
+
omp-conductor dashboard [--port N] [--host ADDR]
|
|
110
66
|
omp-conductor status [--project NAME]
|
|
67
|
+
omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
|
|
68
|
+
omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
|
|
111
69
|
omp-conductor ledger [--issue N] [--limit N] [--project NAME]
|
|
112
70
|
omp-conductor hold [--keep-ticks] [--project NAME | --all]
|
|
113
71
|
omp-conductor arm [--project NAME | --all]
|
|
@@ -124,12 +82,16 @@ usage:
|
|
|
124
82
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
125
83
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
126
84
|
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]
|
|
128
|
-
omp-conductor message --text TEXT [--project NAME]
|
|
85
|
+
omp-conductor report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]
|
|
86
|
+
omp-conductor message --text TEXT [--category CATEGORY] [--blocks TEXT] [--project NAME]
|
|
129
87
|
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
130
88
|
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
131
89
|
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
132
90
|
omp-conductor decision list [--project NAME]
|
|
91
|
+
omp-conductor intake "<text>" [--project NAME]
|
|
92
|
+
omp-conductor intake list [--project NAME]
|
|
93
|
+
omp-conductor intake dismiss <id> [--project NAME]
|
|
94
|
+
omp-conductor intake groomed <id> --issue <url> [--project NAME]
|
|
133
95
|
omp-conductor help
|
|
134
96
|
|
|
135
97
|
setup interview, then write config.json, the labels, the briefs and the
|
|
@@ -141,13 +103,23 @@ usage:
|
|
|
141
103
|
brief as one pinned release. Pauses only new claims, drains live
|
|
142
104
|
workers, reloads, verifies twice, and restores the prior dispatch
|
|
143
105
|
state. Run it from a shell outside the target Herdr session.
|
|
106
|
+
upgrade-install
|
|
107
|
+
the detached executor half of the fleet-installs-itself request:
|
|
108
|
+
the same transaction, journaled per surface, leaving verification
|
|
109
|
+
and dispatch restore to the first tick after the restart. Run by
|
|
110
|
+
the transient unit, never by hand in a Herdr session.
|
|
111
|
+
upgrade-rollback
|
|
112
|
+
the detached rollback unit: restore every surface the failed
|
|
113
|
+
upgrade-install touched, from the durable pre-install snapshot in
|
|
114
|
+
the upgrade journal, and report through the durable outbox.
|
|
144
115
|
start start the installed herdr-fleet.service when present, then run the
|
|
145
116
|
dispatch loop in the background and wait until it answers GET
|
|
146
117
|
/healthz on :8787 (override with --port). Refuses if one is running.
|
|
147
118
|
stop stop the conductor: pause claiming, disarm ticks, then stop the
|
|
148
|
-
dispatch daemon (systemctl-aware
|
|
149
|
-
it
|
|
150
|
-
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.
|
|
151
123
|
stop --pane
|
|
152
124
|
also stop the conductor agent's pane and pin herdr-conductor recovery
|
|
153
125
|
off for that agent only — it does NOT stop herdr-fleet.service or any
|
|
@@ -166,11 +138,34 @@ usage:
|
|
|
166
138
|
status layered fleet report: dispatch (running|paused|stopped), ticks and
|
|
167
139
|
next due time, pane, herdr, Telegram bot/API health, daemon, caps
|
|
168
140
|
and active runs.
|
|
141
|
+
stats what the fleet accomplished and at what cost, read wholly from the
|
|
142
|
+
local store: issues merged, merge rate, queue-to-merge lead time
|
|
143
|
+
(median/p90), attempts per merged issue, metered spend per merged
|
|
144
|
+
issue, the failure-class breakdown of what did not merge, and
|
|
145
|
+
tracked gh calls over the window. Continuation chains collapse into
|
|
146
|
+
one journey; $0.00 runs are reported as unmetered, never averaged in
|
|
147
|
+
as free. --json prints the stable {project, window, ghCalls, empty,
|
|
148
|
+
total, repos} shape.
|
|
149
|
+
doctor read-only deployment health, one finding per past failure mode:
|
|
150
|
+
gh auth/scopes, exact-case configured labels, systemd unit drift
|
|
151
|
+
and runtime-dir ownership, config + backup freshness, sqlite
|
|
152
|
+
integrity, spend telemetry, reporting timezones, Telegram health.
|
|
153
|
+
The only write anywhere is the self-identified probe message that
|
|
154
|
+
--probe-telegram sends through the report transport. Exit code 0
|
|
155
|
+
only when nothing failed; --json prints the stable CI shape. Run
|
|
156
|
+
it after install and after every upgrade.
|
|
169
157
|
board open the live keyboard-driven fleet board. It renders queue holds,
|
|
170
158
|
every run lifecycle stage, recent merges, spend and health; Enter
|
|
171
159
|
follows a selected transcript without leaving the board. --json (or
|
|
172
160
|
a non-interactive stdin/stdout) prints a one-shot JSON snapshot of
|
|
173
161
|
the same lanes instead.
|
|
162
|
+
dashboard
|
|
163
|
+
serve the fleet dashboard in a browser: the UI shell and GET
|
|
164
|
+
/api/projects, bearer-authenticated with the token minted 0600 at
|
|
165
|
+
<state>/dashboard-token on first start. Binds 127.0.0.1:8788 by
|
|
166
|
+
default; a non-loopback --host still starts but prints a warning
|
|
167
|
+
naming the token file. A separate process — never a route on the
|
|
168
|
+
daemon port.
|
|
174
169
|
ledger every conductor-verb call and how the daemon decided it: the verb,
|
|
175
170
|
the arguments, allow or refuse, the named refusal reason, and the
|
|
176
171
|
resulting sha. Sessions cannot push, open, merge, label or release
|
|
@@ -223,15 +218,25 @@ usage:
|
|
|
223
218
|
be a repeat. --kind digest is accepted at most once per local day,
|
|
224
219
|
decided from the ledger rather than from what you remember sending.
|
|
225
220
|
A digest associates the comma-separated --events and --notices rows
|
|
226
|
-
atomically; omitted rows stay owed.
|
|
221
|
+
atomically; omitted rows stay owed. The other --kind values (tier2,
|
|
222
|
+
decision-needed, fleet-stopped, confirmed-failure) declare the
|
|
223
|
+
report's interrupt category: the reporting policy decides between
|
|
224
|
+
sending now and holding, exactly as for a daemon escalation. An
|
|
225
|
+
identical retry is refused only while the earlier handoff is still
|
|
226
|
+
undelivered, and admitted again once it lands (#453).
|
|
227
227
|
message deliver one direct Telegram message to this project's own chat and
|
|
228
228
|
forum topic, resolved from config rather than from whichever chat
|
|
229
229
|
last wrote to the session. This is how a locally injected tick
|
|
230
230
|
answers or asks something directly: telegram_send keeps the active
|
|
231
231
|
topic only while it names no chat, and a tick has no active topic to
|
|
232
|
-
keep.
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
235
240
|
decision record, list and close the questions you have put to your operator.
|
|
236
241
|
A question that lives only in a session's context is lost to the next
|
|
237
242
|
compaction, so \`decision open\` writes it down and every tick's prompt
|
|
@@ -243,6 +248,12 @@ usage:
|
|
|
243
248
|
decision resolve <id> --answer TEXT
|
|
244
249
|
decision withdraw <id> [--reason TEXT]
|
|
245
250
|
decision list
|
|
251
|
+
intake keep a raw idea durably before it becomes anything: record it now
|
|
252
|
+
with \`omp-conductor intake "<text>"\`, list what is still pending,
|
|
253
|
+
dismiss what turned out to be nothing. Backed by the sqlite store,
|
|
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>\`.
|
|
246
257
|
friction record a bounded observation the daemon cannot classify itself:
|
|
247
258
|
an escalation that belonged in a digest, or a tick report that was
|
|
248
259
|
noise/surprising. Repeated observations feed the existing Learning
|
|
@@ -251,8 +262,11 @@ usage:
|
|
|
251
262
|
and exits. This is what \`start\` launches.
|
|
252
263
|
resume clear pause and any pane-recovery pin. Does NOT re-arm: run arm after
|
|
253
264
|
an inbound Telegram proof to bring ticks back. Use --all for every project.
|
|
254
|
-
setup host
|
|
265
|
+
setup host [NAME]
|
|
255
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.
|
|
256
270
|
setup graph
|
|
257
271
|
set up the code-graph indexes workers query instead of grepping, end
|
|
258
272
|
to end: check prerequisites, clone any missing index-only clone as
|
|
@@ -287,7 +301,7 @@ Stop the conductor:
|
|
|
287
301
|
stop --pane stop + pin conductor-pane recovery off
|
|
288
302
|
resume && arm clear pause and any pane pin, then prove inbound Telegram`;
|
|
289
303
|
|
|
290
|
-
/**
|
|
304
|
+
/** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
|
|
291
305
|
function flag(argv: string[], name: string): string | undefined {
|
|
292
306
|
const i = argv.indexOf(`--${name}`);
|
|
293
307
|
if (i >= 0) return argv[i + 1];
|
|
@@ -305,9 +319,19 @@ function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
|
|
|
305
319
|
return [];
|
|
306
320
|
}
|
|
307
321
|
const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
|
|
322
|
+
// The three shapes the runtime actually mints as ledger row ids: 12-hex
|
|
323
|
+
// material event ids, UUID held-notice ids (quiet-hours material holds,
|
|
324
|
+
// operator messages), and 64-hex sha256 held-notice ids (escalate and the
|
|
325
|
+
// orchestrator tick). Anything else is a typo; refusing it is what keeps a
|
|
326
|
+
// mistyped id from silently claiming nothing.
|
|
308
327
|
if (
|
|
309
328
|
ids.length === 0 ||
|
|
310
|
-
ids.some(
|
|
329
|
+
ids.some(
|
|
330
|
+
(id) =>
|
|
331
|
+
!/^(?:[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(
|
|
332
|
+
id,
|
|
333
|
+
),
|
|
334
|
+
)
|
|
311
335
|
) {
|
|
312
336
|
process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
|
|
313
337
|
process.exit(2);
|
|
@@ -352,53 +376,6 @@ function humanDuration(ms: number): string {
|
|
|
352
376
|
return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
|
|
353
377
|
}
|
|
354
378
|
|
|
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
379
|
/**
|
|
403
380
|
* The `<issue>` positional, for the two verbs that take one. Exits 2 rather
|
|
404
381
|
* than following run #NaN or clearing the labels of issue #0; `verb` is named
|
|
@@ -414,86 +391,6 @@ function issueArg(verb: string, raw: string | undefined): number {
|
|
|
414
391
|
}
|
|
415
392
|
|
|
416
393
|
|
|
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
394
|
const argv = process.argv.slice(2);
|
|
498
395
|
const cmd = argv[0];
|
|
499
396
|
/**
|
|
@@ -503,1189 +400,111 @@ const cmd = argv[0];
|
|
|
503
400
|
* `ProjectConfig` under that name.
|
|
504
401
|
*/
|
|
505
402
|
const projectFlag = flag(argv, "project");
|
|
403
|
+
/**
|
|
404
|
+
* The parsed essentials every command receives. Argument parsing lives above
|
|
405
|
+
* (and behind) this object; a command module reads what it needs and never
|
|
406
|
+
* imports this entry point, so no verb depends on another's edits here beyond
|
|
407
|
+
* the table line that names it.
|
|
408
|
+
*/
|
|
409
|
+
const ctx: CommandContext = {
|
|
410
|
+
argv,
|
|
411
|
+
projectFlag,
|
|
412
|
+
flag: (name) => flag(argv, name),
|
|
413
|
+
portFlag: () => portFlag(argv),
|
|
414
|
+
turnsFlag: () => turnsFlag(argv),
|
|
415
|
+
digestIdsFlag: (name) => digestIdsFlag(argv, name),
|
|
416
|
+
issueArg: (verb, raw) => issueArg(verb, raw),
|
|
417
|
+
targetProjects: () => targetProjects(),
|
|
418
|
+
};
|
|
506
419
|
|
|
507
420
|
function targetProjects(): ProjectConfig[] {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
`config has ${cfg.projects.length} projects; use --project NAME or --all`,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
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"));
|
|
516
425
|
}
|
|
517
426
|
|
|
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
|
-
}
|
|
427
|
+
/**
|
|
428
|
+
* Removed verbs keep an entry rather than falling through to usage: an operator
|
|
429
|
+
* who typed the old word gets the new one, not a wall of help text. Exit 2 is
|
|
430
|
+
* the same code an unknown verb uses. Each is a two-line pointer with its own
|
|
431
|
+
* fixed text and nothing to grow, so they stay as entries here instead of a
|
|
432
|
+
* file per alias — the #462 acceptance carve-out for two-line cases.
|
|
433
|
+
*/
|
|
434
|
+
const REMOVED: Record<string, string> = {
|
|
435
|
+
halt: '"halt" is now "stop" — see help',
|
|
436
|
+
pause: '"pause" is gone — "hold" pauses claiming AND disarms ticks; see help',
|
|
437
|
+
"release-pane": '"release-pane" is now part of "resume" — see help',
|
|
438
|
+
"graph-setup": '"graph-setup" is now "setup graph" — see help',
|
|
439
|
+
};
|
|
1676
440
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
441
|
+
/**
|
|
442
|
+
* The registration table — verb → handler. Adding an operator subcommand is one
|
|
443
|
+
* module in ./commands/ plus one line here; nothing else about dispatch
|
|
444
|
+
* changes. Version and help are tiny enough that their handlers are single
|
|
445
|
+
* statements, but they still ride the table so every surface is nameable.
|
|
446
|
+
*/
|
|
447
|
+
const COMMANDS: Record<string, CommandHandler> = {
|
|
448
|
+
"--version": () => versionCommand(),
|
|
449
|
+
"-V": () => versionCommand(),
|
|
450
|
+
version: () => versionCommand(),
|
|
451
|
+
setup: () => setupCommand(ctx),
|
|
452
|
+
upgrade: () => upgradeCommand(ctx),
|
|
453
|
+
"upgrade-install": () => upgradeInstallCommand(ctx),
|
|
454
|
+
"upgrade-rollback": () => upgradeRollbackCommand(ctx),
|
|
455
|
+
daemon: () => daemonCommand(ctx),
|
|
456
|
+
start: () => startCommand(ctx),
|
|
457
|
+
stop: () => stopCommand(ctx),
|
|
458
|
+
restart: () => restartCommand(ctx),
|
|
459
|
+
status: () => statusCommand(ctx),
|
|
460
|
+
stats: () => statsCommand(ctx),
|
|
461
|
+
doctor: () => doctorCommand(ctx),
|
|
462
|
+
ledger: () => ledgerCommand(ctx),
|
|
463
|
+
board: () => boardCommand(ctx),
|
|
464
|
+
dashboard: () => dashboardCommand(ctx),
|
|
465
|
+
hold: () => holdCommand(ctx),
|
|
466
|
+
arm: () => armCommand(ctx),
|
|
467
|
+
disarm: () => disarmCommand(ctx),
|
|
468
|
+
tail: () => tailCommand(ctx),
|
|
469
|
+
extend: () => extendCommand(ctx),
|
|
470
|
+
worker: () => workerCommand(ctx),
|
|
471
|
+
unblock: () => unblockCommand(ctx),
|
|
472
|
+
verb: () => verbCommand(ctx),
|
|
473
|
+
event: () => eventCommand(ctx),
|
|
474
|
+
report: () => reportCommand(ctx),
|
|
475
|
+
message: () => messageCommand(ctx),
|
|
476
|
+
decision: () => decisionCommand(ctx),
|
|
477
|
+
intake: () => intakeCommand(ctx),
|
|
478
|
+
friction: () => frictionCommand(ctx),
|
|
479
|
+
resume: () => resumeCommand(ctx),
|
|
480
|
+
"brief-upgrade": () => briefUpgradeCommand(ctx),
|
|
481
|
+
help: () => helpCommand(USAGE),
|
|
482
|
+
"--help": () => helpCommand(USAGE),
|
|
483
|
+
"-h": () => helpCommand(USAGE),
|
|
484
|
+
};
|
|
1682
485
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
486
|
+
try {
|
|
487
|
+
const removed = cmd === undefined ? undefined : REMOVED[cmd];
|
|
488
|
+
if (removed !== undefined) {
|
|
489
|
+
process.stderr.write(`omp-conductor: ${removed}\n`);
|
|
490
|
+
process.exit(2);
|
|
491
|
+
}
|
|
492
|
+
const handler = cmd === undefined ? undefined : COMMANDS[cmd];
|
|
493
|
+
if (handler === undefined) {
|
|
494
|
+
process.stderr.write(
|
|
495
|
+
`${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
|
|
496
|
+
);
|
|
497
|
+
process.exit(2);
|
|
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);
|
|
1688
506
|
}
|
|
507
|
+
await handler(ctx);
|
|
1689
508
|
} catch (err) {
|
|
1690
509
|
// Config, lifecycle and `gh` errors are written to be read by a human, so
|
|
1691
510
|
// surface the message rather than a stack.
|