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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the reply appears in the orchestrator transcript.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { armTicks } from "../fleet.ts";
11
+
12
+ export async function armCommand(ctx: CommandContext): Promise<void> {
13
+ for (const project of ctx.targetProjects()) {
14
+ process.stdout.write("arm: sending inbound Telegram challenge…\n");
15
+ const r = await armTicks(project.name);
16
+ process.stdout.write(
17
+ `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
18
+ `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
19
+ );
20
+ }
21
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `board` — the live keyboard-driven fleet board, or a one-shot --json snapshot of the same lanes.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { boardJson, boardSnapshotOnce, runBoard } from "../board.ts";
11
+
12
+ export async function boardCommand(ctx: CommandContext): Promise<void> {
13
+ const project = ctx.projectFlag;
14
+ // Headless one-shot mode: `--json` asks for it explicitly, and a
15
+ // non-interactive stdin or stdout (pipe, redirect, cron, script) cannot
16
+ // host the key-driven board anyway. The TTY throw inside runBoard stays
17
+ // as a backstop for direct callers.
18
+ if (ctx.argv.includes("--json") || !process.stdout.isTTY || !process.stdin.isTTY) {
19
+ process.stdout.write(`${boardJson(await boardSnapshotOnce(project))}\n`);
20
+ return;
21
+ }
22
+ await runBoard(project);
23
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `briefUpgrade` — inspect the brief overlay (package floor + POLICY.md) and run the `--migrate` / `--retrofit` paths.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import {
13
+ applyRetrofit,
14
+ formatBriefReport,
15
+ formatMigrateResult,
16
+ formatRetrofitProposal,
17
+ formatRetrofitRefusal,
18
+ inspectBriefLayout,
19
+ migrateToPolicy,
20
+ missingSections,
21
+ proposeRetrofit,
22
+ repairPolicyBannerCrumbs,
23
+ sharedPolicyPath,
24
+ } from "../brief-upgrade.ts";
25
+ import { findProject, loadConfig } from "../config.ts";
26
+ import {
27
+ briefPathForProject,
28
+ policyPathForProject,
29
+ renderBriefForProject,
30
+ renderFloorForProject,
31
+ shippedBriefTemplate,
32
+ } from "../setup.ts";
33
+ import type { ProjectConfig } from "../types.ts";
34
+
35
+ export async function briefUpgradeCommand(ctx: CommandContext): Promise<void> {
36
+ const override = ctx.flag("file");
37
+ let project: ProjectConfig | undefined;
38
+ let path: string;
39
+ if (override === undefined) {
40
+ project = findProject(loadConfig(), ctx.projectFlag);
41
+ path = briefPathForProject(project);
42
+ } else {
43
+ path = override;
44
+ try {
45
+ project = findProject(loadConfig(), ctx.projectFlag);
46
+ } catch {
47
+ project = undefined;
48
+ }
49
+ }
50
+
51
+ const workspaceRoot = project?.workspaceRoot ?? dirname(path);
52
+ const rendered =
53
+ project === undefined ? shippedBriefTemplate() : renderBriefForProject(project);
54
+ const floor = project === undefined ? shippedBriefTemplate() : renderFloorForProject(project);
55
+ const layout = inspectBriefLayout(workspaceRoot, rendered);
56
+ // Host-wide shared policy, reported as a layer when present (#363).
57
+ const sharedBrief = existsSync(sharedPolicyPath()) ? sharedPolicyPath() : undefined;
58
+
59
+ if (ctx.argv.includes("--retrofit")) {
60
+ let live: string;
61
+ try {
62
+ live = readFileSync(path, "utf8");
63
+ } catch {
64
+ process.stderr.write(`omp-conductor: no brief at ${path}.\n`);
65
+ process.exit(1);
66
+ }
67
+ const result = proposeRetrofit(live);
68
+ if (result.kind === "no-cut") {
69
+ process.stdout.write(
70
+ `brief ${path}\n\nNo Releases / Project context / Reporting / Amendments heading found — cannot classify a cut.\n`,
71
+ );
72
+ process.exit(1);
73
+ }
74
+ if (result.kind === "interleaved") {
75
+ process.stdout.write(`${formatRetrofitRefusal(path, result)}\n`);
76
+ process.exit(1);
77
+ }
78
+ process.stdout.write(`${formatRetrofitProposal(path, result.proposal)}\n`);
79
+ if (ctx.argv.includes("--apply")) {
80
+ const backup = applyRetrofit(path, result.proposal);
81
+ process.stdout.write(`\napplied retrofit — previous brief kept at ${backup}\n`);
82
+ }
83
+ return;
84
+ }
85
+
86
+ if (ctx.argv.includes("--migrate")) {
87
+ if (layout.kind === "overlay") {
88
+ if (!ctx.argv.includes("--apply")) {
89
+ process.stdout.write(
90
+ `${formatBriefReport(path, layout, [], sharedBrief)}\n\n` +
91
+ "POLICY.md already present. --migrate --apply will strip any leading\n" +
92
+ "banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
93
+ );
94
+ return;
95
+ }
96
+ if (project === undefined) {
97
+ process.stderr.write(
98
+ "omp-conductor: repairing an overlay needs --project (or a config) so the floor renders.\n",
99
+ );
100
+ process.exit(1);
101
+ }
102
+ const repaired = repairPolicyBannerCrumbs({
103
+ orchestratorPath: layout.orchestratorPath,
104
+ policyPath: layout.policyPath,
105
+ floor: renderFloorForProject(project),
106
+ });
107
+ if (repaired === undefined) {
108
+ process.stdout.write(
109
+ `${formatBriefReport(path, layout, [], sharedBrief)}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
110
+ );
111
+ } else {
112
+ process.stdout.write(`${formatMigrateResult(repaired)}\n`);
113
+ }
114
+ return;
115
+ }
116
+ if (layout.kind === "missing") {
117
+ process.stderr.write(`omp-conductor: no brief at ${path} to migrate.\n`);
118
+ process.exit(1);
119
+ }
120
+ if (layout.kind === "legacy-handwritten") {
121
+ process.stdout.write(`${formatBriefReport(path, layout, layout.missing, sharedBrief)}\n`);
122
+ process.exit(1);
123
+ }
124
+ if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
125
+ process.stderr.write(
126
+ "omp-conductor: --migrate needs --project (or a config) so the floor renders without {{PLACEHOLDER}}s.\n",
127
+ );
128
+ process.exit(1);
129
+ }
130
+ if (!ctx.argv.includes("--apply")) {
131
+ process.stdout.write(
132
+ [
133
+ `migrate ${layout.orchestratorPath}`,
134
+ "",
135
+ "Would write POLICY.md from the owned half below YOURS TO EDIT,",
136
+ "then recompose ORCHESTRATOR.md from the package floor + that policy.",
137
+ "",
138
+ "Apply: omp-conductor brief-upgrade --migrate --apply",
139
+ ].join("\n") + "\n",
140
+ );
141
+ return;
142
+ }
143
+ const policyPath = project ? policyPathForProject(project) : join(workspaceRoot, "POLICY.md");
144
+ const result = migrateToPolicy({
145
+ orchestratorPath: layout.orchestratorPath,
146
+ policyPath,
147
+ floor: project ? renderFloorForProject(project) : floor,
148
+ owned: layout.owned,
149
+ });
150
+ process.stdout.write(`${formatMigrateResult(result)}\n`);
151
+ return;
152
+ }
153
+
154
+ // No `--migrate` / `--retrofit`: report the layout and what to run. The
155
+ // legacy single-file merge that used to live here is gone (#131) — a bare
156
+ // `--apply` now says so rather than silently doing nothing.
157
+ if (ctx.argv.includes("--apply")) {
158
+ process.stderr.write(
159
+ "brief-upgrade: the legacy single-file merge was removed in 0.4.3 — use --migrate --apply (bannered) or --retrofit --apply (hand-written)\n",
160
+ );
161
+ process.exit(2);
162
+ }
163
+
164
+ let missing: string[] = [];
165
+ if (layout.kind !== "overlay" && layout.kind !== "missing") {
166
+ let live: string;
167
+ try {
168
+ live = readFileSync(path, "utf8");
169
+ } catch {
170
+ process.stderr.write(
171
+ `omp-conductor: no brief at ${path}` +
172
+ `${override === undefined ? " — run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md." : "."}\n`,
173
+ );
174
+ process.exit(1);
175
+ }
176
+ missing = missingSections(live, rendered);
177
+ }
178
+
179
+ process.stdout.write(`${formatBriefReport(path, layout, missing, sharedBrief)}\n`);
180
+ if (layout.kind !== "overlay" && project === undefined) {
181
+ process.stdout.write(
182
+ "\nnote: no conductor config resolved on this host, so the template's\n" +
183
+ "{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
184
+ );
185
+ }
186
+ }
@@ -0,0 +1,49 @@
1
+ import type { ProjectConfig } from "../types.ts";
2
+
3
+ /**
4
+ * What a command handler may read about the invocation it was dispatched for.
5
+ *
6
+ * Argument parsing stays in cli.ts — the flag helpers, the shared usage text
7
+ * and the registration table — and each handler receives the parsed essentials
8
+ * plus the lookups it needs through this object. A command module therefore
9
+ * never imports the entry point back: no import cycle, and no module-level
10
+ * mutable state for one verb to change under another.
11
+ */
12
+ export interface CommandContext {
13
+ /** The full argv including the verb at index 0, read once per process. */
14
+ argv: string[];
15
+ /**
16
+ * The `--project` value, parsed once because nearly every verb takes it.
17
+ * Named `projectFlag` rather than `project` because many handlers bind their
18
+ * own resolved `ProjectConfig` under that name.
19
+ */
20
+ projectFlag: string | undefined;
21
+ /** Applies both `--flag VALUE` and `--flag=VALUE`; undefined when absent. */
22
+ flag(name: string): string | undefined;
23
+ /**
24
+ * `--port` for the daemon-bound verbs. Exits 2 rather than defaulting,
25
+ * because silently ignoring a typo'd port would leave the operator probing
26
+ * an endpoint the daemon is not on.
27
+ */
28
+ portFlag(): number | undefined;
29
+ /** Required positive integer for `extend`; no partial parses such as `180x`. */
30
+ turnsFlag(): number;
31
+ /**
32
+ * `report`'s comma-separated `--events` / `--notices` ledger row ids, in the
33
+ * three shapes the runtime actually mints. Exits 2 on anything else.
34
+ */
35
+ digestIdsFlag(name: "events" | "notices"): string[];
36
+ /**
37
+ * The `<issue>` positional, for the verbs that take one. Exits 2 rather
38
+ * than proceeding with run #NaN; `verb` is named in the message.
39
+ */
40
+ issueArg(verb: string, raw: string | undefined): number;
41
+ /**
42
+ * The projects a fleet-level verb targets: `--all`, `--project NAME`, or the
43
+ * sole configured project (throwing when the choice would be ambiguous).
44
+ */
45
+ targetProjects(): ProjectConfig[];
46
+ }
47
+
48
+ /** One operator subcommand: what the registration table maps a verb to. */
49
+ export type CommandHandler = (ctx: CommandContext) => Promise<void> | void;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `daemon` — run the dispatch loop in the foreground; --once runs a single tick and exits. This is what `start` launches.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { runDaemon } from "../daemon.ts";
11
+ import { clearRecord, DEFAULT_PORT, livingDaemon, writeRecord } from "../lifecycle.ts";
12
+
13
+ /**
14
+ * `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
15
+ * `status` prints it, so it has to say something true: a foreground daemon
16
+ * opened no log of its own — whoever started it owns its stdout, be that
17
+ * systemd's journal, a terminal, or a pane.
18
+ */
19
+ const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
20
+
21
+ export async function daemonCommand(ctx: CommandContext): Promise<void> {
22
+ // Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
23
+ // pidfile, which left a daemon started in the foreground (which is how
24
+ // systemd runs it) invisible twice over: `omp-conductor status` reported
25
+ // no daemon at all, and a `daemon --once` drill run beside it saw
26
+ // `livingDaemon() === undefined`, concluded nothing else was dispatching,
27
+ // and reconciled the live daemon's in-flight runs as orphans. Writing the
28
+ // record here closes both holes.
29
+ const once = ctx.argv.includes("--once");
30
+ const port = ctx.portFlag();
31
+ const project = ctx.projectFlag;
32
+
33
+ // A drill is still a dispatcher: it settles rows, projects labels,
34
+ // admits, and launches workers, so it holds the same exclusive daemon/
35
+ // state ownership as the long-running dispatcher for the whole tick
36
+ // (#431). `runDaemon` acquires that ownership up front (the one choke
37
+ // point every once tick passes through) and refuses — having done no
38
+ // mutation — while another live daemon owns the project; the thrown
39
+ // refusal surfaces as `omp-conductor: another daemon is alive (pid N)…`.
40
+ if (once) {
41
+ await runDaemon({ once, port, project });
42
+ return;
43
+ }
44
+
45
+ const running = livingDaemon();
46
+ if (running !== undefined && running.pid !== process.pid) {
47
+ process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
48
+ process.exit(1);
49
+ }
50
+
51
+ // A living record that already names this pid was written by the `start`
52
+ // that spawned us, and it knows the log file our stdout is really going
53
+ // to. Replacing it with a guess would be a downgrade.
54
+ if (running === undefined) {
55
+ writeRecord({
56
+ pid: process.pid,
57
+ port: port ?? DEFAULT_PORT,
58
+ startedAt: Date.now(),
59
+ logFile: FOREGROUND_LOG,
60
+ ...(project === undefined ? {} : { project }),
61
+ });
62
+ }
63
+
64
+ try {
65
+ await runDaemon({ once, port, project });
66
+ } finally {
67
+ // The record names a pid that is about to stop existing. Leaving it
68
+ // behind makes the next reader probe a ghost before believing us.
69
+ clearRecord();
70
+ }
71
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `dashboard` — serve the fleet dashboard in a browser (epic #292, slice 1/5):
3
+ * the UI shell plus one bearer-authenticated read endpoint, `/api/projects`,
4
+ * from a dedicated `Bun.serve` process — never a route on the daemon port,
5
+ * whose unauthenticated loopback surface stays exactly as
6
+ * `verbs/server.test.ts` pins it.
7
+ *
8
+ * Argument parsing follows `stats.ts`'s template: every token after the verb
9
+ * is accounted for, and an unexpected one exits 2 rather than being silently
10
+ * ignored.
11
+ */
12
+
13
+ import type { CommandContext } from "./context.ts";
14
+ import { DASHBOARD_HOST, startDashboard } from "../dashboard/server.ts";
15
+
16
+ const DASHBOARD_USAGE = `omp-conductor dashboard — serve the fleet dashboard in your browser.
17
+
18
+ usage:
19
+ omp-conductor dashboard [--port N] [--host ADDR]
20
+
21
+ Serves the dashboard UI and GET /api/projects from a dedicated process —
22
+ separate from the daemon, which it only ever reads. /api/projects answers with
23
+ every configured project annotated with its daemon state (the same
24
+ livingDaemon + /healthz classification status and board use), the daemon's
25
+ port, and the raw /healthz body when it answers. Every /api request must carry
26
+ Authorization: Bearer <token>; anything else is a bare 401. The token is
27
+ minted mode 0600 at <stateDir()>/dashboard-token on first start (the
28
+ $OMP_CONDUCTOR_HOME state directory, next to config.json) and reused
29
+ afterwards; the page asks for it once and keeps it in localStorage. Static
30
+ assets are served without auth.
31
+
32
+ Binds 127.0.0.1:8788 by default. A non-loopback --host (say, a tailnet IP)
33
+ still starts, but prints a one-line warning naming the token file: the token
34
+ is then the only thing between anyone who can reach that address and the
35
+ project list. Ctrl-C stops the server.
36
+
37
+ flags:
38
+ --port PORT listen port (default 8788)
39
+ --host ADDR bind address (default 127.0.0.1)`;
40
+
41
+ export async function dashboardCommand(ctx: CommandContext): Promise<void> {
42
+ // Help first: parsing stops before any config read, token mint or socket.
43
+ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
44
+ process.stdout.write(`${DASHBOARD_USAGE}\n`);
45
+ return;
46
+ }
47
+ // Every remaining token after the verb is accounted for: the two value
48
+ // flags cli.ts already parsed (--port via portFlag, --host via flag).
49
+ for (let i = 1; i < ctx.argv.length; i++) {
50
+ const token = ctx.argv[i];
51
+ if (token === undefined) continue;
52
+ if (
53
+ token !== "--port" &&
54
+ token !== "--host" &&
55
+ !token.startsWith("--port=") &&
56
+ !token.startsWith("--host=")
57
+ ) {
58
+ process.stderr.write(`omp-conductor: dashboard: unexpected argument "${token}"\n`);
59
+ process.exit(2);
60
+ }
61
+ if (token === "--port" || token === "--host") i += 1;
62
+ }
63
+ const host = ctx.flag("host");
64
+ if (host === undefined && ctx.argv.includes("--host")) {
65
+ process.stderr.write(`omp-conductor: dashboard --host needs an address\n`);
66
+ process.exit(2);
67
+ }
68
+ if (host !== undefined && host.length === 0) {
69
+ process.stderr.write(`omp-conductor: dashboard --host needs an address\n`);
70
+ process.exit(2);
71
+ }
72
+
73
+ await startDashboard({ port: ctx.portFlag(), host: host ?? DASHBOARD_HOST });
74
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `decision` — record, list and close the questions put to the operator.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig } from "../config.ts";
11
+ import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
12
+ import { dbPath, openStore } from "../store.ts";
13
+
14
+ export async function decisionCommand(ctx: CommandContext): Promise<void> {
15
+ const sub = ctx.argv[1];
16
+ const project = findProject(loadConfig(), ctx.projectFlag);
17
+ const store = openStore(dbPath());
18
+ try {
19
+ if (sub === "open") {
20
+ const question = ctx.flag("question")?.trim();
21
+ if (question === undefined || question.length === 0 || question.startsWith("--")) {
22
+ process.stderr.write(
23
+ "omp-conductor: decision open needs --question with the question you sent\n",
24
+ );
25
+ process.exit(2);
26
+ }
27
+ const condition = ctx.flag("resolves-when")?.trim();
28
+ if (condition !== undefined && parseCondition(condition) === undefined) {
29
+ process.stderr.write(
30
+ `omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
31
+ );
32
+ process.exit(2);
33
+ }
34
+ const blocks = ctx.flag("blocks")?.trim();
35
+ const decision = store.createDecision({
36
+ project: project.name,
37
+ question,
38
+ ...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
39
+ ...(condition === undefined ? {} : { condition }),
40
+ at: Date.now(),
41
+ });
42
+ process.stdout.write(
43
+ `decision ${decision.id} recorded — resolve with: omp-conductor decision resolve ${decision.id} --answer "..."\n`,
44
+ );
45
+ return;
46
+ }
47
+
48
+ if (sub === "resolve" || sub === "withdraw") {
49
+ const id = ctx.argv[2];
50
+ if (id === undefined || id.startsWith("--")) {
51
+ process.stderr.write(`omp-conductor: decision ${sub} needs the decision id\n`);
52
+ process.exit(2);
53
+ }
54
+ const answered = sub === "resolve";
55
+ const text = answered ? ctx.flag("answer")?.trim() : ctx.flag("reason")?.trim();
56
+ if (answered && (text === undefined || text.length === 0)) {
57
+ process.stderr.write("omp-conductor: decision resolve needs --answer with what was decided\n");
58
+ process.exit(2);
59
+ }
60
+ const ok = store.resolveDecision(
61
+ id,
62
+ answered ? "answered" : "withdrawn",
63
+ text === undefined || text.length === 0 ? "withdrawn" : text,
64
+ Date.now(),
65
+ );
66
+ if (!ok) {
67
+ // A decision that is not open is a different mistake from an id that
68
+ // never existed, and the operator can only act on one of them.
69
+ process.stderr.write(
70
+ `omp-conductor: no open decision ${id} for ${project.name} — it was answered, withdrawn or expired, or the id is wrong\n`,
71
+ );
72
+ process.exit(1);
73
+ }
74
+ process.stdout.write(`decision ${id} ${answered ? "answered" : "withdrawn"}\n`);
75
+ return;
76
+ }
77
+
78
+ if (sub === "list" || sub === undefined) {
79
+ const open = store.openDecisions(project.name);
80
+ if (open.length === 0) {
81
+ process.stdout.write("no open decisions\n");
82
+ return;
83
+ }
84
+ const now = Date.now();
85
+ for (const d of open) {
86
+ const condition =
87
+ d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
88
+ const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
89
+ process.stdout.write(
90
+ `${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
91
+ );
92
+ }
93
+ return;
94
+ }
95
+
96
+ process.stderr.write(
97
+ `omp-conductor: unknown decision subcommand "${sub}" — expected open, resolve, withdraw or list\n`,
98
+ );
99
+ process.exit(2);
100
+ } finally {
101
+ store.close();
102
+ }
103
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `disarm` — remove the arm marker so ticks skip. Processes untouched.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { disarmTicks } from "../fleet.ts";
11
+
12
+ export async function disarmCommand(ctx: CommandContext): Promise<void> {
13
+ for (const project of ctx.targetProjects()) {
14
+ const r = disarmTicks(project.name);
15
+ process.stdout.write(
16
+ `disarmed — ticks will be skipped` +
17
+ `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
18
+ `marker ${r.path}\n`,
19
+ );
20
+ }
21
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `doctor` — read-only deployment health, one finding per past failure mode
3
+ * (#287, legibility 1/5).
4
+ *
5
+ * The probe logic lives in ../doctor.ts with every I/O seam injectable; this
6
+ * module only parses the doctor's own flags, renders the findings the runner
7
+ * produced (the same findings `--json` emits), and derives the exit code:
8
+ * nonzero iff the report contains a failing finding.
9
+ */
10
+
11
+ import type { CommandContext } from "./context.ts";
12
+ import { runDoctor, type DoctorReport, type FindingStatus } from "../doctor.ts";
13
+
14
+ const DOCTOR_USAGE = `omp-conductor doctor — check the deployment faults that have already cost
15
+ debugging sessions, mechanically and read-only.
16
+
17
+ usage:
18
+ omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
19
+
20
+ Checks, each one the mechanical version of a past production incident:
21
+ gh auth token alive and scopes cover every configured repo (a token
22
+ that expired under a live daemon failed every tracker call
23
+ silently)
24
+ labels every configured routing/state label exists with exact case
25
+ — label matching is case-sensitive and a mismatch fails
26
+ silently (README Limitations)
27
+ systemd unit the installed units vs the staged render (User=, environment,
28
+ working directory, exec, restart/success status) and the
29
+ ownership/modes of the unit and runtime dirs (the worker
30
+ die-at-turn-0 class)
31
+ config config.json validates, and its pre-write backup exists and
32
+ is fresh
33
+ store PRAGMA integrity_check
34
+ spend telemetry every completed run recording $0.00 means the USD cap cannot
35
+ fire — $0.00 is not proof of no spend
36
+ timezone reporting timezones are known IANA zones
37
+ telegram bot health, and one self-identified probe message
38
+
39
+ flags:
40
+ --json print the stable report shape {project, checkedAt,
41
+ status, findings:[{id,status,summary,fix?}]} — the CI
42
+ contract
43
+ --probe-telegram the ONE opt-in side effect: send one self-identified
44
+ test message through the report transport
45
+ --project NAME the project to check (required only when the config
46
+ has more than one)
47
+
48
+ Exit code is 0 only when nothing failed; warnings do not fail the run.`;
49
+
50
+ const LABEL_BY_STATUS: Record<FindingStatus, string> = { pass: "PASS", warn: "WARN", fail: "FAIL" };
51
+
52
+ /** Renders the same findings `--json` emits, so the two cannot disagree. */
53
+ export function renderDoctorReport(report: DoctorReport): string {
54
+ const width = Math.max(...report.findings.map((f) => f.id.length).concat(0), 8);
55
+ const lines = [
56
+ `omp-conductor doctor — ${report.project}`,
57
+ `status: ${report.status.toUpperCase()} (${report.findings.filter((f) => f.status === "fail").length} failed · ${report.findings.filter((f) => f.status === "warn").length} warnings) · checked ${report.checkedAt}`,
58
+ "",
59
+ ];
60
+ for (const f of report.findings) {
61
+ const pad = " ".repeat(Math.max(0, width - f.id.length));
62
+ lines.push(` ${LABEL_BY_STATUS[f.status].padEnd(5)} ${f.id}${pad} ${f.summary}`);
63
+ if (f.fix !== undefined) lines.push(` ${" ".repeat(width)} fix: ${f.fix}`);
64
+ }
65
+ return `${lines.join("\n")}\n`;
66
+ }
67
+
68
+ export async function doctorCommand(ctx: CommandContext): Promise<void> {
69
+ // Help first: parsing stops before any probe, config read, or side effect.
70
+ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
71
+ process.stdout.write(`${DOCTOR_USAGE}\n`);
72
+ return;
73
+ }
74
+ // Every remaining token after the verb is accounted for: the two flag
75
+ // presence markers and the `--project NAME` pair cli.ts already parsed.
76
+ const DOCTOR_FLAGS: Record<string, true> = {
77
+ "--json": true,
78
+ "--probe-telegram": true,
79
+ "--project": true,
80
+ };
81
+ for (let i = 1; i < ctx.argv.length; i++) {
82
+ const token = ctx.argv[i];
83
+ if (token === undefined) continue;
84
+ if (DOCTOR_FLAGS[token] !== true) {
85
+ process.stderr.write(`omp-conductor: doctor: unexpected argument "${token}"\n`);
86
+ process.exit(2);
87
+ }
88
+ if (token === "--project") i += 1;
89
+ }
90
+
91
+ const report = await runDoctor(ctx.projectFlag, {
92
+ probeTelegram: ctx.argv.includes("--probe-telegram"),
93
+ });
94
+ if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
95
+ else process.stdout.write(renderDoctorReport(report));
96
+ // The one contract CI reads: nonzero iff something failed. Warnings pass.
97
+ if (report.status === "fail") process.exitCode = 1;
98
+ }