omp-conductor 0.3.25 → 0.4.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.
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * cannot drift apart.
7
7
  */
8
8
  import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
+ import { userInfo } from "node:os";
9
10
  import { dirname, join } from "node:path";
10
11
  import { runBoard } from "./board.ts";
11
12
  import {
@@ -21,7 +22,8 @@ import {
21
22
  repairPolicyBannerCrumbs,
22
23
  writeMergedBrief,
23
24
  } from "./brief-upgrade.ts";
24
- import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
25
+ import { findProject, loadConfig, resolveCaps, sharedRoot, stateDir } from "./config.ts";
26
+ import { boundarySetupScript } from "./credentials.ts";
25
27
  import { runDaemon, setPaused } from "./daemon.ts";
26
28
  import {
27
29
  armTicks,
@@ -45,6 +47,7 @@ import {
45
47
  writeRecord,
46
48
  } from "./lifecycle.ts";
47
49
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
50
+ import { digestDedupeKey } from "./reports.ts";
48
51
  import {
49
52
  briefPathForProject,
50
53
  policyPathForProject,
@@ -54,8 +57,10 @@ import {
54
57
  } from "./setup.ts";
55
58
  import { dbPath, LIVE_STATES, openStore } from "./store.ts";
56
59
  import { formatTranscriptLine } from "./transcript.ts";
60
+ import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
57
61
  import { makeTracker } from "./tracker/github.ts";
58
- import type { ProjectConfig } from "./types.ts";
62
+ import { REPORT_KINDS } from "./types.ts";
63
+ import type { ProjectConfig, ReportKind } from "./types.ts";
59
64
  import { formatUnblock, unblockIssue } from "./unblock.ts";
60
65
  import { upgradeConductor } from "./upgrade.ts";
61
66
 
@@ -87,6 +92,7 @@ usage:
87
92
  omp-conductor upgrade [--to VERSION] [--project NAME]
88
93
  omp-conductor board [--project NAME]
89
94
  omp-conductor status [--project NAME]
95
+ omp-conductor ledger [--issue N] [--limit N] [--project NAME]
90
96
  omp-conductor hold [--project NAME]
91
97
  omp-conductor halt [--pane] [--project NAME]
92
98
  omp-conductor arm [--project NAME]
@@ -101,6 +107,7 @@ usage:
101
107
  omp-conductor graph-setup [--project NAME] [--write]
102
108
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
103
109
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
110
+ omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
104
111
  omp-conductor help
105
112
 
106
113
  upgrade update the Bun-global CLI, omp plugin, Herdr recovery plugin, and
@@ -124,6 +131,11 @@ usage:
124
131
  board open the live keyboard-driven fleet board. It renders queue holds,
125
132
  every run lifecycle stage, recent merges, spend and health; Enter
126
133
  follows a selected transcript without leaving the board.
134
+ ledger every conductor-verb call and how the daemon decided it: the verb,
135
+ the arguments, allow or refuse, the named refusal reason, and the
136
+ resulting sha. Sessions cannot push, open, merge, label or release
137
+ except through those verbs, so this is the record of what they tried
138
+ as well as what they did. --issue narrows it to one issue's run.
127
139
  hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
128
140
  This is "stop the conductor overnight" without killing processes.
129
141
  halt hold, then stop the dispatch daemon (systemctl-aware). Pane stays up
@@ -153,6 +165,12 @@ usage:
153
165
  Refuses when the newest attempt's work could not be committed and
154
166
  its worktree is the only copy: re-claiming removes that tree. Use
155
167
  --force once you have recovered it or accepted the loss.
168
+ report hand a rendered report to the daemon's durable outbox. The report is
169
+ persisted before anything is sent, delivered by the daemon with
170
+ bounded retries, and shown by status until it lands. Delivery is
171
+ at-least-once: a crash mid-send is retried and the retry says it may
172
+ be a repeat. --kind digest is accepted at most once per local day,
173
+ decided from the ledger rather than from what you remember sending.
156
174
  friction record a bounded observation the daemon cannot classify itself:
157
175
  an escalation that belonged in a digest, or a tick report that was
158
176
  noise/surprising. Repeated observations feed the existing Learning
@@ -511,6 +529,91 @@ try {
511
529
  break;
512
530
  }
513
531
 
532
+ case "boundary-setup": {
533
+ // Printed, never executed: it creates system accounts, so it is the
534
+ // operator's `sudo` and their chance to read it first — same posture as
535
+ // `graph-setup`. Generated from the same constants the probe checks, so
536
+ // the instructions cannot drift from what the daemon then demands (#125).
537
+ //
538
+ // Nothing is read from the config unless a flag was omitted, and that is
539
+ // the point rather than an optimisation: per-run provisioning has to
540
+ // happen BEFORE `setup`, because setup writes worktree and mirror paths
541
+ // into the privileged shared root this command creates. Loading the
542
+ // config unconditionally would be a bootstrap cycle — provision needs a
543
+ // config, the config needs the provisioned root.
544
+ const slotsFlag = flag(argv, "slots");
545
+ let slots: number;
546
+ if (slotsFlag === undefined) {
547
+ const cfg = loadConfig();
548
+ slots = resolveCaps(findProject(cfg, flag(argv, "project")), cfg.defaults).maxConcurrentWorkers;
549
+ } else if (/^\d+$/.test(slotsFlag) && Number.parseInt(slotsFlag, 10) >= 1) {
550
+ // Whole string, not a prefix: `parseInt("2workers")` is 2, and silently
551
+ // provisioning a different pool than the operator typed is worse than
552
+ // refusing.
553
+ slots = Number.parseInt(slotsFlag, 10);
554
+ } else {
555
+ console.error(`boundary-setup: --slots must be a positive integer, got ${JSON.stringify(slotsFlag)}`);
556
+ process.exitCode = 2;
557
+ break;
558
+ }
559
+ // Both explicit where given, because neither can be inferred safely here.
560
+ // Under `sudo` the invoking user is root, so `userInfo()` would provision
561
+ // the wrong account and hand the daemon group to root; and the shared
562
+ // root must be the *same* string the daemon later resolves, not one
563
+ // derived from however this process happened to be invoked.
564
+ const daemonUser = flag(argv, "daemon-user") ?? process.env["SUDO_USER"] ?? userInfo().username;
565
+ const root = flag(argv, "shared-root") ?? sharedRoot();
566
+ process.stdout.write(boundarySetupScript({ slots, sharedRoot: root, daemonUser }));
567
+ break;
568
+ }
569
+ /**
570
+ * The action ledger (#126): every conductor-verb call, what it asked for,
571
+ * and how the daemon decided it.
572
+ *
573
+ * Its own command as well as a block in `status`, because the two questions
574
+ * are different sizes. `status` answers "is anything being refused right
575
+ * now"; this answers "what did run 3 actually try to do", which is the
576
+ * question an escalation about a run asks, and it needs the whole record
577
+ * rather than the newest five lines of every run at once.
578
+ */
579
+ case "ledger": {
580
+ const cfg = loadConfig();
581
+ const p = findProject(cfg, flag(argv, "project"));
582
+ const issueFlag = flag(argv, "issue");
583
+ const issue = issueFlag === undefined ? undefined : issueArg("ledger", issueFlag);
584
+ const limitFlag = flag(argv, "limit");
585
+ const limit = limitFlag === undefined ? 50 : Number.parseInt(limitFlag, 10);
586
+ if (!Number.isSafeInteger(limit) || limit < 1) {
587
+ process.stderr.write(`omp-conductor: ledger --limit needs a positive integer, got "${limitFlag ?? ""}"\n`);
588
+ process.exitCode = 1;
589
+ break;
590
+ }
591
+ // Read-only, WAL, same as `tail`: this never contends with the daemon
592
+ // writing the rows it is printing.
593
+ const store = openStore(dbPath());
594
+ try {
595
+ const entries = store.verbLedger(p.name, {
596
+ ...(issue === undefined ? {} : { issue }),
597
+ limit,
598
+ });
599
+ if (entries.length === 0) {
600
+ process.stdout.write(
601
+ `no conductor-verb calls recorded for ${p.name}` +
602
+ `${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
603
+ );
604
+ break;
605
+ }
606
+ const refused = entries.filter((e) => e.decision === "refused").length;
607
+ process.stdout.write(
608
+ `${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
609
+ `${entries.flatMap(formatVerbLedgerEntry).join("\n")}\n`,
610
+ );
611
+ } finally {
612
+ store.close();
613
+ }
614
+ break;
615
+ }
616
+
514
617
  case "board":
515
618
  await runBoard(flag(argv, "project"));
516
619
  break;
@@ -651,6 +754,56 @@ try {
651
754
  break;
652
755
  }
653
756
 
757
+ /**
758
+ * The handover point. Authorship stays with the model; from here the daemon
759
+ * owns delivery, so "I sent the report" stops being a claim the model makes
760
+ * about a tool call it may never have run and becomes a row anyone can read
761
+ * back out of the store (#123).
762
+ */
763
+ case "report": {
764
+ const rawText = flag(argv, "text");
765
+ const body = rawText?.trim();
766
+ if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
767
+ process.stderr.write("omp-conductor: report needs --text with the rendered report\n");
768
+ process.exit(2);
769
+ }
770
+ const rawKind = flag(argv, "kind") ?? "material";
771
+ // Fail closed on an unknown kind rather than defaulting to `material`: a
772
+ // typo'd `--kind diggest` that silently became a material report would
773
+ // bypass the daily-digest guard, which is the one thing the kind is for.
774
+ if (!(REPORT_KINDS as readonly string[]).includes(rawKind)) {
775
+ process.stderr.write(
776
+ `omp-conductor: report --kind must be one of: ${REPORT_KINDS.join(", ")}\n`,
777
+ );
778
+ process.exit(2);
779
+ }
780
+ const kind = rawKind as ReportKind;
781
+ const project = findProject(loadConfig(), flag(argv, "project"));
782
+ const store = openStore(dbPath());
783
+ try {
784
+ const at = Date.now();
785
+ const { report, deduped } = store.enqueueReport({
786
+ project: project.name,
787
+ kind,
788
+ body,
789
+ // Only the digest is at-most-once. A material report describes one
790
+ // event as it happens, and two of those in a day are two events.
791
+ ...(kind === "digest" ? { dedupeKey: digestDedupeKey(at) } : {}),
792
+ at,
793
+ });
794
+ process.stdout.write(
795
+ deduped
796
+ ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
797
+ "the ledger decides this, not your memory of the last tick; use --kind material for a second event\n"
798
+ : `report ${report.id} queued for ${project.name} (${kind})\n` +
799
+ "the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
800
+ );
801
+ } finally {
802
+ store.close();
803
+ }
804
+ break;
805
+ }
806
+
654
807
  case "friction": {
655
808
  const name = argv[1] as FrictionFeedbackName | undefined;
656
809
  if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {