omp-conductor 0.3.24 → 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]
@@ -94,13 +100,14 @@ usage:
94
100
  omp-conductor release-pane [--project NAME]
95
101
  omp-conductor tail <issue> [--project NAME]
96
102
  omp-conductor extend <issue> --turns N [--project NAME]
97
- omp-conductor unblock <issue> [--project NAME]
103
+ omp-conductor unblock <issue> [--force] [--project NAME]
98
104
  omp-conductor daemon [--once] [--port N] [--project NAME]
99
105
  omp-conductor pause
100
106
  omp-conductor resume
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
@@ -150,6 +162,15 @@ usage:
150
162
  and why the brief's "never hand-edit a state label" rule can stay
151
163
  absolute. Run history is kept; answered blocks consume the separate
152
164
  operational-continuation budget, not failed implementation attempts.
165
+ Refuses when the newest attempt's work could not be committed and
166
+ its worktree is the only copy: re-claiming removes that tree. Use
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.
153
174
  friction record a bounded observation the daemon cannot classify itself:
154
175
  an escalation that belonged in a digest, or a tick report that was
155
176
  noise/surprising. Repeated observations feed the existing Learning
@@ -508,6 +529,91 @@ try {
508
529
  break;
509
530
  }
510
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
+
511
617
  case "board":
512
618
  await runBoard(flag(argv, "project"));
513
619
  break;
@@ -636,8 +742,62 @@ try {
636
742
  const project = findProject(cfg, flag(argv, "project"));
637
743
  const store = openStore(dbPath());
638
744
  try {
639
- const outcome = await unblockIssue(project, makeTracker(project), store, issue);
745
+ const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
746
+ force: argv.includes("--force"),
747
+ });
640
748
  process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
749
+ // A refusal must not read as success to a script or a board keypress.
750
+ if (outcome.refused !== undefined) process.exitCode = 3;
751
+ } finally {
752
+ store.close();
753
+ }
754
+ break;
755
+ }
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
+ );
641
801
  } finally {
642
802
  store.close();
643
803
  }