omp-conductor 0.16.0 → 0.16.2

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
@@ -114,6 +114,31 @@ For what the host needs before anything runs — `bun`, an authenticated `gh`,
114
114
  `omp-telegram` for the escalation channel — see the
115
115
  [Install prerequisites](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#install-prerequisites) in the reference.
116
116
 
117
+ ### Shell completions
118
+
119
+ `omp-conductor complete` generates completions for zsh, bash, fish, and
120
+ PowerShell. Load them for the current shell:
121
+
122
+ ```bash
123
+ source <(omp-conductor complete zsh)
124
+ # or
125
+ source <(omp-conductor complete bash)
126
+ ```
127
+
128
+ For a persistent zsh install:
129
+
130
+ ```bash
131
+ mkdir -p ~/.omp/conductor
132
+ omp-conductor complete zsh > ~/.omp/conductor/completions.zsh
133
+ echo '[ -f ~/.omp/conductor/completions.zsh ] && source ~/.omp/conductor/completions.zsh' >> ~/.zshrc
134
+ ```
135
+
136
+ Use `bash` and `~/.bashrc` for the equivalent bash install. In fish, run
137
+ `omp-conductor complete fish | source`; in PowerShell, run
138
+ `omp-conductor complete powershell | Out-String | Invoke-Expression`.
139
+ After a successful interactive setup, the wizard offers to install zsh or bash
140
+ completions this way and adds the rc source line only when it is absent.
141
+
117
142
  ## Quick start
118
143
 
119
144
  1. Install `omp-conductor` and `omp-telegram`. Pair the Telegram bot and enable its bridge.
package/REFERENCE.md CHANGED
@@ -2240,6 +2240,8 @@ omp-conductor watch list [--project NAME]
2240
2240
  omp-conductor daemon [--once] [--port N] [--project NAME]
2241
2241
  omp-conductor resume [--project NAME]
2242
2242
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
2243
+ omp-conductor complete <zsh|bash|fish|powershell>
2244
+ omp-conductor complete -- <args...>
2243
2245
  omp-conductor help
2244
2246
  ```
2245
2247
 
@@ -2285,6 +2287,7 @@ omp-conductor help
2285
2287
  | `--retrofit` | — | Only for `brief-upgrade`. Propose (or with `--apply`, write) a `YOURS TO EDIT` banner before the first owned-topic heading on a hand-written brief. |
2286
2288
  | `--apply` | — | Only for `brief-upgrade`. Confirms `--migrate` / `--retrofit`. On its own it exits `2`: the legacy single-file merge was removed in 0.4.3. |
2287
2289
  | `--file PATH` | — | Only for `brief-upgrade`. Check a brief that is not where the wizard would have put it, on a host that may have no config at all. |
2290
+ | `complete <zsh\|bash\|fish\|powershell>` | none | Print a shell completion script. Source it directly for a session or save it and source the file from the shell rc. Interactive setup offers the persistent zsh/bash install; fish and PowerShell remain available manually. `complete -- <args...>` is the fast shell callback protocol: static verbs, subcommands, flags and setup areas come from the command manifest, while project names load from config and fail empty on any read error. |
2288
2291
  | `help`, `--help`, `-h` | none | Print usage. An unknown or missing verb prints it too, and exits `2`. |
2289
2292
 
2290
2293
  Pause is a sentinel under the state directory and survives a daemon restart.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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.",
@@ -33,6 +33,7 @@
33
33
  "schema": "bun run src/generate-schema.ts"
34
34
  },
35
35
  "dependencies": {
36
+ "@bomb.sh/tab": "0.0.22",
36
37
  "yaml": "^2.9.0",
37
38
  "zod": "^4"
38
39
  },
@@ -305,6 +305,7 @@
305
305
  "modelFallbacks": {},
306
306
  "modelFallbackThreshold": {},
307
307
  "ompSettings": {},
308
+ "workerAdvisor": {},
308
309
  "escalation": {
309
310
  "type": "object",
310
311
  "properties": {
package/src/admission.ts CHANGED
@@ -29,6 +29,7 @@ import type {
29
29
  } from "./types.ts";
30
30
  import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
31
31
  import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
32
+ import { repoSlugFor } from "./gitops.ts";
32
33
  import { branchName, type Routed } from "./routing.ts";
33
34
  import { parseDependsOn } from "./depends-on.ts";
34
35
 
@@ -51,6 +52,17 @@ export interface AdmissionDeps {
51
52
  escalate(e: Escalation): Promise<void>;
52
53
  probeCriticalBase?: CriticalBaseProbe;
53
54
  probeWorktreeLane?: RunLaneProbe;
55
+ /**
56
+ * Reads one issue's tracker state in a repository the admission tracker is
57
+ * NOT bound to — the cross-repo half of the Depends-on interlock (#420).
58
+ * The tracker bound to `project.tracker.repo` can only answer same-repo
59
+ * references; a `owner/repo#n` prerequisite naming a different routed repo
60
+ * goes through this. Undefined means "could not tell" (same posture as
61
+ * {@link Tracker.issueSnapshot}); a routed prerequisite it cannot answer
62
+ * fails that candidate closed. Unset, admission fails a routed cross-repo
63
+ * prerequisite closed too — the daemon always wires it.
64
+ */
65
+ probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
54
66
  }
55
67
  /** `stops` are the operational ends that each require one resume. */
56
68
  export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
@@ -190,6 +202,22 @@ function isPathLike(token: string): boolean {
190
202
  return token !== "" && !/\s/.test(token) && (token.includes("/") || /\.[A-Za-z0-9]{1,10}$/.test(token));
191
203
  }
192
204
 
205
+ /**
206
+ * Where a `owner/repo` cross-repo reference can be read. Only repositories
207
+ * routed in THIS fleet are resolvable (#420): the issue source repo
208
+ * (`project.tracker.repo`) is answered by the existing tracker, and any routed
209
+ * work repo by {@link AdmissionDeps.probeIssueIn}. Anything else is unresolved
210
+ * — the caller fails closed rather than guess which repo was meant, and never
211
+ * silently resolves the reference against the candidate's own repo.
212
+ */
213
+ function crossRepoTarget(project: ProjectConfig, ownerRepo: string): "tracker" | "routed" | undefined {
214
+ if (ownerRepo === project.tracker.repo) return "tracker";
215
+ for (const routed of Object.values(project.routing.repos)) {
216
+ if (repoSlugFor(routed) === ownerRepo) return "routed";
217
+ }
218
+ return undefined;
219
+ }
220
+
193
221
  /**
194
222
  * Which routed candidates get a worker this tick — in queue order, never more
195
223
  * than `slots` of them. Every non-admission receives a stable reason code.
@@ -623,13 +651,14 @@ export async function admitCandidates(
623
651
  continue;
624
652
  }
625
653
 
626
- // The Depends-on interlock (#419): a candidate declares the same-repo
627
- // issues it must not be dispatched before, and any *open* prerequisite
628
- // holds it until every referenced issue is closed. Prerequisite state is
629
- // read fresh from the tracker at claim time, every pass — never cached
630
- // across ticks so a prerequisite that reopens re-holds on the next tick.
631
- // Only same-repo `#<n>` references are resolved here; cross-repo
632
- // references and graph cycles are later slices (#420/#421).
654
+ // The Depends-on interlock (#419/#420): a candidate declares the issues it
655
+ // must not be dispatched before same-repo (`#123`) or cross-repo
656
+ // (`owner/repo#123`) and any *open* prerequisite holds it until every
657
+ // referenced issue is closed. Prerequisite state is read fresh from the
658
+ // tracker at claim time, every pass never cached across ticks so a
659
+ // prerequisite that reopens re-holds on the next tick. Cross-repo
660
+ // references resolve only against repositories routed in this fleet;
661
+ // graph cycles are a later slice (#421).
633
662
  const dependsOn = parseDependsOn(r.issue.body);
634
663
  if (dependsOn.malformed.length > 0) {
635
664
  // A marker line with no strict `#<n>` reference (e.g. `Depends-on:
@@ -686,6 +715,81 @@ export async function admitCandidates(
686
715
  continue;
687
716
  }
688
717
  }
718
+ if (dependsOn.crossRefs.length > 0) {
719
+ // The cross-repo half (#420): each `owner/repo#n` is read from the repo
720
+ // it names. The fully qualified form travels in the `detail` string, so
721
+ // status/digest render it as `blocked by owner/repo#123` while same-repo
722
+ // refs stay compact `blocked by #5`.
723
+ let blocking: string | undefined;
724
+ let unreadable: string | undefined;
725
+ let unresolved: string | undefined;
726
+ for (const ref of dependsOn.crossRefs) {
727
+ const where = crossRepoTarget(project, ref.repo);
728
+ const full = `${ref.repo}#${ref.issue}`;
729
+ if (where === undefined) {
730
+ unresolved = full;
731
+ break;
732
+ }
733
+ let state: IssueSnapshot | undefined;
734
+ try {
735
+ if (where === "tracker") {
736
+ // The qualified form of the candidate's own tracker repo: the
737
+ // same state read the same-repo interlock uses.
738
+ state = await tracker.issueSnapshot(ref.issue);
739
+ } else if (d.probeIssueIn === undefined) {
740
+ // No resolver wired — fail closed, never guess the issue's state.
741
+ state = undefined;
742
+ } else {
743
+ state = await d.probeIssueIn(ref.repo, ref.issue);
744
+ }
745
+ } catch {
746
+ state = undefined;
747
+ }
748
+ if (state?.state === "open") {
749
+ blocking = full;
750
+ break;
751
+ }
752
+ if (state === undefined) {
753
+ unreadable = full;
754
+ break;
755
+ }
756
+ }
757
+ // Same fail-closed posture as same-repo refs: an open prerequisite
758
+ // holds, an unreadable one holds (the next tick rereads it fresh), and
759
+ // an unresolved repo holds because the dispatcher cannot verify the
760
+ // dependency — it is never silently treated as closed. Only the
761
+ // unresolved repo also surfaces one material event for grooming, and it
762
+ // follows the malformed-declaration escalation's stable-summary
763
+ // dedupe, so repeated ticks page once, not per tick.
764
+ if (blocking !== undefined) {
765
+ hold(issue, "depends-on", `blocked by ${blocking}`);
766
+ log(`#${issue} held (depends-on): cross-repo prerequisite ${blocking} is open`);
767
+ continue;
768
+ }
769
+ if (unreadable !== undefined) {
770
+ hold(issue, "depends-on", `prerequisite ${unreadable} state unreadable`);
771
+ log(`#${issue} held (depends-on): cross-repo prerequisite ${unreadable} state unreadable`);
772
+ continue;
773
+ }
774
+ if (unresolved !== undefined) {
775
+ hold(issue, "depends-on", `blocked by ${unresolved} — repo not routed in this fleet`);
776
+ log(`#${issue} held (depends-on): cross-repo prerequisite ${unresolved} is not a routed repo`);
777
+ await safeEscalate(d, {
778
+ tier: 1,
779
+ project: project.name,
780
+ issue,
781
+ summary: `#${issue} depends on ${unresolved}, a repo not routed in this fleet — held until groomed`,
782
+ detail: [
783
+ r.issue.title,
784
+ r.issue.url,
785
+ `Unroutable prerequisite: ${unresolved}`,
786
+ "Depends-on cross-repo references must name a repository routed in this project",
787
+ "(`routing.repos` or the tracker repo). Groom the body or route the repo.",
788
+ ].join("\n"),
789
+ });
790
+ continue;
791
+ }
792
+ }
689
793
 
690
794
  // The file-lane interlock (#555): a candidate whose declared lane overlaps
691
795
  // a live run's *actual* lane is held until that run's work has merged, so
@@ -381,12 +381,27 @@ did not arrive.
381
381
 
382
382
  ## Human messages
383
383
 
384
- A human writing to you between ticks is not a tick. Answer with a **single
385
- `telegram_send` call** one message, the answer only, from evidence you already
386
- hold or go and fetch. Never answer as plain end-of-turn text: on this session,
387
- text you merely write reaches nobody — if you do not call `telegram_send`, the
388
- person gets silence. While handling any turn, produce no visible commentary
389
- between tool calls reasoning stays in thinking, actions stay in tools.
384
+ A human writing to you between ticks is not a tick, and how a reply reaches
385
+ them depends on which of three shapes this turn is a shape the brief states,
386
+ never something a session infers from a tool's error:
387
+
388
+ - **A locally injected tick** has no inbound message and no topic to keep:
389
+ hand anything reportable to the outbox with `omp-conductor report`, and reach
390
+ the operator directly with `omp-conductor message --text "<the message>"`,
391
+ declaring the escalation category when you are asking. Your tick prompt names
392
+ this shape — it carries the delivery rule that says when a tick was injected
393
+ locally.
394
+ - **A turn that began as an inbound Telegram message** is answered with a
395
+ **single `telegram_send` call** — one message, the answer only, from evidence
396
+ you already hold or go and fetch — omitting **both** `chat_id` and `thread_id`
397
+ so the reply keeps the topic the message came from.
398
+ - **An interactive terminal session**, started by hand, is one the operator is
399
+ reading live: there, end-of-turn text **is** the delivery, and Telegram is
400
+ used only when it must demonstrably arrive — `telegram_send` resolves
401
+ `chat_id` from the last inbound message and refuses without one.
402
+
403
+ While handling any turn, produce no visible commentary between tool calls —
404
+ reasoning stays in thinking, actions stay in tools.
390
405
 
391
406
  **Reply where the message arrived.** `thread_id` defaults to the active topic
392
407
  *only while `chat_id` is omitted*, so a fleet whose Telegram is a forum topic
@@ -85,11 +85,19 @@ authoritative. All four scopes, spelled out:
85
85
  interrupt when operator availability permits; everything else waits for one
86
86
  daily rollup.
87
87
 
88
- **Delivery.** Never rely on end-of-turn text reaching anyone. The provable
89
- delivery paths are `omp-conductor report` (reports — persisted and retried by
90
- the daemon) and `telegram_send` (direct messages). `telegram_ask` is the decision
91
- primitive, not Telegram delivery evidence. Everything else is noise or silence.
92
- Hand every reportable event to the conductor's outbox:
88
+ **Delivery.** A reply reaches the operator by the shape of the turn it answers.
89
+ A **locally injected tick** delivers through the outbox — `omp-conductor report`
90
+ for anything reportable, `omp-conductor message` to reach the operator directly
91
+ because a report written as end-of-turn text on a tick reaches nobody. A turn
92
+ that **began as an inbound Telegram message** answers with `telegram_send`,
93
+ omitting `chat_id` and `thread_id` so the reply keeps its topic. An **interactive
94
+ terminal session** is read live by the operator, so end-of-turn text *is* the
95
+ delivery there; `telegram_send` resolves `chat_id` from the last inbound message
96
+ and refuses without one, so it is used only when the message must demonstrably
97
+ arrive. The provable delivery paths are `omp-conductor report` (persisted and
98
+ retried by the daemon) and `telegram_send` (direct messages); `telegram_ask` is
99
+ the decision primitive, not delivery evidence. Hand every reportable event to
100
+ the conductor's outbox:
93
101
 
94
102
  ```
95
103
  omp-conductor report --text "<the whole report>" # a material event