omp-conductor 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1918,6 +1918,7 @@ omp-conductor release-pane [--project NAME]
1918
1918
  omp-conductor tail <issue> [--project NAME]
1919
1919
  omp-conductor extend <issue> --turns N [--project NAME]
1920
1920
  omp-conductor unblock <issue> [--force] [--project NAME]
1921
+ omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
1921
1922
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
1922
1923
  omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
1923
1924
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
@@ -1949,6 +1950,7 @@ omp-conductor help
1949
1950
  | `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
1950
1951
  | `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
1951
1952
  | `unblock <issue> [--force]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label, and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
1953
+ | `verb <conductor_*> [--arg k=v ...]` | Run one [mediated verb](#the-mediated-verbs-126) as the orchestrator, from the CLI — the external-orchestrator half of the verb surface. Every argument goes in as a `--arg k=v` string; an orchestrator can merge (`conductor_pr_merge`), label (`conductor_label`), release (`conductor_release`), update a branch (`conductor_pr_update_branch`) or title/body (`conductor_pr_update`), or read PR state (`conductor_pr_status`). The daemon applies the same checks and writes the same ledger rows a session's call would; a missing `--arg` is refused exactly as a missing tool argument is, worker-only verbs (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed`, and a refusal exits `3`. An unknown verb exits `2`. |
1952
1954
  | `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
1953
1955
  | `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The text is persisted **before** anything is sent and the command prints a report id; the daemon then owns delivery, retries on a bounded backoff, and records the Telegram message id it actually got back. Delivery is [at-least-once](#report-delivery-the-outbox) — a crash mid-send is retried and the retry says it may be a repeat — so `delivered` is never proof of exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger rather than from what the session remembers sending; an unknown `--kind` exits `2` rather than quietly becoming a material report. Anything undelivered shows in `status` with its age. |
1954
1956
  | `decision open --question TEXT [--blocks TEXT] [--resolves-when COND]` | Record a question the orchestrator has put to you, and print its id. A question that lives only in a session's context is lost at the next compaction — after which it is either asked twice or dropped silently. `--resolves-when` attaches a machine-checkable condition: `pr-merged:<https url>`, `issue-closed:<n>`, or `npm-version:<pkg>@<version>`; anything else exits `2` listing the three forms. See [The decision ledger](#the-decision-ledger-136). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -55,7 +55,12 @@ import { dirname, join } from "node:path";
55
55
  * says whether it can be called must name the same thing. */
56
56
  export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
57
57
 
58
- export type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
58
+ export type ApprovalSurface =
59
+ | { kind: "ready"; /** `notifyMode` from access.json, when the surface read it
60
+ (#169): whether end-of-turn text reaches the operator's
61
+ chat, so the tick's delivery rule can say so truthfully. */
62
+ notifyMode?: string }
63
+ | { kind: "missing"; reason: string };
59
64
 
60
65
  /** Whether the Telegram bridge is running the headless contract, or merely
61
66
  * configured well enough to answer a question.
@@ -257,7 +262,7 @@ export function readApprovalSurface(path: string): ApprovalSurface {
257
262
  "set notifyChat to the paired owner id",
258
263
  };
259
264
  }
260
- return { kind: "ready" };
265
+ return { kind: "ready", notifyMode: mode === undefined ? undefined : String(mode) };
261
266
  }
262
267
 
263
268
  // A group destination needs a policy entry, and that policy must admit the
@@ -283,7 +288,7 @@ export function readApprovalSurface(path: string): ApprovalSurface {
283
288
  `paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
284
289
  };
285
290
  }
286
- return { kind: "ready" };
291
+ return { kind: "ready", notifyMode: mode === undefined ? undefined : String(mode) };
287
292
  }
288
293
 
289
294
  /**
package/src/board.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, openSync, readSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { emitKeypressEvents } from "node:readline";
4
4
  import { findProject, loadConfig, resolveCaps } from "./config.ts";
@@ -16,10 +16,21 @@ import { dbPath, openStore } from "./store.ts";
16
16
  import { formatTranscriptLine } from "./transcript.ts";
17
17
  import { makeTracker } from "./tracker/github.ts";
18
18
  import { planUsageBadge, readPlanUsage, sharedUsageSource } from "./usage.ts";
19
- import type { AdmissionHoldReason, ProjectConfig, RunRecord, RunState, Store } from "./types.ts";
19
+ import type {
20
+ AdmissionHoldReason,
21
+ PrVerification,
22
+ ProjectConfig,
23
+ RunRecord,
24
+ RunState,
25
+ Store,
26
+ } from "./types.ts";
20
27
 
21
28
  const REFRESH_MS = 1_000;
22
29
  const HEALTH_REFRESH_MS = 10_000;
30
+ /** Cadence for re-verifying pushed rows against GitHub — deliberately slower
31
+ * than the board's health/label probe: it is one `gh` call per pushed row, and
32
+ * pull-request state does not move at 1 Hz. #173. */
33
+ const PR_REFRESH_MS = 60_000;
23
34
  const MERGED_HISTORY_MS = 24 * 60 * 60_000;
24
35
  const TRANSCRIPT_BYTES = 64 * 1024;
25
36
  const MIN_WIDTH = 50;
@@ -53,6 +64,7 @@ const COLUMN_DEFS = [
53
64
  { key: "blocked", title: "BLOCKED" },
54
65
  { key: "failed", title: "FAILED" },
55
66
  { key: "orphaned", title: "ORPHANED" },
67
+ { key: "parked", title: "PARKED" },
56
68
  { key: "merged", title: "MERGED" },
57
69
  { key: "history", title: "HISTORY" },
58
70
  ] as const satisfies readonly { key: string; title: string }[];
@@ -69,6 +81,23 @@ const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
69
81
  "pushed-green": "green",
70
82
  };
