omp-conductor 0.13.0 → 0.15.0

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 (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
package/src/cli.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Standalone entry point. Everything here is argument handling and printing
4
- * the loop, the caps and the state all live in ./daemon.ts and the background
5
- * process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
6
- * cannot drift apart.
3
+ * Standalone entry point the only operator surface after #309. Everything here
4
+ * is argument handling and printing: the loop, the caps and the state live in
5
+ * ./daemon.ts and the background process lifecycle in ./lifecycle.ts.
7
6
  */
7
+ import { randomUUID } from "node:crypto";
8
8
  import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
9
  import { userInfo } from "node:os";
10
10
  import { dirname, join } from "node:path";
@@ -21,19 +21,21 @@ import {
21
21
  proposeRetrofit,
22
22
  repairPolicyBannerCrumbs,
23
23
  } from "./brief-upgrade.ts";
24
+ import { interruptDisposition } from "./availability.ts";
24
25
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
25
26
  import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
26
27
  import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
27
28
  import {
28
29
  armTicks,
29
- clearPaneHalt,
30
+ clearPaneHaltIfResolvable,
30
31
  disarmTicks,
31
- halt,
32
- haltWithPane,
33
32
  hold,
33
+ pinPaneHalt,
34
34
  releaseHold,
35
35
  renderStatus,
36
36
  startHerdrFleet,
37
+ stopConductorPane,
38
+ telegramStateDir,
37
39
  } from "./fleet.ts";
38
40
  import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
39
41
  import { readBaseChain } from "./gitops.ts";
@@ -51,12 +53,17 @@ import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
51
53
  import { digestDedupeKey } from "./reports.ts";
52
54
  import { digestDue } from "./digest-schedule.ts";
53
55
  import {
56
+ AMEND_AREA_IDS,
54
57
  briefPathForProject,
55
58
  policyPathForProject,
56
59
  renderBriefForProject,
57
60
  renderFloorForProject,
58
61
  shippedBriefTemplate,
62
+ type AmendAreaId,
59
63
  } from "./setup.ts";
64
+ import { runGraphInstall, runHostInstall } from "./setup-install.ts";
65
+ import { DEFAULT_PROBES, NO_PROBES, setup } from "./setup-wizard.ts";
66
+ import { terminalUi } from "./wizard-ui.ts";
60
67
  import { dbPath, LIVE_STATES, openStore } from "./store.ts";
61
68
  import { formatTranscriptLine } from "./transcript.ts";
62
69
  import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
@@ -89,19 +96,18 @@ function packageVersion(): string {
89
96
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
90
97
 
91
98
  usage:
99
+ omp-conductor setup [area] [--no-ai] [--project NAME]
92
100
  omp-conductor start [--port N] [--project NAME]
93
101
  omp-conductor --version
94
- omp-conductor stop
102
+ omp-conductor stop [--pane] [--project NAME | --all]
95
103
  omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
96
104
  omp-conductor upgrade [--to VERSION] [--project NAME]
97
105
  omp-conductor board [--project NAME] [--json]
98
106
  omp-conductor status [--project NAME]
99
107
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
100
- omp-conductor hold [--project NAME]
101
- omp-conductor halt [--pane] [--project NAME]
102
- omp-conductor arm [--project NAME]
103
- omp-conductor disarm [--project NAME]
104
- omp-conductor release-pane [--project NAME]
108
+ omp-conductor hold [--project NAME | --all]
109
+ omp-conductor arm [--project NAME | --all]
110
+ omp-conductor disarm [--project NAME | --all]
105
111
  omp-conductor tail <issue> [--project NAME]
106
112
  omp-conductor extend <issue> --turns N [--project NAME]
107
113
  omp-conductor worker pause <issue> [--project NAME]
@@ -110,18 +116,22 @@ usage:
110
116
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
111
117
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
112
118
  omp-conductor daemon [--once] [--port N] [--project NAME]
113
- omp-conductor pause [--reason TEXT]
114
- omp-conductor resume
115
- omp-conductor graph-setup [--project NAME] [--write]
119
+ omp-conductor resume [--project NAME | --all]
116
120
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
117
121
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
118
- omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
122
+ omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
123
+ omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
119
124
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
120
125
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
121
126
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
122
127
  omp-conductor decision list [--project NAME]
123
128
  omp-conductor help
124
129
 
130
+ setup interview, then write config.json, the labels, the briefs and the
131
+ staged host files behind one confirm. Bare is a full run — or, when
132
+ the project already exists, a chooser of which area to amend. Naming
133
+ an area positionally skips that chooser and amends only that area:
134
+ ${AMEND_AREA_IDS.join(", ")}.
125
135
  upgrade update the Bun-global CLI, omp plugin, Herdr recovery plugin, and
126
136
  brief as one pinned release. Pauses only new claims, drains live
127
137
  workers, reloads, verifies twice, and restores the prior dispatch
@@ -129,9 +139,14 @@ usage:
129
139
  start start the installed herdr-fleet.service when present, then run the
130
140
  dispatch loop in the background and wait until it answers GET
131
141
  /healthz on :8787 (override with --port). Refuses if one is running.
132
- stop stop the running daemon. Uses systemctl when the omp-conductor
133
- unit owns the process (so Restart=on-failure cannot bring it back);
134
- otherwise SIGTERM then SIGKILL.
142
+ stop stop the conductor: pause claiming, disarm ticks, then stop the
143
+ dispatch daemon (systemctl-aware, so Restart=on-failure cannot bring
144
+ it back). Pane stays up unless --pane is passed. To bounce the daemon
145
+ without stopping the fleet, use restart.
146
+ stop --pane
147
+ also stop the conductor agent's pane and pin herdr-conductor recovery
148
+ off for that agent only — it does NOT stop herdr-fleet.service or any
149
+ other herdr session. resume clears the pin.
135
150
  restart drain the fleet by default: pause new claims, wait until live workers
136
151
  reach 0/N (bounded by --timeout SECONDS, default 1800), restart keeping
137
152
  the running daemon's port and project unless a flag overrides them,
@@ -156,18 +171,12 @@ usage:
156
171
  as well as what they did. --issue narrows it to one issue's run.
157
172
  hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
158
173
  This is "stop the conductor overnight" without killing processes.
159
- halt hold, then stop the dispatch daemon (systemctl-aware). Pane stays up
160
- unless --pane is passed.
161
- halt --pane
162
- halt, then pin herdr-conductor recovery off for the conductor agent
163
- only — does NOT stop herdr-fleet.service or any other herdr session.
164
- Clear the pin with release-pane when you want recovery again.
174
+ Use --all to target every configured project.
165
175
  arm proof-gated: send a Telegram challenge and write the arm marker only
166
176
  after your reply appears as a user turn in the orchestrator transcript.
167
- Never auto-armed by resume/hold.
168
- disarm remove the arm marker so ticks skip. Processes untouched.
169
- release-pane
170
- clear the halt --pane recovery pin so herdr-conductor may resume again.
177
+ Never auto-armed by resume/hold. Use --all for every project.
178
+ disarm remove the arm marker so ticks skip. Processes untouched. Use --all
179
+ to target every configured project.
171
180
  tail follow the newest run for <issue>: the worker's assistant text and
172
181
  the tools it calls, printed as they land. Workers are sessions inside
173
182
  the daemon rather than terminals, so this is the only way to watch
@@ -197,12 +206,17 @@ usage:
197
206
  strings, one per --arg (e.g. --arg prUrl=https://x --arg headSha=y).
198
207
  A refusal exits 3. See conductor_pr_merge/conductor_label/
199
208
  conductor_release/conductor_pr_update_branch in the brief.
209
+ event persist one ordinary material outcome without sending it. Category
210
+ is a short lowercase slug; summary and evidence name what happened
211
+ and where it can be verified. --occurred-at defaults to now.
200
212
  report hand a rendered report to the daemon's durable outbox. The report is
201
213
  persisted before anything is sent, delivered by the daemon with
202
214
  bounded retries, and shown by status until it lands. Delivery is
203
215
  at-least-once: a crash mid-send is retried and the retry says it may
204
216
  be a repeat. --kind digest is accepted at most once per local day,
205
217
  decided from the ledger rather than from what you remember sending.
218
+ A digest associates the comma-separated --events and --notices rows
219
+ atomically; omitted rows stay owed.
206
220
  decision record, list and close the questions you have put to your operator.
207
221
  A question that lives only in a session's context is lost to the next
208
222
  compaction, so \`decision open\` writes it down and every tick's prompt
@@ -220,16 +234,16 @@ usage:
220
234
  loop; recording one never edits policy by itself.
221
235
  daemon run the dispatch loop in the foreground; --once runs a single tick
222
236
  and exits. This is what \`start\` launches.
223
- pause stop claiming new work only (ticks keep firing if armed). Prefer hold.
224
- --reason TEXT is recorded in the pause sentinel (\`status\` shows it).
225
- resume clear pause only — does NOT re-arm. Prefer hold's inverse: resume + arm.
226
- graph-setup
227
- print how to set up the code-graph indexes workers query instead of
228
- grepping: the clone commands for any missing index-only clone, the
229
- index command per repo, and a systemd service+timer that keeps them
230
- current. --write writes the two units and the script they run, and
231
- prints the systemctl line to run — it never runs systemctl itself.
232
- Exits 1 when no repo in the project has graphProject configured.
237
+ resume clear pause and any pane-recovery pin. Does NOT re-arm: run arm after
238
+ an inbound Telegram proof to bring ticks back. Use --all for every project.
239
+ setup host
240
+ re-stage the systemd unit and run the install behind one confirm.
241
+ setup graph
242
+ set up the code-graph indexes workers query instead of grepping, end
243
+ to end: check prerequisites, clone any missing index-only clone as
244
+ you, install and enable the reindex timer as root, then seed one
245
+ indexing run and verify it. --no-seed enables without seeding;
246
+ --print changes nothing. Exits 1 when no repo has graphProject.
233
247
  brief-upgrade
234
248
  inspect the brief overlay (package floor + POLICY.md). Reports by
235
249
  default. --migrate lifts a bannered ORCHESTRATOR.md owned half into
@@ -243,19 +257,20 @@ usage:
243
257
  --version
244
258
  print the installed omp-conductor package version (also -V, version).
245
259
 
246
- Pause is a flag file under the state directory, so it applies to every project
247
- and survives a daemon restart. Hold also removes the arm marker the heartbeat
248
- reads, so both brains go quiet without killing processes. A running daemon is
249
- tracked by a pidfile under $OMP_CONDUCTOR_RUNTIME_DIR (default
260
+ Pause is a flag file under the state directory, so it applies to the selected
261
+ project (or every configured project with --all) and survives a daemon restart.
262
+ Hold also removes the arm marker the heartbeat reads, so both brains go quiet
263
+ without killing processes. A running daemon is tracked by a pidfile under
264
+ $OMP_CONDUCTOR_RUNTIME_DIR (default
250
265
  ~/.omp/run/daemons/omp-conductor), written whether it was started in the
251
266
  background or in the foreground, and probed for liveness on every read — a
252
267
  stale one never blocks a start.
253
268
 
254
269
  Stop the conductor:
255
270
  hold no claims, no tick sends (inspectable)
256
- halt hold + stop dispatch daemon
257
- halt --pane halt + pin conductor-pane recovery off
258
- resume && arm clear pause, then prove inbound Telegram before ticks resume`;
271
+ stop hold + stop dispatch daemon
272
+ stop --pane stop + pin conductor-pane recovery off
273
+ resume && arm clear pause and any pane pin, then prove inbound Telegram`;
259
274
 
260
275
  /** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
261
276
  function flag(argv: string[], name: string): string | undefined {
@@ -265,6 +280,26 @@ function flag(argv: string[], name: string): string | undefined {
265
280
  return prefixed?.slice(name.length + 3);
266
281
  }
267
282
 
283
+ function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
284
+ const raw = flag(argv, name);
285
+ if (raw === undefined) {
286
+ if (argv.includes(`--${name}`)) {
287
+ process.stderr.write(`omp-conductor: report --${name} needs comma-separated row ids\n`);
288
+ process.exit(2);
289
+ }
290
+ return [];
291
+ }
292
+ const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
293
+ if (
294
+ ids.length === 0 ||
295
+ ids.some((id) => !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(id))
296
+ ) {
297
+ process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
298
+ process.exit(2);
299
+ }
300
+ return ids;
301
+ }
302
+
268
303
  /**
269
304
  * `--port` for the three verbs that take one. Exits 2 rather than defaulting,
270
305
  * because silently ignoring a typo'd port would leave the operator probing an
@@ -446,6 +481,24 @@ async function tailRun(project: string, issue: number): Promise<void> {
446
481
 
447
482
  const argv = process.argv.slice(2);
448
483
  const cmd = argv[0];
484
+ /**
485
+ * Parsed once, because nearly every verb takes it and a per-case
486
+ * `projectFlag` is a line each of them can forget. Named `projectFlag`
487
+ * rather than `project` because many cases bind their own resolved
488
+ * `ProjectConfig` under that name.
489
+ */
490
+ const projectFlag = flag(argv, "project");
491
+
492
+ function targetProjects(): ProjectConfig[] {
493
+ const cfg = loadConfig();
494
+ if (argv.includes("--all")) return cfg.projects;
495
+ if (projectFlag === undefined && cfg.projects.length > 1) {
496
+ throw new Error(
497
+ `config has ${cfg.projects.length} projects; use --project NAME or --all`,
498
+ );
499
+ }
500
+ return [findProject(cfg, projectFlag)];
501
+ }
449
502
 
450
503
  try {
451
504
  switch (cmd) {
@@ -454,10 +507,57 @@ try {
454
507
  case "version":
455
508
  process.stdout.write(`${packageVersion()}\n`);
456
509
  break;
510
+ case "setup": {
511
+ const sub = argv[1];
512
+ const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
513
+ // The terminal surface owns stdin for whichever path runs, and is released
514
+ // in a finally: a throw with readline still attached leaves the operator's
515
+ // shell without an echo.
516
+ const ui = terminalUi();
517
+ try {
518
+ // `host` and `graph` are install subcommands, checked BEFORE the amend
519
+ // areas. They are not areas — routing them through the area validation
520
+ // is how `setup host` came to exit 2 as an "unknown setup area".
521
+ if (positional === "host" || positional === "graph") {
522
+ const cfg = loadConfig();
523
+ const project = findProject(cfg, projectFlag);
524
+ const outcome =
525
+ positional === "host"
526
+ ? await runHostInstall(project, resolveCaps(project, cfg.defaults), telegramStateDir(), ui)
527
+ : await runGraphInstall(project, ui, {
528
+ noSeed: argv.includes("--no-seed"),
529
+ print: argv.includes("--print"),
530
+ });
531
+ // `staged` is a success on a host that has no systemd: the files are
532
+ // real, only the enable step is impossible.
533
+ if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
534
+ break;
535
+ }
536
+ let area: AmendAreaId | undefined;
537
+ if (positional !== undefined) {
538
+ if (!(AMEND_AREA_IDS as readonly string[]).includes(positional)) {
539
+ process.stderr.write(
540
+ `omp-conductor: unknown setup argument "${positional}" — ` +
541
+ `install subcommands are host, graph; amend areas are ${AMEND_AREA_IDS.join(", ")}\n`,
542
+ );
543
+ process.exit(2);
544
+ }
545
+ area = positional as AmendAreaId;
546
+ }
547
+ // `--no-ai` is the interview with its reading half removed: every question
548
+ // is still asked, nothing is proposed. For a host with no omp peer, a
549
+ // private repo no probe can clone, or an operator who would rather type the
550
+ // gates than review a model's reading of their CI.
551
+ await setup(ui, projectFlag, area, argv.includes("--no-ai") ? NO_PROBES : DEFAULT_PROBES);
552
+ } finally {
553
+ ui.close();
554
+ }
555
+ break;
556
+ }
457
557
  case "upgrade": {
458
558
  const result = await upgradeConductor({
459
559
  version: flag(argv, "to"),
460
- project: flag(argv, "project"),
560
+ project: projectFlag,
461
561
  });
462
562
  process.stdout.write(
463
563
  `${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
@@ -479,7 +579,7 @@ try {
479
579
  // record here closes both holes.
480
580
  const once = argv.includes("--once");
481
581
  const port = portFlag(argv);
482
- const project = flag(argv, "project");
582
+ const project = projectFlag;
483
583
 
484
584
  // `--once` registers nothing, on purpose. It is precisely the single-tick
485
585
  // drill the orphan guard exists to protect, so a drill that announced
@@ -520,7 +620,7 @@ try {
520
620
  }
521
621
 
522
622
  case "start": {
523
- const project = flag(argv, "project");
623
+ const project = projectFlag;
524
624
  const herdr = startHerdrFleet(project);
525
625
  const rec = await startDaemon({ port: portFlag(argv), project });
526
626
  process.stdout.write(
@@ -532,14 +632,40 @@ try {
532
632
  break;
533
633
  }
534
634
 
635
+ // `stop` absorbs the old `halt`: one word for "stop the conductor", instead of
636
+ // an operator holding in their head that `halt` was `hold` plus stopping the
637
+ // daemon while `stop` killed only the process. Bouncing the daemon is
638
+ // `restart`, which is what the removed kill-only `stop` was reached for.
535
639
  case "stop": {
536
- const result = await stopDaemon();
537
- if (result.kind === "not-running") {
538
- process.stdout.write("not running\n");
539
- } else {
540
- process.stdout.write(
541
- `stopped — pid ${result.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
542
- );
640
+ const withPane = argv.includes("--pane");
641
+ const targets = targetProjects().map((project) => ({
642
+ project,
643
+ hold: hold(project.name, "halt"),
644
+ pin: withPane ? pinPaneHalt(project.name).path : undefined,
645
+ }));
646
+ const stop = await stopDaemon();
647
+ const stopLine =
648
+ stop.kind === "not-running"
649
+ ? "daemon was not running"
650
+ : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
651
+ for (const target of targets) {
652
+ if (target.pin !== undefined) {
653
+ const pane = await stopConductorPane(target.project.name);
654
+ process.stdout.write(
655
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
656
+ `${stopLine}\n` +
657
+ `pane recovery pinned at ${target.pin}\n` +
658
+ `pane stop: ${pane.stopped} — ${pane.detail}\n` +
659
+ ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
660
+ ` resume clears the pin when you want recovery again)\n`,
661
+ );
662
+ } else {
663
+ process.stdout.write(
664
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
665
+ `${stopLine}\n` +
666
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
667
+ );
668
+ }
543
669
  }
544
670
  break;
545
671
  }
@@ -552,7 +678,7 @@ try {
552
678
  // goes through systemctl so the replacement stays supervised.
553
679
  const { previous, record, via } = await restartDaemon({
554
680
  port: portFlag(argv),
555
- project: flag(argv, "project"),
681
+ project: projectFlag,
556
682
  });
557
683
  if (previous !== undefined) {
558
684
  process.stdout.write(
@@ -578,16 +704,17 @@ try {
578
704
  let result!: RestartResult;
579
705
  const deps: UpgradeDeps = {
580
706
  ...DEFAULT_DEPS,
581
- setPaused: (v) => setPaused(v, { source: "restart", reason: "restart, draining" }),
707
+ setPaused: (v, project) =>
708
+ setPaused(v, { source: "restart", reason: "restart, draining" }, project),
582
709
  restartDaemon: async () => {
583
710
  result = await restartDaemon({
584
711
  port: portFlag(argv),
585
- project: flag(argv, "project"),
712
+ project: projectFlag,
586
713
  });
587
714
  },
588
715
  };
589
716
  try {
590
- await drainAndRestart(deps, { project: flag(argv, "project"), timeoutMs });
717
+ await drainAndRestart(deps, { project: projectFlag, timeoutMs });
591
718
  } catch (err) {
592
719
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
593
720
  process.exit(1);
@@ -606,7 +733,7 @@ try {
606
733
  }
607
734
 
608
735
  case "status": {
609
- const project = flag(argv, "project");
736
+ const project = projectFlag;
610
737
  const text = await renderStatus(project);
611
738
  const stalled = stallLine();
612
739
  process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
@@ -622,7 +749,7 @@ try {
622
749
  */
623
750
  case "ledger": {
624
751
  const cfg = loadConfig();
625
- const p = findProject(cfg, flag(argv, "project"));
752
+ const p = findProject(cfg, projectFlag);
626
753
  const issueFlag = flag(argv, "issue");
627
754
  const issue = issueFlag === undefined ? undefined : issueArg("ledger", issueFlag);
628
755
  const limitFlag = flag(argv, "limit");
@@ -679,7 +806,7 @@ try {
679
806
  }
680
807
 
681
808
  case "board": {
682
- const project = flag(argv, "project");
809
+ const project = projectFlag;
683
810
  // Headless one-shot mode: `--json` asks for it explicitly, and a
684
811
  // non-interactive stdin or stdout (pipe, redirect, cron, script) cannot
685
812
  // host the key-driven board anyway. The TTY throw inside runBoard stays
@@ -693,89 +820,78 @@ try {
693
820
  }
694
821
 
695
822
  case "hold": {
696
- const r = hold(flag(argv, "project"));
697
- process.stdout.write(
698
- `held — claiming paused` +
699
- `${r.wasPaused ? " (already paused)" : ""}` +
700
- `; ticks disarmed at ${r.disarmed.path}` +
701
- `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}\n` +
702
- `daemon and pane left running; halt to stop the daemon\n`,
703
- );
704
- break;
705
- }
706
-
707
- case "halt": {
708
- const project = flag(argv, "project");
709
- const withPane = argv.includes("--pane");
710
- if (withPane) {
711
- const r = await haltWithPane(project);
712
- const stopLine =
713
- r.stop.kind === "not-running"
714
- ? "daemon was not running"
715
- : `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
716
- process.stdout.write(
717
- `halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
718
- `${stopLine}\n` +
719
- `pane recovery pinned at ${r.pane.pinPath}\n` +
720
- `pane stop: ${r.pane.stopped} — ${r.pane.detail}\n` +
721
- ` (conductor agent "${r.pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
722
- ` release-pane clears the pin when you want recovery again)\n`,
723
- );
724
- } else {
725
- const r = await halt(project);
726
- const stopLine =
727
- r.stop.kind === "not-running"
728
- ? "daemon was not running"
729
- : `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
823
+ for (const project of targetProjects()) {
824
+ const r = hold(project.name);
730
825
  process.stdout.write(
731
- `halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
732
- `${stopLine}\n` +
733
- `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
826
+ `held — claiming paused` +
827
+ `${r.wasPaused ? " (already paused)" : ""}` +
828
+ `; ticks disarmed at ${r.disarmed.path}` +
829
+ `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}\n` +
830
+ `daemon and pane left running; stop to stop the daemon too\n`,
734
831
  );
735
832
  }
736
833
  break;
737
834
  }
738
835
 
739
- case "arm": {
740
- process.stdout.write("arm: sending inbound Telegram challenge…\n");
741
- const r = await armTicks(flag(argv, "project"));
742
- process.stdout.write(
743
- `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
744
- `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
745
- );
836
+ // Removed verbs keep a case rather than falling through to usage: an operator
837
+ // who typed the old word gets the new one, not a wall of help text. Exit 2 is
838
+ // the same code an unknown verb uses.
839
+ case "halt":
840
+ process.stderr.write('omp-conductor: "halt" is now "stop" see help\n');
841
+ process.exit(2);
746
842
  break;
747
- }
748
843
 
749
- case "disarm": {
750
- const r = disarmTicks(flag(argv, "project"));
751
- process.stdout.write(
752
- `disarmed — ticks will be skipped` +
753
- `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
754
- `marker ${r.path}\n`,
844
+ case "pause":
845
+ process.stderr.write(
846
+ 'omp-conductor: "pause" is gone — "hold" pauses claiming AND disarms ticks; see help\n',
755
847
  );
848
+ process.exit(2);
849
+ break;
850
+
851
+ case "release-pane":
852
+ process.stderr.write('omp-conductor: "release-pane" is now part of "resume" — see help\n');
853
+ process.exit(2);
854
+ break;
855
+
856
+ case "graph-setup":
857
+ process.stderr.write('omp-conductor: "graph-setup" is now "setup graph" — see help\n');
858
+ process.exit(2);
859
+ break;
860
+
861
+ case "arm": {
862
+ for (const project of targetProjects()) {
863
+ process.stdout.write("arm: sending inbound Telegram challenge…\n");
864
+ const r = await armTicks(project.name);
865
+ process.stdout.write(
866
+ `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
867
+ `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
868
+ );
869
+ }
756
870
  break;
757
871
  }
758
872
 
759
- case "release-pane": {
760
- const r = clearPaneHalt(flag(argv, "project"));
761
- process.stdout.write(
762
- r.wasHalted
763
- ? `pane recovery pin cleared ${r.path}\nherdr-conductor may resume the fleet agent again\n`
764
- : `no pane recovery pin at ${r.path}\n`,
765
- );
873
+ case "disarm": {
874
+ for (const project of targetProjects()) {
875
+ const r = disarmTicks(project.name);
876
+ process.stdout.write(
877
+ `disarmedticks will be skipped` +
878
+ `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
879
+ `marker ${r.path}\n`,
880
+ );
881
+ }
766
882
  break;
767
883
  }
768
884
 
769
885
  case "tail": {
770
886
  const issue = issueArg("tail", argv[1]);
771
- await tailRun(findProject(loadConfig(), flag(argv, "project")).name, issue);
887
+ await tailRun(findProject(loadConfig(), projectFlag).name, issue);
772
888
  break;
773
889
  }
774
890
 
775
891
  case "extend": {
776
892
  const issue = issueArg("extend", argv[1]);
777
893
  const maxTurns = turnsFlag(argv);
778
- const project = findProject(loadConfig(), flag(argv, "project"));
894
+ const project = findProject(loadConfig(), projectFlag);
779
895
  const daemon = livingDaemon();
780
896
  if (daemon === undefined) throw new Error("daemon is not running");
781
897
  if (daemon.project !== undefined && daemon.project !== project.name) {
@@ -843,7 +959,7 @@ try {
843
959
  );
844
960
  process.exit(2);
845
961
  }
846
- const project = findProject(loadConfig(), flag(argv, "project"));
962
+ const project = findProject(loadConfig(), projectFlag);
847
963
  const daemon = livingDaemon();
848
964
  if (daemon === undefined) throw new Error("daemon is not running");
849
965
  if (daemon.project !== undefined && daemon.project !== project.name) {
@@ -919,7 +1035,7 @@ try {
919
1035
  case "unblock": {
920
1036
  const issue = issueArg("unblock", argv[1]);
921
1037
  const cfg = loadConfig();
922
- const project = findProject(cfg, flag(argv, "project"));
1038
+ const project = findProject(cfg, projectFlag);
923
1039
  const store = openStore(dbPath());
924
1040
  try {
925
1041
  const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
@@ -961,7 +1077,7 @@ try {
961
1077
  process.exit(2);
962
1078
  }
963
1079
  const cfg = loadConfig();
964
- const project = findProject(cfg, flag(argv, "project"));
1080
+ const project = findProject(cfg, projectFlag);
965
1081
  const args: Record<string, string> = {};
966
1082
  for (let i = 2; i < argv.length; i++) {
967
1083
  const token = argv[i];
@@ -975,7 +1091,7 @@ try {
975
1091
  args[pair.slice(0, eq)] = pair.slice(eq + 1);
976
1092
  i++;
977
1093
  } else if (token === "--project" || token?.startsWith("--project=") === true) {
978
- // Consumed by `flag(argv, "project")` above; both spellings skip here.
1094
+ // Consumed by `projectFlag` above; both spellings skip here.
979
1095
  if (token === "--project") i++;
980
1096
  } else {
981
1097
  process.stderr.write(
@@ -999,10 +1115,10 @@ try {
999
1115
  tracker: makeTracker(project),
1000
1116
  actions: githubVerbActions(project),
1001
1117
  fleetStop: () =>
1002
- isPaused()
1003
- ? "claiming is paused for this fleet (omp-conductor pause, hold or halt)"
1118
+ isPaused(project.name)
1119
+ ? "claiming is paused for this fleet (omp-conductor hold or stop)"
1004
1120
  : undefined,
1005
- pausedAt,
1121
+ pausedAt: () => pausedAt(project.name),
1006
1122
  log: (m) => process.stderr.write(`${m}\n`),
1007
1123
  now: () => Date.now(),
1008
1124
  chain: { readBaseChain },
@@ -1019,6 +1135,58 @@ try {
1019
1135
  break;
1020
1136
  }
1021
1137
 
1138
+ case "event": {
1139
+ if (argv[1] !== "record") {
1140
+ process.stderr.write("omp-conductor: event needs the record subcommand\n");
1141
+ process.exit(2);
1142
+ }
1143
+ const category = flag(argv, "category");
1144
+ const summary = flag(argv, "summary")?.replace(/\s+/g, " ").trim();
1145
+ const evidence = flag(argv, "evidence")?.replace(/\s+/g, " ").trim();
1146
+ if (
1147
+ category === undefined ||
1148
+ !/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
1149
+ summary === undefined ||
1150
+ summary.length === 0 ||
1151
+ summary.length > 240 ||
1152
+ summary.startsWith("--") ||
1153
+ evidence === undefined ||
1154
+ evidence.length === 0 ||
1155
+ evidence.length > 500 ||
1156
+ evidence.startsWith("--")
1157
+ ) {
1158
+ process.stderr.write(
1159
+ "omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
1160
+ );
1161
+ process.exit(2);
1162
+ }
1163
+ const rawOccurredAt = flag(argv, "occurred-at");
1164
+ const recordedAt = Date.now();
1165
+ const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
1166
+ if (!Number.isFinite(occurredAt)) {
1167
+ process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
1168
+ process.exit(2);
1169
+ }
1170
+ const project = findProject(loadConfig(), projectFlag);
1171
+ const store = openStore(dbPath());
1172
+ try {
1173
+ const event = store.recordMaterialEvent({
1174
+ project: project.name,
1175
+ category,
1176
+ summary,
1177
+ evidence,
1178
+ occurredAt,
1179
+ recordedAt,
1180
+ });
1181
+ process.stdout.write(
1182
+ `event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
1183
+ );
1184
+ } finally {
1185
+ store.close();
1186
+ }
1187
+ break;
1188
+ }
1189
+
1022
1190
  /**
1023
1191
  * The handover point. Authorship stays with the model; from here the daemon
1024
1192
  * owns delivery, so "I sent the report" stops being a claim the model makes
@@ -1043,7 +1211,13 @@ try {
1043
1211
  process.exit(2);
1044
1212
  }
1045
1213
  const kind = rawKind as ReportKind;
1046
- const project = findProject(loadConfig(), flag(argv, "project"));
1214
+ if (kind !== "digest" && (argv.includes("--events") || argv.includes("--notices"))) {
1215
+ process.stderr.write(
1216
+ "omp-conductor: report --events and --notices are valid only with --kind digest\n",
1217
+ );
1218
+ process.exit(2);
1219
+ }
1220
+ const project = findProject(loadConfig(), projectFlag);
1047
1221
  const store = openStore(dbPath());
1048
1222
  try {
1049
1223
  const at = Date.now();
@@ -1051,8 +1225,8 @@ try {
1051
1225
  // policy defers is refused here rather than silently turning into a
1052
1226
  // page, or a digest going out off-schedule.
1053
1227
  const policy = project.reporting;
1228
+ const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
1054
1229
  if (kind === "digest") {
1055
- const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
1056
1230
  if (digestPolicy.cadence === "none") {
1057
1231
  process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
1058
1232
  process.exit(2);
@@ -1070,32 +1244,54 @@ try {
1070
1244
  }
1071
1245
  }
1072
1246
  if (kind === "material") {
1073
- const interruptOn = policy?.interruptOn;
1074
- if (interruptOn !== undefined && !interruptOn.includes("material")) {
1247
+ const disposition = interruptDisposition(policy, "material", at);
1248
+ if (disposition === "digest") {
1075
1249
  process.stderr.write(
1076
1250
  "omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
1077
1251
  );
1078
1252
  process.exit(2);
1079
1253
  }
1254
+ if (disposition === "availability") {
1255
+ const noticeId = randomUUID();
1256
+ store.addHeldNotice({
1257
+ id: noticeId,
1258
+ project: project.name,
1259
+ category: "material",
1260
+ summary: body.split("\n", 1)[0]!.slice(0, 240),
1261
+ detail: body,
1262
+ createdAt: at,
1263
+ releaseOnAvailable: true,
1264
+ });
1265
+ process.stdout.write(
1266
+ `held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
1267
+ "the daemon will include it in the next digest or working-hours catch-up\n",
1268
+ );
1269
+ break;
1270
+ }
1080
1271
  }
1081
- const { report, deduped } = store.enqueueReport({
1272
+ const draft = {
1082
1273
  project: project.name,
1083
1274
  kind,
1084
1275
  body,
1085
- // Only the digest is at-most-once. A material report describes one
1086
- // event as it happens, and two of those in a day are two events.
1087
- ...(kind === "digest"
1088
- ? { dedupeKey: digestDedupeKey(at, project.reporting?.digest?.timezone) }
1276
+ // Only a daily digest is at-most-once. Per-tick digests and material
1277
+ // reports describe new outcomes each time, so they carry no daily key.
1278
+ ...(kind === "digest" && digestPolicy.cadence === "daily"
1279
+ ? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
1089
1280
  : {}),
1090
1281
  at,
1091
- });
1092
- // The held notices an accepted digest re-surfaces are now owed by it,
1093
- // whatever the model goes on to write.
1094
- if (kind === "digest") store.markNoticesDigested(project.name, at);
1282
+ };
1283
+ const { report, deduped } =
1284
+ kind === "digest"
1285
+ ? store.enqueueDigestReport(
1286
+ draft,
1287
+ digestIdsFlag(argv, "events"),
1288
+ digestIdsFlag(argv, "notices"),
1289
+ )
1290
+ : store.enqueueReport(draft);
1095
1291
  process.stdout.write(
1096
1292
  deduped
1097
1293
  ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
1098
- "the ledger decides this, not your memory of the last tick; use --kind material for a second event\n"
1294
+ "the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
1099
1295
  : `report ${report.id} queued for ${project.name} (${kind})\n` +
1100
1296
  "the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
1101
1297
  );
@@ -1107,7 +1303,7 @@ try {
1107
1303
 
1108
1304
  case "decision": {
1109
1305
  const sub = argv[1];
1110
- const project = findProject(loadConfig(), flag(argv, "project"));
1306
+ const project = findProject(loadConfig(), projectFlag);
1111
1307
  const store = openStore(dbPath());
1112
1308
  try {
1113
1309
  if (sub === "open") {
@@ -1218,7 +1414,7 @@ try {
1218
1414
  }
1219
1415
  const issueText = flag(argv, "issue");
1220
1416
  const issue = issueText === undefined ? undefined : issueArg("friction --issue", issueText);
1221
- const project = findProject(loadConfig(), flag(argv, "project"));
1417
+ const project = findProject(loadConfig(), projectFlag);
1222
1418
  const store = openStore(dbPath());
1223
1419
  try {
1224
1420
  store.recordFriction(project.name, {
@@ -1235,70 +1431,18 @@ try {
1235
1431
  break;
1236
1432
  }
1237
1433
 
1238
- case "pause":
1239
- setPaused(true, { source: "pause", reason: flag(argv, "reason") });
1240
- process.stdout.write(
1241
- "paused no new work will be claimed\n" +
1242
- "note: ticks keep firing if armed; use hold to silence both\n",
1243
- );
1244
- break;
1245
-
1246
- case "resume":
1247
- releaseHold();
1248
- process.stdout.write(
1249
- "resumed — claiming allowed on the next tick\n" +
1250
- "note: did NOT re-arm; run arm after an inbound Telegram proof to resume ticks\n",
1251
- );
1252
- break;
1253
-
1254
- case "graph-setup": {
1255
- // Refused rather than accommodated. Under sudo every path this command
1256
- // derives — the config it loads, the state directory it writes to, the
1257
- // HOME and User= it bakes into the unit — resolves as root instead of the
1258
- // fleet's account, and the result is a timer that goes green while
1259
- // building indexes in a store no worker session ever reads. Nothing about
1260
- // that announces itself, so the only safe answer is to stop.
1261
- if (process.env["SUDO_USER"] !== undefined) {
1262
- process.stderr.write(
1263
- "omp-conductor: run graph-setup as the account the fleet runs as, not under sudo.\n" +
1264
- `Under sudo the config, ~/.cache and the unit's User= all resolve as root, and the\n` +
1265
- `indexes land where no worker can read them. Only installing the units needs root,\n` +
1266
- "and this command prints those two lines for you at the end.\n",
1267
- );
1268
- process.exit(1);
1269
- }
1270
-
1271
- const project = findProject(loadConfig(), flag(argv, "project"));
1272
- if (graphRepos(project).length === 0) {
1273
- // Not a warning: with nothing configured there is nothing to print, and
1274
- // the fix is a wizard answer rather than a flag on this command.
1275
- process.stderr.write(
1276
- `omp-conductor: no repo in project "${project.name}" has graphProject set — re-run\n` +
1277
- "/conductor setup and say yes to code-graph discovery.\n",
1278
- );
1279
- process.exit(1);
1280
- }
1281
-
1282
- if (!argv.includes("--write")) {
1283
- process.stdout.write(`${formatGraphSetup(project)}\n`);
1284
- break;
1285
- }
1286
-
1287
- let result: GraphSetupWrite;
1288
- try {
1289
- result = writeGraphSetup(project);
1290
- } catch (err) {
1291
- // No longer the permissions case — all three files go to this account's
1292
- // own state directory — so this is a full disk, a read-only mount or a
1293
- // state directory someone else owns. Say what failed and offer the
1294
- // printed plan, which is a complete substitute for the write.
1295
- process.stderr.write(
1296
- `omp-conductor: could not stage the files (${err instanceof Error ? err.message : String(err)}).\n` +
1297
- "Drop --write and copy the printed text yourself — it is the same content.\n",
1434
+ case "resume": {
1435
+ for (const project of targetProjects()) {
1436
+ releaseHold(project.name);
1437
+ const pin = clearPaneHaltIfResolvable(project.name);
1438
+ process.stdout.write(
1439
+ "resumed — claiming allowed on the next tick\n" +
1440
+ (pin.wasHalted
1441
+ ? `pane recovery pin cleared — ${pin.path}\n`
1442
+ : "no pane recovery pin to clear\n") +
1443
+ "ticks stay disarmed — run `omp-conductor arm`\n",
1298
1444
  );
1299
- process.exit(1);
1300
1445
  }
1301
- process.stdout.write(`wrote:\n${result.written.map((f) => ` ${f}`).join("\n")}\n\n${result.next}\n`);
1302
1446
  break;
1303
1447
  }
1304
1448
 
@@ -1307,12 +1451,12 @@ try {
1307
1451
  let project: ProjectConfig | undefined;
1308
1452
  let path: string;
1309
1453
  if (override === undefined) {
1310
- project = findProject(loadConfig(), flag(argv, "project"));
1454
+ project = findProject(loadConfig(), projectFlag);
1311
1455
  path = briefPathForProject(project);
1312
1456
  } else {
1313
1457
  path = override;
1314
1458
  try {
1315
- project = findProject(loadConfig(), flag(argv, "project"));
1459
+ project = findProject(loadConfig(), projectFlag);
1316
1460
  } catch {
1317
1461
  project = undefined;
1318
1462
  }
@@ -1437,7 +1581,7 @@ try {
1437
1581
  } catch {
1438
1582
  process.stderr.write(
1439
1583
  `omp-conductor: no brief at ${path}` +
1440
- `${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
1584
+ `${override === undefined ? " — run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md." : "."}\n`,
1441
1585
  );
1442
1586
  process.exit(1);
1443
1587
  }