omp-conductor 0.3.23 → 0.3.25

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
@@ -167,14 +167,20 @@ Also required on the host:
167
167
  dispatch is only defensible while a tier-2 page can reach a person.
168
168
  - **Approving a Learning-loop amendment from a heartbeat tick** needs one more
169
169
  setting than pairing: a notify destination. `/telegram notify` writes
170
- `notifyMode` and `notifyChat` into the same `access.json`; `/telegram topics`
171
- does it with a forum topic instead. omp-telegram mounts its `telegram_ask`
172
- tool only for a turn that resolves a notify target, and a locally injected
173
- tick resolves one only through that setting so without it the orchestrator
174
- can page you but cannot put a yes/no question in front of you, which is the
175
- one thing the Learning loop's approval step requires. `omp-conductor status`
176
- reports this on the `telegram` row, and a tick that finds the tool missing
177
- says so in its own prompt and falls back to `telegram_send`.
170
+ `notifyMode` and `notifyChat` into the same `access.json`. omp-telegram
171
+ mounts its `telegram_ask` tool only for a turn that resolves a notify target,
172
+ and a locally injected tick resolves one only through that setting so
173
+ without it the orchestrator can page you but cannot put a yes/no question in
174
+ front of you, which is the one thing the Learning loop's approval step
175
+ requires. `omp-conductor status` reports this on the `telegram` row, and a
176
+ tick that cannot ask says so in its own prompt and falls back to
177
+ `telegram_send`.
178
+
179
+ Set `notifyChat` even on a forum fleet. `/telegram topics` routes to a topic
180
+ this session claims at runtime, and that claim is not visible in
181
+ `access.json` — so a file carrying only `topicsChat` is reported as
182
+ unconfigured rather than guessed at, on the grounds that a health row which
183
+ reads green over a broken contract is worse than one that overstates a fault.
178
184
 
179
185
  With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
180
186
  that configuration: it is supported, just slower to reach you.
@@ -611,7 +617,7 @@ Then, per admitted issue:
611
617
  | --- | --- | --- | --- |
612
618
  | `pushed-pending` | `agent:in-progress` stays while the daemon rechecks GitHub | removed | none |
613
619
  | `pushed-green` | `agent:in-progress` stays while the PR is open | removed | none |
614
- | `blocked` | swapped to `agent:blocked` | removed | Tier 1 |
620
+ | `blocked` | swapped to `agent:blocked` | dirty tree committed to the branch, then removed | Tier 1 |
615
621
  | `failed` / `killed` | swapped to `agent:failed` | dirty tree committed to the branch, then retained until the PR or issue is terminal | Tier 1 |
616
622
  | unexpected error | swapped to `agent:failed` | same | Tier 1 |
617
623
 
@@ -624,18 +630,38 @@ Then, per admitted issue:
624
630
  leaves a window in which the issue carries no state label at all, which is
625
631
  exactly the shape eligibility reads as fresh work.
626
632
 
627
- **A non-graceful end salvages the tree first.** A turns-cap kill, a
628
- wall-clock kill and a crash are all external and unannounced: they land
629
- mid-edit, and only the run's *branch* is preserved across attempts — the tree
630
- is removed `--force` by the next one. So before the escalation is written,
631
- a dirty tree is committed to the run's own branch as
632
- `wip(#<issue>): attempt <n> killed by <reason> auto-salvaged` (everything,
633
- including files git has never seen) and pushed, and the escalation says where
634
- it went: `WIP committed to <branch> @ <sha>`. A push that is refused leaves
635
- the commit in this host's mirror and says so; a salvage that fails outright
636
- says that, loudly, naming the tree that now holds the only copy. A `blocked`
637
- run is deliberately *not* salvaged it stopped on purpose, with turns still
638
- in hand to commit for itself.
633
+ **Every continuable end salvages the tree first.** A turns-cap kill, a
634
+ wall-clock kill, a crash and a graceful block all leave a tree the next
635
+ attempt removes `--force` — only the run's *branch* is preserved across
636
+ attempts. So before the escalation is written, a dirty tree is committed to
637
+ the run's own branch as `wip(#<issue>): attempt <n> <ending> — auto-salvaged`
638
+ (everything, including files git has never seen) and pushed, and the
639
+ escalation says where it went: `WIP committed to <branch> @ <sha>`. A push
640
+ that is refused leaves the commit in this host's mirror and says so.
641
+
642
+ Blocking was excluded from this until #118, on the argument that a worker
643
+ which stops on purpose has turns left to commit for itself. It cost a
644
+ 34-file refactor: the worker blocked to ask whether a failing test was
645
+ obsolete — which is precisely a worker declining to commit a half-migrated
646
+ tree — and the daemon removed the tree seconds later, leaving the run branch
647
+ and `origin/main` on the same commit. A `pushed-green` or `pushed-pending`
648
+ run is now the only end that does not salvage: its deliverable is already on
649
+ a remote branch, and appending a WIP commit would turn the PR the daemon
650
+ just verified red.
651
+
652
+ **A salvage that fails keeps the tree and stops the issue.** If git refuses
653
+ the commit, the worktree is the only copy in existence, so it is retained
654
+ whatever the run's outcome was, the row records the failure, and the issue
655
+ is held out of dispatch with the `unsalvaged-wip` reason — because claiming
656
+ it is what would finally destroy the tree. `status` shows it under `wip` as
657
+ `UNSALVAGED`, and `omp-conductor unblock <n>` refuses. Recover the tree by
658
+ hand, then `unblock <n> --force` records that you accepted it and releases
659
+ the hold.
660
+
661
+ **A preserved tip is named to the next worker.** The sha is written to the
662
+ run row, shown by `status` and the board, and the continuation brief tells
663
+ the resuming worker the exact commit it is building on and that it is the
664
+ only copy.
639
665
 
640
666
  Later ticks reap retained failure trees in bounded batches after the tracker
641
667
  proves their PR merged/closed or their issue closed, provided no live run or
@@ -714,12 +740,15 @@ Run history is untouched. A PR closed without merging becomes a concrete failed
714
740
  attempt; a merge does not spend failure or continuation budget. A settled row also
715
741
  loses `agent:in-progress` from its issue: the row transition and the label are one
716
742
  fact, and a terminal answer about the PR proves no worker process owns the issue,
717
- so the duplicate-dispatch guard it exists for is spent. Rows that did not settle
718
- keep the label, and a tracker that refuses the removal is logged rather than
719
- allowed to abort the sweep `omp-conductor unblock` clears it by hand in that
720
- rare case. Anything beyond that one release a re-queue, a `blocked` marker
721
- is still the orchestrator's drain-duty judgement. One unreachable PR costs its own
722
- row and nothing else; the rest of the sweep still settles.
743
+ so the duplicate-dispatch guard it exists for is spent. The label comes off
744
+ *before* the row is written terminal, because this sweep is the only thing that
745
+ revisits a `pushed-*` row: a tracker that refuses the removal therefore leaves the
746
+ row pending and the next tick tries again, rather than stranding the label on an
747
+ issue nothing will look at twice. The issue stays in the busy set while it waits,
748
+ so nothing is dispatched onto it in between. Anything beyond that one release — a
749
+ re-queue, a `blocked` marker — is still the orchestrator's drain-duty judgement.
750
+ One unreachable PR costs its own row and nothing else; the rest of the sweep
751
+ still settles.
723
752
 
724
753
  Until this existed, nothing ever revisited a `pushed-green` row: the startup
725
754
  reconciler only settles rows that held a process, and `merged` went unwritten. On
@@ -1259,13 +1288,26 @@ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
1259
1288
  startup snapshot would keep dispatching for days after the channel went away. A
1260
1289
  stale arm marker must not outlive the channel that makes running unattended safe.
1261
1290
 
1262
- The check passes only when the file parses to an object with `enabled: true` and
1263
- exactly one `allowFrom` entry. Everything else stops the heartbeat: file missing,
1291
+ The check passes only when a bot token is resolvable `TELEGRAM_BOT_TOKEN` in
1292
+ the environment, or in the `.env` beside `accessFile` and the file parses to an
1293
+ object with `enabled: true` and exactly one `allowFrom` entry. Everything else
1294
+ stops the heartbeat: no token, so nothing outbound works at all; file missing,
1264
1295
  unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
1265
1296
  absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
1266
1297
  the conductor refuses to guess which human is on the hook). Failure modes are
1267
1298
  deliberately not distinguished in the decision: each one means a page lands
1268
- nowhere.
1299
+ nowhere. `omp-conductor status` is where they are told apart — its `telegram` row
1300
+ names the specific fault.
1301
+
1302
+ One caveat the file cannot express: omp-telegram binds its own copy of the token
1303
+ in `startBot()` at session start, and only when the bridge is switched on. It
1304
+ rebinds only on `/telegram token` or `/telegram on`. So writing a token into
1305
+ `.env` out-of-band — or flipping `enabled` to true by hand — restores tier-2
1306
+ paging immediately, because conductor sends those itself, while the bridge's own
1307
+ tools, `telegram_send` and `telegram_ask`, stay dead until you reload it. After
1308
+ either edit, run `/telegram on` in the orchestrator session. Until you do, ticks
1309
+ carry an explicit note that an amendment cannot be approved on this surface, and
1310
+ the conductor never assumes an answer it did not receive.
1269
1311
 
1270
1312
  Leaving `accessFile` unset passes the gate, because an ordinary developer session
1271
1313
  that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
@@ -1373,7 +1415,7 @@ omp-conductor disarm [--project NAME]
1373
1415
  omp-conductor release-pane [--project NAME]
1374
1416
  omp-conductor tail <issue> [--project NAME]
1375
1417
  omp-conductor extend <issue> --turns N [--project NAME]
1376
- omp-conductor unblock <issue> [--project NAME]
1418
+ omp-conductor unblock <issue> [--force] [--project NAME]
1377
1419
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
1378
1420
  omp-conductor daemon [--once] [--port N] [--project NAME]
1379
1421
  omp-conductor pause
@@ -1398,7 +1440,7 @@ omp-conductor help
1398
1440
  | `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
1399
1441
  | `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>`. |
1400
1442
  | `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. |
1401
- | `unblock <issue>` | 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. Exits `2` when the issue number is missing or malformed. |
1443
+ | `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. |
1402
1444
  | `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. |
1403
1445
  | `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. Admitted workers run in a tracked background pool, so settlement and capacity checks remain periodic while they work; shutdown drains the pool before closing the store. This is what `start` launches and what a systemd unit should call. |
1404
1446
  | `daemon --once` | Run a single tick, wait for workers admitted by that tick, and exit. No HTTP server or pidfile — a drill must not register itself as the daemon, or the next reader believes it and the real daemon's in-flight runs get reconciled as orphans. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
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.",
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Can a *locally injected* turn ask its operator a yes/no question?
3
+ *
4
+ * One predicate, shared by the tick that has to warn about the answer and the
5
+ * status row that has to report it, because two spellings of it would drift and
6
+ * the whole point is that the health line and the tick agree.
7
+ *
8
+ * It is deliberately a question about **configuration**, not about the mounted
9
+ * tool set, and that distinction is the correction to a first attempt that got
10
+ * it wrong. omp-telegram mounts `telegram_ask` in `before_agent_start` and
11
+ * removes it again in `agent_end` (`restorePromptTools`), so the tool exists
12
+ * only *during* a turn. Anything that looks at `getActiveTools()` between turns
13
+ * — which is exactly where a heartbeat composes its prompt — sees the base set
14
+ * and would report the tool missing on every tick, including a correctly
15
+ * configured fleet. A false alarm every interval is worse than no alarm: it
16
+ * trains the operator to ignore the line that is supposed to mean something.
17
+ *
18
+ * What actually decides the mounting is knowable ahead of the turn. omp-telegram
19
+ * resolves a prompt target from the turn's own `<telegram-message>` wrapper, or
20
+ * failing that from `notifyTarget()`, which needs `notifyMode` set to "away" or
21
+ * "always" *and* a destination it can reach. A locally injected tick never has a
22
+ * wrapper, so for ticks the predicate reduces to config keys readable before the
23
+ * turn starts — with one deliberate narrowing, documented at the destination
24
+ * check below: only `notifyChat` counts, because a forum topic depends on live
25
+ * per-session state no file can evidence.
26
+ *
27
+ * That is exactly what failed on 2026-08-09 06:17Z: the fleet's access.json had
28
+ * no `notifyMode`, so the locally injected tick could not ask the Learning-loop
29
+ * yes/no question the package floor requires — while the status line reported
30
+ * `telegram ok (@tbcoder_bot; inbound configured)` throughout (#114). A health
31
+ * row that stays green through a broken contract is worse than no row.
32
+ *
33
+ * The legacy `away: true` boolean counts: `loadAccess()` migrates it to
34
+ * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
35
+ * and must not be reported as broken.
36
+ */
37
+
38
+ import { readFileSync } from "node:fs";
39
+ import { dirname, join } from "node:path";
40
+
41
+ /** The tool the package floor names for the Learning-loop yes/no amendment
42
+ * approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`). Held as
43
+ * a constant because the prose that tells a turn to call it and the check that
44
+ * says whether it can be called must name the same thing. */
45
+ export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
46
+
47
+ export type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
48
+
49
+ /** One checked read of a JSON property, so nothing below asserts a shape the
50
+ * parse never proved. Anything that is not a plain object, or a key that is
51
+ * absent, answers undefined — which every caller here already treats as
52
+ * "unconfigured". */
53
+ function field(value: unknown, key: string): unknown {
54
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
55
+ return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined;
56
+ }
57
+
58
+ /**
59
+ * Whether omp-telegram would find a bot token — its rule, not a reasonable one.
60
+ *
61
+ * This gates the heartbeat, so it has to predict another package's answer, and
62
+ * anywhere it is more generous than that package the gate fails *open*: it would
63
+ * report a token, let the tick fire, and leave the fleet dispatching with an
64
+ * outbound channel that never worked. So it mirrors `resolveToken()` in
65
+ * omp-telegram's `access.ts` exactly — process environment first, then the first
66
+ * line of `<state dir>/.env` matching `/^TELEGRAM_BOT_TOKEN=(.*)$/` after a
67
+ * trim, first match winning even when its value is empty.
68
+ *
69
+ * That regex is deliberately strict where a dotenv reader would not be:
70
+ * `export TELEGRAM_BOT_TOKEN=…` and `TELEGRAM_BOT_TOKEN = …` are both files
71
+ * omp-telegram reads no token from, so both must close this gate.
72
+ *
73
+ * `escalate.ts` parses the same file more permissively on purpose, and the two
74
+ * are not a contradiction to be tidied away: that one asks "can *conductor* send
75
+ * a page with this", and it can, because it does the parsing and the request
76
+ * itself. This one asks "will *omp-telegram* have a token", and only omp-telegram
77
+ * decides that.
78
+ */
79
+ export function hasBotToken(stateDir: string): boolean {
80
+ const fromEnv = process.env["TELEGRAM_BOT_TOKEN"];
81
+ if (fromEnv !== undefined && fromEnv.length > 0) return true;
82
+ let raw: string;
83
+ try {
84
+ raw = readFileSync(join(stateDir, ".env"), "utf8");
85
+ } catch {
86
+ return false;
87
+ }
88
+ for (const line of raw.split("\n")) {
89
+ const match = /^TELEGRAM_BOT_TOKEN=(.*)$/.exec(line.trim());
90
+ if (match === null) continue;
91
+ // First match wins even when empty, exactly as `resolveToken()` returns on
92
+ // its first hit — a file whose first token line is blank has no token, and
93
+ // reading past it would disagree with the package this predicts.
94
+ return (match[1] ?? "").length > 0;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Whether omp-telegram would have *bound* a token, which is what its own tools
101
+ * depend on and is strictly narrower than the token existing.
102
+ *
103
+ * `startBot()` is the only thing that assigns `token`, and `session_start` calls
104
+ * it only when the bridge is switched on:
105
+ *
106
+ * ```js
107
+ * if (pi.getFlag("telegram") === true || process.env.OMP_TELEGRAM === "1" || access.enabled) await startBot(ctx);
108
+ * ```
109
+ *
110
+ * So a token sitting beside a bridge that was disabled at start was never
111
+ * picked up, and flipping `enabled` to true in the file afterwards does not pick
112
+ * it up either — only `/telegram on` or a restart does. Both halves therefore
113
+ * have to be true *at the same moment*, which is why this is sampled once rather
114
+ * than re-read.
115
+ *
116
+ * `pi.getFlag("telegram")` is omp's own launch flag and is not visible to this
117
+ * extension, so a session started with it but with `enabled: false` on disk
118
+ * reads as unbound here. That is the conservative direction: the cost is a
119
+ * fallback instruction the orchestrator can carry out, against an amendment
120
+ * recorded as approved that nobody answered.
121
+ */
122
+ export function bridgeTokenBound(accessPath: string): boolean {
123
+ if (!hasBotToken(dirname(accessPath))) return false;
124
+ if (process.env["OMP_TELEGRAM"] === "1") return true;
125
+ let access: unknown;
126
+ try {
127
+ access = JSON.parse(readFileSync(accessPath, "utf8"));
128
+ } catch {
129
+ return false;
130
+ }
131
+ return field(access, "enabled") === true;
132
+ }
133
+
134
+ /** Reads the omp-telegram access file and answers whether a locally injected
135
+ * turn would resolve an answerable destination.
136
+ *
137
+ * A bot token is *not* checked here, deliberately: without one nothing outbound
138
+ * works at all, so it is a whole-channel fault rather than an approval-surface
139
+ * one. Both callers settle it first — the tick through its escalation-channel
140
+ * gate, which refuses to fire at all, and `probeTelegramHealth` through a
141
+ * `getMe` that has already succeeded. This function is only ever asked about a
142
+ * bridge that can send. Never throws: an unreadable or malformed file is a
143
+ * missing surface with the path named, not an exception taken out of a
144
+ * heartbeat. */
145
+ export function readApprovalSurface(path: string): ApprovalSurface {
146
+ let access: unknown;
147
+ try {
148
+ access = JSON.parse(readFileSync(path, "utf8"));
149
+ } catch {
150
+ return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: cannot read ${path}` };
151
+ }
152
+ if (access === null || typeof access !== "object" || Array.isArray(access)) {
153
+ return {
154
+ kind: "missing",
155
+ reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} is not an object`,
156
+ };
157
+ }
158
+ const mode = field(access, "notifyMode");
159
+ const active = mode === "away" || mode === "always" || field(access, "away") === true;
160
+ if (!active) {
161
+ return {
162
+ kind: "missing",
163
+ reason:
164
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: no notifyMode in ${path} — ` +
165
+ `set it to "always" and set notifyChat to the paired owner id`,
166
+ };
167
+ }
168
+ // Only `notifyChat` counts, and `topicsChat` deliberately does not, even
169
+ // though omp-telegram can resolve a forum topic. `notifyTarget()` is handed a
170
+ // topic destination only when `ownTopic && access.topicsChat`, and `ownTopic`
171
+ // is per-session runtime state — the thread this process actually claimed —
172
+ // assigned during topic setup and cleared on failure. A file cannot prove it.
173
+ // So a fleet carrying `topicsChat` alone may or may not have the tool mounted,
174
+ // and this check answers the safe way: #114 exists because a health signal
175
+ // said "ok" through a broken contract, and repeating that with a stale
176
+ // `topicsChat` would be the same bug wearing a different key. `notifyChat` is
177
+ // resolvable from the file alone, so it is what "configured" means here.
178
+ const destination = field(access, "notifyChat");
179
+ if (typeof destination !== "string" || destination.length === 0) {
180
+ const topicsChat = field(access, "topicsChat");
181
+ const viaTopic =
182
+ typeof topicsChat === "string" && topicsChat.length > 0
183
+ ? " — topicsChat only resolves once this session has claimed its forum topic, which this file cannot show"
184
+ : "";
185
+ return {
186
+ kind: "missing",
187
+ reason:
188
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyMode is set but ${path} names no notifyChat` +
189
+ `${viaTopic} — set notifyChat to the paired owner id`,
190
+ };
191
+ }
192
+
193
+ // Naming a destination is not the same as being able to answer in it, and
194
+ // `buildPromptTarget()` applies two more gates before a target exists. Both
195
+ // are decidable from this file, so both belong here — a `ready` that skipped
196
+ // them would say the approval works while a stale or wrong chat id silently
197
+ // reproduces #114.
198
+ //
199
+ // First, `pairedOwnerId()` is the single entry of `allowFrom`; anything else
200
+ // yields no responder and therefore no target.
201
+ const allowFrom = field(access, "allowFrom");
202
+ const owner = Array.isArray(allowFrom) && allowFrom.length === 1 ? String(allowFrom[0]) : undefined;
203
+ if (owner === undefined) {
204
+ return {
205
+ kind: "missing",
206
+ reason:
207
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} has no single paired owner in allowFrom, ` +
208
+ "so no one is authorised to answer",
209
+ };
210
+ }
211
+
212
+ // Then `canAnswerPrompt()`, which is the same DM/group policy inbound turns
213
+ // use. `isDmChat()` calls anything not starting with "-" a DM, and a DM only
214
+ // authorises the owner's *own* chat — a DM chat id equals its user id, so a
215
+ // notifyChat pointing at any other person is unanswerable by the paired owner.
216
+ if (!destination.startsWith("-")) {
217
+ if (destination !== owner) {
218
+ return {
219
+ kind: "missing",
220
+ reason:
221
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a DM that ` +
222
+ `is not the paired owner's (${owner}), and only the owner's own DM authorises an answer — ` +
223
+ "set notifyChat to the paired owner id",
224
+ };
225
+ }
226
+ return { kind: "ready" };
227
+ }
228
+
229
+ // A group destination needs a policy entry, and that policy must admit the
230
+ // owner: an empty or absent `allowFrom` means everyone in the group, else the
231
+ // owner has to be listed.
232
+ const policy = field(field(access, "groups"), destination);
233
+ if (policy === null || typeof policy !== "object") {
234
+ return {
235
+ kind: "missing",
236
+ reason:
237
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a group with ` +
238
+ "no entry under groups, so no answer from it is authorised",
239
+ };
240
+ }
241
+ const groupAllow = field(policy, "allowFrom");
242
+ const admitted =
243
+ !Array.isArray(groupAllow) || groupAllow.length === 0 || groupAllow.map(String).includes(owner);
244
+ if (!admitted) {
245
+ return {
246
+ kind: "missing",
247
+ reason:
248
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: group ${destination} in ${path} does not admit the ` +
249
+ `paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
250
+ };
251
+ }
252
+ return { kind: "ready" };
253
+ }
package/src/board.ts CHANGED
@@ -418,7 +418,12 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
418
418
  `attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
419
419
  `$${run.spendUsd.toFixed(2)} · ${duration}`,
420
420
  ];
421
- if (run.state === "pushed-pending") lines.push("checks pending");
421
+ // Ordered by what needs a human first. An unsalvaged tree outranks even a
422
+ // last error: the error describes a run that is over, the tree is work that
423
+ // is still at risk and an issue that will not dispatch (#118).
424
+ if (run.salvageError !== undefined && run.salvageAckAt === undefined) lines.push("UNSALVAGED WIP");
425
+ else if (run.state === "pushed-pending") lines.push("checks pending");
426
+ else if (run.salvageSha !== undefined) lines.push(`wip @ ${run.salvageSha.slice(0, 7)}`);
422
427
  else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " "));
423
428
  else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, ""));
424
429
  else lines.push(run.branch);
@@ -125,6 +125,14 @@ which in its output: a run that is genuinely still live, where declining is the
125
125
  correct answer, and no run row at all to prove the worker is gone. In that second
126
126
  case, escalate the stuck issue rather than editing its labels.
127
127
 
128
+ `unblock` refuses outright, clearing nothing, when the newest attempt's work
129
+ could not be committed and its worktree is the only copy of it — re-claiming the
130
+ issue force-removes that tree. Its output names the directory and offers
131
+ `--force`. **`--force` is never yours to pass.** It records that a human
132
+ inspected the tree and accepted the loss, which is a judgement about work you
133
+ cannot see, and the daemon has already paged the operator about it. Report the
134
+ refusal and move on to the next issue.
135
+
128
136
  Never leave an orphan holding a slot "to be safe": a label nobody is working under
129
137
  is not safety, it is a deadlocked fleet that looks busy.
130
138
 
package/src/cli.ts CHANGED
@@ -94,7 +94,7 @@ usage:
94
94
  omp-conductor release-pane [--project NAME]
95
95
  omp-conductor tail <issue> [--project NAME]
96
96
  omp-conductor extend <issue> --turns N [--project NAME]
97
- omp-conductor unblock <issue> [--project NAME]
97
+ omp-conductor unblock <issue> [--force] [--project NAME]
98
98
  omp-conductor daemon [--once] [--port N] [--project NAME]
99
99
  omp-conductor pause
100
100
  omp-conductor resume
@@ -150,6 +150,9 @@ usage:
150
150
  and why the brief's "never hand-edit a state label" rule can stay
151
151
  absolute. Run history is kept; answered blocks consume the separate
152
152
  operational-continuation budget, not failed implementation attempts.
153
+ Refuses when the newest attempt's work could not be committed and
154
+ its worktree is the only copy: re-claiming removes that tree. Use
155
+ --force once you have recovered it or accepted the loss.
153
156
  friction record a bounded observation the daemon cannot classify itself:
154
157
  an escalation that belonged in a digest, or a tick report that was
155
158
  noise/surprising. Repeated observations feed the existing Learning
@@ -636,8 +639,12 @@ try {
636
639
  const project = findProject(cfg, flag(argv, "project"));
637
640
  const store = openStore(dbPath());
638
641
  try {
639
- const outcome = await unblockIssue(project, makeTracker(project), store, issue);
642
+ const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
643
+ force: argv.includes("--force"),
644
+ });
640
645
  process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
646
+ // A refusal must not read as success to a script or a board keypress.
647
+ if (outcome.refused !== undefined) process.exitCode = 3;
641
648
  } finally {
642
649
  store.close();
643
650
  }