71
83
 
84
+ /** Terminal run states that park an issue nobody has acted on: a human cleared
85
+ * the state labels but the issue is still open, so its last run is the story
86
+ * that has to stay visible — #173. Differs from {@link LIVE_LANES} in that
87
+ * these are rows, never current work, and from MERGED in that they are not a
88
+ * happy resolution. */
89
+ const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "orphaned"]);
90
+
91
+ /** Whether a terminal blocked/failed run is wearing its own state label. When
92
+ * the label is absent the run row is the only record of what happened, so the
93
+ * card and detail header say "last run: <state>" rather than presenting it as
94
+ * current — #173, the same distinction that parks the lane. */
95
+ function isLastRun(run: RunRecord, labels: BoardLabels): boolean {
96
+ if (run.state === "blocked") return !labels.blocked.has(run.issue);
97
+ if (run.state === "failed") return !labels.failed.has(run.issue);
98
+ return false;
99
+ }
100
+
72
101
  type DaemonBoardState = "stopped" | "ok" | "unreachable" | "other-project";
73
102
 
74
103
  /**
@@ -114,6 +143,11 @@ export interface BoardLabels {
114
143
  inProgress: ReadonlySet<number>;
115
144
  blocked: ReadonlySet<number>;
116
145
  failed: ReadonlySet<number>;
146
+ /** Issues whose newest run is terminal and unlabelled but that are still open
147
+ * on the tracker — the PARKED lane (#173), probed on the same 10s cadence as
148
+ * the label sets. An issue missing from this set (closed, or the probe could
149
+ * not tell) keeps today's HISTORY/undefined behaviour rather than parking. */
150
+ open: ReadonlySet<number>;
117
151
  /** Epoch ms of the last read that succeeded; 0 when none has. */
118
152
  readAt: number;
119
153
  /** Message from the most recent failed read. The sets above are then the last
@@ -129,6 +163,7 @@ const UNREAD_LABELS: BoardLabels = {
129
163
  inProgress: new Set<number>(),
130
164
  blocked: new Set<number>(),
131
165
  failed: new Set<number>(),
166
+ open: new Set<number>(),
132
167
  readAt: 0,
133
168
  };
134
169
 
@@ -137,10 +172,18 @@ export interface BoardSnapshot {
137
172
  status: StatusSnapshot;
138
173
  health: BoardHealth;
139
174
  labels: BoardLabels;
175
+ /** Live re-verification of pushed rows (#173): run id → what the tracker says
176
+ * the PR looks like now. Rendered as a `now:` suffix on the card. */
177
+ pr: ReadonlyMap<string, { status: PrVerification["status"]; reason: string }>;
140
178
  runs: RunRecord[];
141
179
  now: number;
142
180
  }
143
181
 
182
+ /** The card state a {@link PrVerification.status} reads as in the UI. */
183
+ function prLabel(status: PrVerification["status"]): string {
184
+ return status === "green" ? "green" : status === "failed" ? "red" : "pending";
185
+ }
186
+
144
187
  export interface BoardCursor {
145
188
  column: number;
146
189
  card: number;
@@ -166,6 +209,7 @@ function ansiColor(key: BoardLane): string {
166
209
  case "claimed":
167
210
  case "blocked":
168
211
  case "orphaned":
212
+ case "parked":
169
213
  return YELLOW;
170
214
  case "failed":
171
215
  return RED;
@@ -266,6 +310,13 @@ function laneOf(
266
310
  if (queued.has(issue)) return "queue";
267
311
  if (run?.state === "merged") return "merged";
268
312
  if (run === undefined) return undefined;
313
+ // PARKED (#173): a terminal run row with no state or queue label left on it,
314
+ // on an issue the tracker says is still open. A human cleared the labels but
315
+ // has not acted — the blank lane the four labels used to imply is a lie, and
316
+ // closed or unreadable issues keep the HISTORY/undefined fallthrough below.
317
+ // Deliberately not subject to MERGED_HISTORY_MS: parked work does not stop
318
+ // being the issue's last word after 24 hours.
319
+ if (PARKED_STATES.has(run.state) && labels.open.has(issue)) return "parked";
269
320
  // What is left is a terminal run row with no current claim on it — a closed
270
321
  // issue, or one a human already cleared. Recent rows stay readable under
271
322
  // HISTORY for the same 24 hours MERGED uses; older ones are not news.
@@ -424,6 +475,21 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
424
475
  `attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
425
476
  `$${run.spendUsd.toFixed(2)} · ${duration}`,
426
477
  ];
478
+ // #173: a live re-verification of a pushed row, when the 60s probe has one.
479
+ // Guarded on the pushed states: a stale entry must not follow a row that
480
+ // settled into another lane and keep claiming "now: green" about a PR that
481
+ // already merged or failed.
482
+ const live =
483
+ run.state === "pushed-green" || run.state === "pushed-pending" ? snapshot.pr.get(run.id) : undefined;
484
+ if (live !== undefined) {
485
+ const reason = live.reason.split(/\s+/, 1)[0] ?? "";
486
+ lines.push(
487
+ live.status === "failed" ? `now: red — ${reason === "" ? "failed" : reason}` : `now: ${prLabel(live.status)}`,
488
+ );
489
+ }
490
+ // #173: a terminal blocked/failed run whose state label is gone reads as the
491
+ // lane it sits in — which the label being absent made PARKED, not the truth.
492
+ if (isLastRun(run, snapshot.labels)) lines.push(`last run: ${run.state}`);
427
493
  // Ordered by what needs a human first. An unsalvaged tree outranks even a
428
494
  // last error: the error describes a run that is over, the tree is work that
429
495
  // is still at risk and an issue that will not dispatch (#118).
@@ -567,6 +633,9 @@ function renderBoardColumns(snapshot: BoardSnapshot, cursor: BoardCursor, width:
567
633
 
568
634
  function readTranscript(run: RunRecord): string[] {
569
635
  if (run.sessionFile === undefined) return [`No transcript yet (state: ${run.state}).`];
636
+ // #172: a row name that never materialised is "not yet", not a read error —
637
+ // rendering it as `Transcript unavailable` would blame the file for existing.
638
+ if (!existsSync(run.sessionFile)) return [`No transcript yet (state: ${run.state}).`];
570
639
  let fd: number | undefined;
571
640
  try {
572
641
  const size = statSync(run.sessionFile).size;
@@ -617,8 +686,11 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
617
686
  }
618
687
 
619
688
  const run = card.run;
689
+ // #173: a blocked/failed run whose label is gone is the reason this card is
690
+ // parked; the header says so rather than presenting the state as current.
691
+ const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
620
692
  const metadata = [
621
- styledCell(` RUN #${run.issue} ${run.repo} ${run.state} `, width, `${BOLD}${REVERSE}`),
693
+ styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
622
694
  styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width),
623
695
  styledCell(`branch ${run.branch}`, width, DIM),
624
696
  styledCell(`worktree ${run.worktree || "removed"}`, width, DIM),
@@ -739,7 +811,11 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
739
811
  * why, because blanking QUEUE and FAILED is a louder lie than showing them
740
812
  * stale — {@link labelFact} puts the staleness in the header.
741
813
  */
742
- async function probeBoardLabels(project: ProjectConfig, previous?: BoardLabels): Promise<BoardLabels> {
814
+ async function probeBoardLabels(
815
+ project: ProjectConfig,
816
+ store: Store,
817
+ previous?: BoardLabels,
818
+ ): Promise<BoardLabels> {
743
819
  const openIssuesLabelled = async (label: string): Promise<Set<number>> =>
744
820
  new Set((await makeTracker({ ...project, queueLabel: label }).listReady()).map((issue) => issue.number));
745
821
  try {
@@ -749,7 +825,26 @@ async function probeBoardLabels(project: ProjectConfig, previous?: BoardLabels):
749
825
  openIssuesLabelled(project.stateLabels.blocked),
750
826
  openIssuesLabelled(project.stateLabels.failed),
751
827
  ]);
752
- return { queued, inProgress, blocked, failed, readAt: Date.now() };
828
+ // PARKED candidates: the newest run per issue, terminal, non-merged, and
829
+ // wearing no state or queue label. Exactly the rows {@link laneOf} would
830
+ // otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit,
831
+ // issued in parallel, and cached: a failed read keeps the last set.
832
+ const labelled = new Set([...queued, ...inProgress, ...blocked, ...failed]);
833
+ const newestRun = new Map<number, RunRecord>();
834
+ for (const run of store.recentRuns(project.name, Date.now() - MERGED_HISTORY_MS)) {
835
+ if (!newestRun.has(run.issue)) newestRun.set(run.issue, run);
836
+ }
837
+ const parkedCandidates = [...newestRun.values()].filter(
838
+ (run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue),
839
+ );
840
+ const open = new Set<number>();
841
+ await Promise.all(
842
+ parkedCandidates.map(async (run) => {
843
+ const state = await makeTracker(project).issueState(run.issue);
844
+ if (state === "open") open.add(run.issue);
845
+ }),
846
+ );
847
+ return { queued, inProgress, blocked, failed, open, readAt: Date.now() };
753
848
  } catch (err) {
754
849
  return {
755
850
  ...(previous ?? UNREAD_LABELS),
@@ -827,11 +922,17 @@ export async function runBoard(projectName?: string): Promise<void> {
827
922
  let notice = "";
828
923
  let [health, labels, planUsage] = await Promise.all([
829
924
  probeBoardHealth(project),
830
- probeBoardLabels(project),
925
+ probeBoardLabels(project, store),
831
926
  readPlanUsage(caps.planUsage, sharedUsageSource()),
832
927
  ]);
833
928
  let healthAt = Date.now();
834
929
  let healthRefresh: Promise<void> | undefined;
930
+ // Live re-verification of pushed rows (#173): cached per run id, refreshed on
931
+ // the slow PR cadence so a `gh` call per pushed row does not ride the 1 Hz
932
+ // repaint or the 10 s health gate.
933
+ const prProbe = new Map<string, { status: PrVerification["status"]; reason: string }>();
934
+ let prProbeAt = 0;
935
+ let prProbeRun: Promise<void> | undefined;
835
936
 
836
937
  emitKeypressEvents(process.stdin);
837
938
  const onKey = (_text: string, key: KeyInput): void => enqueue(queue, key, wake);
@@ -855,7 +956,7 @@ export async function runBoard(projectName?: string): Promise<void> {
855
956
  healthAt = now;
856
957
  healthRefresh = Promise.all([
857
958
  probeBoardHealth(project),
858
- probeBoardLabels(project, labels),
959
+ probeBoardLabels(project, store, labels),
859
960
  // On the health cadence, not the 1s repaint: the provider read is a
860
961
  // subprocess, and an allowance does not move at 1 Hz.
861
962
  readPlanUsage(caps.planUsage, sharedUsageSource()),
@@ -873,11 +974,37 @@ export async function runBoard(projectName?: string): Promise<void> {
873
974
  healthRefresh = undefined;
874
975
  });
875
976
  }
977
+ // #173: re-verify pushed rows against the tracker on their own slow
978
+ // cadence, so red-shown-as-green and a moved head surface without paying
979
+ // for the read every second. A failed probe leaves the last verdicts in
980
+ // place (blanking a card's `now:` is a quieter lie than nothing).
981
+ if (now - prProbeAt >= PR_REFRESH_MS && prProbeRun === undefined) {
982
+ prProbeAt = now;
983
+ const rows = store.recentRuns(project.name, now - MERGED_HISTORY_MS).filter(
984
+ (run) => (run.state === "pushed-green" || run.state === "pushed-pending") && run.prUrl !== undefined && run.headSha !== undefined,
985
+ );
986
+ prProbeRun = (async () => {
987
+ const tracker = makeTracker(project);
988
+ await Promise.all(
989
+ rows.map(async (run) => {
990
+ const verified = await tracker.verifyPr(run.prUrl!, run.headSha!);
991
+ if (verified !== undefined) prProbe.set(run.id, { status: verified.status, reason: verified.reason });
992
+ }),
993
+ );
994
+ })()
995
+ .catch((err: unknown) => {
996
+ notice = `PR probe failed: ${err instanceof Error ? err.message : String(err)}`;
997
+ })
998
+ .finally(() => {
999
+ prProbeRun = undefined;
1000
+ });
1001
+ }
876
1002
  const snapshot: BoardSnapshot = {
877
1003
  project,
878
1004
  status: statusSnapshotFromStore(project, caps, store, planUsage),
879
1005
  health,
880
1006
  labels,
1007
+ pr: prProbe,
881
1008
  runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
882
1009
  now,
883
1010
  };
@@ -290,6 +290,22 @@ what you asked for and what it decided — readable with `omp-conductor ledger`,
290
290
  including the refusals. That record is the point: a merge or a release the
291
291
  dispatcher did not perform is one nobody can account for afterwards.
292
292
 
293
+ An external orchestrator (a session outside the daemon) runs the same verbs, with
294
+ the same checks and the same ledger rows, through the CLI:
295
+
296
+ ```
297
+ omp-conductor verb conductor_pr_merge --arg prUrl=<url> --arg headSha=<sha> --arg reason=<reason>
298
+ omp-conductor verb conductor_label --arg issueUrl=<url> --arg label=<name> --arg action=add --arg reason=<reason>
299
+ omp-conductor verb conductor_release --arg shape=git-tag --arg repo=<name> --arg reason=<reason> --arg tag=<tag>
300
+ omp-conductor verb conductor_pr_update_branch --arg prUrl=<url>
301
+ omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
302
+ ```
303
+
304
+ Each `--arg k=v` is one argument, read as a string. Worker-only verbs
305
+ (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed` —
306
+ an external orchestrator never pushes or opens a PR, exactly as this floor's
307
+ sessions do not.
308
+
293
309
  | Tool | Yours when | What the dispatcher checks before acting |
294
310
  | --- | --- | --- |
295
311
  | `conductor_pr_status` | always | Nothing to gate: it reads. |
package/src/cli.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from "./brief-upgrade.ts";
24
24
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
25
25
  import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
26
- import { runDaemon, setPaused } from "./daemon.ts";
26
+ import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
27
27
  import {
28
28
  armTicks,
29
29
  clearPaneHalt,
@@ -58,7 +58,9 @@ import { dbPath, LIVE_STATES, openStore } from "./store.ts";
58
58
  import { formatTranscriptLine } from "./transcript.ts";
59
59
  import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
60
60
  import { makeTracker } from "./tracker/github.ts";
61
- import { REPORT_KINDS } from "./types.ts";
61
+ import { githubVerbActions } from "./verbs/actions.ts";
62
+ import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
63
+ import { REPORT_KINDS, VERB_NAMES } from "./types.ts";
62
64
  import type { ProjectConfig, ReportKind } from "./types.ts";
63
65
  import { formatUnblock, unblockIssue } from "./unblock.ts";
64
66
  import { upgradeConductor } from "./upgrade.ts";
@@ -100,6 +102,7 @@ usage:
100
102
  omp-conductor tail <issue> [--project NAME]
101
103
  omp-conductor extend <issue> --turns N [--project NAME]
102
104
  omp-conductor unblock <issue> [--force] [--project NAME]
105
+ omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
103
106
  omp-conductor daemon [--once] [--port N] [--project NAME]
104
107
  omp-conductor pause
105
108
  omp-conductor resume
@@ -168,6 +171,13 @@ usage:
168
171
  Refuses when the newest attempt's work could not be committed and
169
172
  its worktree is the only copy: re-claiming removes that tree. Use
170
173
  --force once you have recovered it or accepted the loss.
174
+ verb run one conductor_* verb as the orchestrator, from the CLI: the same
175
+ checks and the same ledger rows a session's call would get. This is
176
+ how an external orchestrator merges, labels, releases or reads PR
177
+ state without a raw gh call that skips every gate. Arguments are
178
+ strings, one per --arg (e.g. --arg prUrl=https://x --arg headSha=y).
179
+ A refusal exits 3. See conductor_pr_merge/conductor_label/
180
+ conductor_release/conductor_pr_update_branch in the brief.
171
181
  report hand a rendered report to the daemon's durable outbox. The report is
172
182
  persisted before anything is sent, delivered by the daemon with
173
183
  bounded retries, and shown by status until it lands. Delivery is
@@ -732,6 +742,89 @@ try {
732
742
  break;
733
743
  }
734
744
 
745
+ /**
746
+ * The external-orchestrator half of the verbs (#167): run any
747
+ * `conductor_*` verb from the CLI, through the daemon's own checks and
748
+ * ledger. An orchestrator that merges, labels or releases through this
749
+ * gets exactly the same refusals a session would and writes the same
750
+ * ledger rows — the raw `gh pr merge` a session reaches for instead is
751
+ * invisible to both.
752
+ *
753
+ * Worker-only verbs (conductor_push, conductor_pr_create) refuse with
754
+ * `role-not-allowed` — correct, and deliberately not special-cased here.
755
+ *
756
+ * Accepted limitation: the daemon's in-process one-merge-per-project slot
757
+ * does not span the daemon and a concurrent CLI merge. The execution-time
758
+ * head re-read (`--match-head-commit` in prMergeVerb) is the cross-process
759
+ * guard, so a CLI merge races a daemon merge exactly as two daemon merges
760
+ * would.
761
+ */
762
+ case "verb": {
763
+ const name = argv[1];
764
+ if (name === undefined || !VERB_NAMES.some((n) => n === name)) {
765
+ process.stderr.write(
766
+ `omp-conductor: unknown verb "${name ?? ""}". Known verbs: ${VERB_NAMES.join(", ")}\n`,
767
+ );
768
+ process.exit(2);
769
+ }
770
+ const cfg = loadConfig();
771
+ const project = findProject(cfg, flag(argv, "project"));
772
+ const args: Record<string, string> = {};
773
+ for (let i = 2; i < argv.length; i++) {
774
+ const token = argv[i];
775
+ if (token === "--arg") {
776
+ const pair = argv[i + 1];
777
+ if (pair === undefined || !pair.includes("=")) {
778
+ process.stderr.write(`omp-conductor: verb --arg needs k=v, got "${pair ?? ""}"\n`);
779
+ process.exit(2);
780
+ }
781
+ const eq = pair.indexOf("=");
782
+ args[pair.slice(0, eq)] = pair.slice(eq + 1);
783
+ i++;
784
+ } else if (token === "--project" || token?.startsWith("--project=") === true) {
785
+ // Consumed by `flag(argv, "project")` above; both spellings skip here.
786
+ if (token === "--project") i++;
787
+ } else {
788
+ process.stderr.write(
789
+ `omp-conductor: verb: unexpected argument "${token ?? ""}" (pass verb arguments as --arg k=v)\n`,
790
+ );
791
+ process.exit(2);
792
+ }
793
+ }
794
+ const store = openStore(dbPath());
795
+ try {
796
+ const channel: VerbChannel = {
797
+ kind: "orchestrator",
798
+ path: "cli",
799
+ project: project.name,
800
+ role: "orchestrator",
801
+ };
802
+ const reply = await handleVerbCall(
803
+ {
804
+ project: () => findProject(loadConfig(), project.name),
805
+ store,
806
+ tracker: makeTracker(project),
807
+ actions: githubVerbActions(project),
808
+ fleetStop: () =>
809
+ isPaused()
810
+ ? "claiming is paused for this fleet (omp-conductor pause, hold or halt)"
811
+ : undefined,
812
+ pausedAt,
813
+ log: (m) => process.stderr.write(`${m}\n`),
814
+ now: () => Date.now(),
815
+ },
816
+ channel,
817
+ { verb: name, args },
818
+ );
819
+ process.stdout.write(`${reply.text}\n`);
820
+ // A refusal must not read as success to a script calling this.
821
+ if (!reply.ok) process.exitCode = 3;
822
+ } finally {
823
+ store.close();
824
+ }
825
+ break;
826
+ }
827
+
735
828
  /**
736
829
  * The handover point. Authorship stays with the model; from here the daemon
737
830
  * owns delivery, so "I sent the report" stops being a claim the model makes