omp-conductor 0.3.23 → 0.3.24

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.
@@ -714,12 +720,15 @@ Run history is untouched. A PR closed without merging becomes a concrete failed
714
720
  attempt; a merge does not spend failure or continuation budget. A settled row also
715
721
  loses `agent:in-progress` from its issue: the row transition and the label are one
716
722
  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.
723
+ so the duplicate-dispatch guard it exists for is spent. The label comes off
724
+ *before* the row is written terminal, because this sweep is the only thing that
725
+ revisits a `pushed-*` row: a tracker that refuses the removal therefore leaves the
726
+ row pending and the next tick tries again, rather than stranding the label on an
727
+ issue nothing will look at twice. The issue stays in the busy set while it waits,
728
+ so nothing is dispatched onto it in between. Anything beyond that one release — a
729
+ re-queue, a `blocked` marker — is still the orchestrator's drain-duty judgement.
730
+ One unreachable PR costs its own row and nothing else; the rest of the sweep
731
+ still settles.
723
732
 
724
733
  Until this existed, nothing ever revisited a `pushed-green` row: the startup
725
734
  reconciler only settles rows that held a process, and `merged` went unwritten. On
@@ -1259,13 +1268,23 @@ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
1259
1268
  startup snapshot would keep dispatching for days after the channel went away. A
1260
1269
  stale arm marker must not outlive the channel that makes running unattended safe.
1261
1270
 
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,
1271
+ The check passes only when a bot token is resolvable `TELEGRAM_BOT_TOKEN` in
1272
+ the environment, or in the `.env` beside `accessFile` and the file parses to an
1273
+ object with `enabled: true` and exactly one `allowFrom` entry. Everything else
1274
+ stops the heartbeat: no token, so nothing outbound works at all; file missing,
1264
1275
  unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
1265
1276
  absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
1266
1277
  the conductor refuses to guess which human is on the hook). Failure modes are
1267
1278
  deliberately not distinguished in the decision: each one means a page lands
1268
- nowhere.
1279
+ nowhere. `omp-conductor status` is where they are told apart — its `telegram` row
1280
+ names the specific fault.
1281
+
1282
+ One caveat the file cannot express: omp-telegram binds its own copy of the token
1283
+ in `startBot()` at session start, and rebinds it only on `/telegram token` or
1284
+ `/telegram on`. Writing a token into `.env` out-of-band therefore restores
1285
+ tier-2 paging immediately — conductor sends those itself — while the bridge's own
1286
+ tools, `telegram_send` and `telegram_ask`, stay dead until you reload it. After
1287
+ adding a token by hand, run `/telegram on` in the orchestrator session.
1269
1288
 
1270
1289
  Leaving `accessFile` unset passes the gate, because an ordinary developer session
1271
1290
  that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
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,218 @@
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 { 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
+ /** Reads the omp-telegram access file and answers whether a locally injected
100
+ * turn would resolve an answerable destination.
101
+ *
102
+ * A bot token is *not* checked here, deliberately: without one nothing outbound
103
+ * works at all, so it is a whole-channel fault rather than an approval-surface
104
+ * one. Both callers settle it first — the tick through its escalation-channel
105
+ * gate, which refuses to fire at all, and `probeTelegramHealth` through a
106
+ * `getMe` that has already succeeded. This function is only ever asked about a
107
+ * bridge that can send. Never throws: an unreadable or malformed file is a
108
+ * missing surface with the path named, not an exception taken out of a
109
+ * heartbeat. */
110
+ export function readApprovalSurface(path: string): ApprovalSurface {
111
+ let access: unknown;
112
+ try {
113
+ access = JSON.parse(readFileSync(path, "utf8"));
114
+ } catch {
115
+ return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: cannot read ${path}` };
116
+ }
117
+ if (access === null || typeof access !== "object" || Array.isArray(access)) {
118
+ return {
119
+ kind: "missing",
120
+ reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} is not an object`,
121
+ };
122
+ }
123
+ const mode = field(access, "notifyMode");
124
+ const active = mode === "away" || mode === "always" || field(access, "away") === true;
125
+ if (!active) {
126
+ return {
127
+ kind: "missing",
128
+ reason:
129
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: no notifyMode in ${path} — ` +
130
+ `set it to "always" and set notifyChat to the paired owner id`,
131
+ };
132
+ }
133
+ // Only `notifyChat` counts, and `topicsChat` deliberately does not, even
134
+ // though omp-telegram can resolve a forum topic. `notifyTarget()` is handed a
135
+ // topic destination only when `ownTopic && access.topicsChat`, and `ownTopic`
136
+ // is per-session runtime state — the thread this process actually claimed —
137
+ // assigned during topic setup and cleared on failure. A file cannot prove it.
138
+ // So a fleet carrying `topicsChat` alone may or may not have the tool mounted,
139
+ // and this check answers the safe way: #114 exists because a health signal
140
+ // said "ok" through a broken contract, and repeating that with a stale
141
+ // `topicsChat` would be the same bug wearing a different key. `notifyChat` is
142
+ // resolvable from the file alone, so it is what "configured" means here.
143
+ const destination = field(access, "notifyChat");
144
+ if (typeof destination !== "string" || destination.length === 0) {
145
+ const topicsChat = field(access, "topicsChat");
146
+ const viaTopic =
147
+ typeof topicsChat === "string" && topicsChat.length > 0
148
+ ? " — topicsChat only resolves once this session has claimed its forum topic, which this file cannot show"
149
+ : "";
150
+ return {
151
+ kind: "missing",
152
+ reason:
153
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyMode is set but ${path} names no notifyChat` +
154
+ `${viaTopic} — set notifyChat to the paired owner id`,
155
+ };
156
+ }
157
+
158
+ // Naming a destination is not the same as being able to answer in it, and
159
+ // `buildPromptTarget()` applies two more gates before a target exists. Both
160
+ // are decidable from this file, so both belong here — a `ready` that skipped
161
+ // them would say the approval works while a stale or wrong chat id silently
162
+ // reproduces #114.
163
+ //
164
+ // First, `pairedOwnerId()` is the single entry of `allowFrom`; anything else
165
+ // yields no responder and therefore no target.
166
+ const allowFrom = field(access, "allowFrom");
167
+ const owner = Array.isArray(allowFrom) && allowFrom.length === 1 ? String(allowFrom[0]) : undefined;
168
+ if (owner === undefined) {
169
+ return {
170
+ kind: "missing",
171
+ reason:
172
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} has no single paired owner in allowFrom, ` +
173
+ "so no one is authorised to answer",
174
+ };
175
+ }
176
+
177
+ // Then `canAnswerPrompt()`, which is the same DM/group policy inbound turns
178
+ // use. `isDmChat()` calls anything not starting with "-" a DM, and a DM only
179
+ // authorises the owner's *own* chat — a DM chat id equals its user id, so a
180
+ // notifyChat pointing at any other person is unanswerable by the paired owner.
181
+ if (!destination.startsWith("-")) {
182
+ if (destination !== owner) {
183
+ return {
184
+ kind: "missing",
185
+ reason:
186
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a DM that ` +
187
+ `is not the paired owner's (${owner}), and only the owner's own DM authorises an answer — ` +
188
+ "set notifyChat to the paired owner id",
189
+ };
190
+ }
191
+ return { kind: "ready" };
192
+ }
193
+
194
+ // A group destination needs a policy entry, and that policy must admit the
195
+ // owner: an empty or absent `allowFrom` means everyone in the group, else the
196
+ // owner has to be listed.
197
+ const policy = field(field(access, "groups"), destination);
198
+ if (policy === null || typeof policy !== "object") {
199
+ return {
200
+ kind: "missing",
201
+ reason:
202
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a group with ` +
203
+ "no entry under groups, so no answer from it is authorised",
204
+ };
205
+ }
206
+ const groupAllow = field(policy, "allowFrom");
207
+ const admitted =
208
+ !Array.isArray(groupAllow) || groupAllow.length === 0 || groupAllow.map(String).includes(owner);
209
+ if (!admitted) {
210
+ return {
211
+ kind: "missing",
212
+ reason:
213
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: group ${destination} in ${path} does not admit the ` +
214
+ `paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
215
+ };
216
+ }
217
+ return { kind: "ready" };
218
+ }
package/src/daemon.ts CHANGED
@@ -893,26 +893,33 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
893
893
  * hand-editing one, and `unblock` refused to clear that particular label — so
894
894
  * both issues were permanently unclaimable with no supported way back (#18).
895
895
  *
896
- * Never throws, and that is the point of it being a function rather than a
897
- * bare `removeLabel`. Callers are sweeps: the store transition this accompanies
898
- * has already been written, so a tracker that fails here must cost one label,
899
- * not the remaining rows. There is no retry the row is terminal, so no later
900
- * tick revisits it which makes the log line the entire record of the miss and
901
- * why it names both the label and the reason. The operator's recovery for that
902
- * rare case is `omp-conductor unblock`, which clears the label itself once the
903
- * newest run is terminal.
896
+ * Never throws, and reports whether the label is provably gone, because the
897
+ * caller has to decide what to write to the store on the strength of it. A
898
+ * sweep must not lose the rest of its rows to one unreachable tracker, and it
899
+ * must not terminalise a row whose label it failed to drop: the settlement
900
+ * sweep only ever revisits `pushed-*` rows, so a row written terminal is a row
901
+ * nothing asks about again, and swallowing the failure under it would recreate
902
+ * the exact permanent-`agent:in-progress` state of #18 in the one case that
903
+ * still reaches it. Answering false instead leaves the row where the next tick
904
+ * will find it.
905
+ *
906
+ * Removing a label the issue does not carry is success, not failure: the GitHub
907
+ * adapter treats an absent label as a no-op, so false means the tracker could
908
+ * not be reached or refused — a condition that passes.
904
909
  */
905
910
  export async function releaseInProgress(
906
911
  d: Pick<Deps, "project" | "tracker">,
907
912
  issue: number,
908
913
  why: string,
909
- ): Promise<void> {
914
+ ): Promise<boolean> {
910
915
  const label = d.project.stateLabels.inProgress;
911
916
  try {
912
917
  await d.tracker.removeLabel(issue, label);
913
918
  log(`#${issue} released ${label}: ${why}`);
919
+ return true;
914
920
  } catch (err) {
915
- log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; clear it with \`unblock\``);
921
+ log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; retrying next tick`);
922
+ return false;
916
923
  }
917
924
  }
918
925
 
@@ -946,6 +953,15 @@ export async function releaseInProgress(
946
953
  * the same Tracker port the dispatcher claimed the issue with — orphan detection
947
954
  * is only trustworthy while every state label on the tracker came from this
948
955
  * package.
956
+ *
957
+ * The two writes are ordered label-then-row, and the order is load-bearing. This
958
+ * sweep is the only thing that revisits a `pushed-*` row, so the terminal state
959
+ * is also the row's exit from it: written first, a tracker that then failed on
960
+ * the label would leave `agent:in-progress` with nothing left to retry it — #18
961
+ * exactly, in the last window able to reach it. Writing the label first makes
962
+ * failure cost a repeated `gh` call on the next tick instead, and the row stays
963
+ * in the busy set throughout, so no second worker can be sent at the issue while
964
+ * it waits.
949
965
  */
950
966
  export async function settlePushedGreen(
951
967
  d: Pick<Deps, "project" | "tracker" | "store">,
@@ -978,14 +994,19 @@ export async function settlePushedGreen(
978
994
 
979
995
  const settlement = settlementFor(pr, run.prUrl);
980
996
  if (settlement !== undefined) {
997
+ // Label first, row second, and the order is the whole safety argument.
998
+ // The sweep only ever revisits `pushed-*` rows, so writing the terminal
999
+ // state first would put this row beyond every later tick — and a tracker
1000
+ // that failed on the label in that instant would strand
1001
+ // `agent:in-progress` permanently, which is #18 again in the one window
1002
+ // still able to reach it. Leaving the row `pushed-*` costs a stale active
1003
+ // row until the tracker answers, and the busy set keeps the issue
1004
+ // occupied meanwhile, so nothing can be dispatched onto it in between.
1005
+ if (!(await releaseInProgress(d, run.issue, settlement.reason))) continue;
981
1006
  const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
982
1007
  if (settlement.state === "failed") patch.lastError = settlement.reason;
983
1008
  store.updateRun(run.id, patch);
984
1009
  log(`#${run.issue} settled: ${settlement.reason}`);
985
- // Store and tracker in the same breath, for the reason above: the row is
986
- // terminal, so the label's interlock is spent. `releaseInProgress` never
987
- // throws, so a tracker hiccup costs this label and not the later rows.
988
- await releaseInProgress(d, run.issue, settlement.reason);
989
1010
  continue;
990
1011
  }
991
1012
 
@@ -1002,13 +1023,13 @@ export async function settlePushedGreen(
1002
1023
  store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
1003
1024
  log(`#${run.issue} checks settled: ${verification.reason}`);
1004
1025
  } else if (verification.status === "failed") {
1026
+ // Equally terminal, so the same label-first order for the same reason:
1027
+ // this row is about to leave the sweep's reach. The green branch above
1028
+ // releases nothing — that row is still awaiting a merge, and its live PR
1029
+ // is exactly the work the label must keep guarding.
1030
+ if (!(await releaseInProgress(d, run.issue, verification.reason))) continue;
1005
1031
  store.updateRun(run.id, { state: "failed", lastError: verification.reason });
1006
1032
  log(`#${run.issue} checks failed: ${verification.reason}`);
1007
- // Equally terminal: a red check on a pushed row ends the attempt, so the
1008
- // same release applies. The green branch above deliberately does not —
1009
- // that row is still awaiting a merge, and its live PR is exactly the work
1010
- // the label must keep guarding.
1011
- await releaseInProgress(d, run.issue, verification.reason);
1012
1033
  } else {
1013
1034
  store.updateRun(run.id, { lastError: verification.reason });
1014
1035
  }
package/src/fleet.ts CHANGED
@@ -28,6 +28,7 @@ import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
+ import { readApprovalSurface } from "./approval-surface.ts";
31
32
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
32
33
  import { formatDispatchSummary, isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
33
34
  import {
@@ -1082,62 +1083,11 @@ function readPairedChannel(path: string): Channel {
1082
1083
  }
1083
1084
 
1084
1085
  /**
1085
- * Whether the *approval* half of the Telegram surface works, which is a
1086
- * different question from whether inbound works and was never asked before.
1087
- *
1088
- * `telegram_ask` is mounted by omp-telegram's `before_agent_start` handler only
1089
- * for a turn whose prompt resolves a notify target. A Telegram-originated turn
1090
- * resolves one from its own `<telegram-message>` wrapper; a locally injected
1091
- * orchestrator tick has no wrapper, so it resolves one only through
1092
- * `notifyTarget()` — which needs `notifyMode` set to "away" or "always" *and* a
1093
- * destination, this session's forum topic under `topicsChat` or the flat
1094
- * `notifyChat`. With neither, the tool is simply absent from the tick.
1095
- *
1096
- * That is exactly what happened on 2026-08-09 06:17Z: the fleet's access.json
1097
- * had no `notifyMode`, so the locally injected tick could not ask the
1098
- * Learning-loop yes/no question the package floor requires — while this very
1099
- * status line reported `telegram ok (@tbcoder_bot; inbound configured)`
1100
- * throughout (#114). A health row that stays green through a broken contract is
1101
- * worse than no row, so the approval surface is now part of it.
1102
- *
1103
- * The legacy `away: true` boolean counts: `loadAccess()` migrates it to
1104
- * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
1105
- * and must not be reported as broken.
1086
+ * The approval half of the Telegram surface is a different question from
1087
+ * whether inbound works, and it is answered by {@link readApprovalSurface} —
1088
+ * shared with the orchestrator tick so this row and the tick's own warning can
1089
+ * never disagree about whether an amendment can be asked.
1106
1090
  */
1107
- type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
1108
-
1109
- function readApprovalSurface(path: string): ApprovalSurface {
1110
- let parsed: unknown;
1111
- try {
1112
- parsed = JSON.parse(readFileSync(path, "utf8"));
1113
- } catch {
1114
- return { kind: "missing", reason: `telegram_ask unavailable on local ticks: cannot read ${path}` };
1115
- }
1116
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1117
- return { kind: "missing", reason: `telegram_ask unavailable on local ticks: ${path} is not an object` };
1118
- }
1119
- const access = parsed as { readonly [key: string]: unknown };
1120
- const mode = access["notifyMode"];
1121
- const active = mode === "away" || mode === "always" || access["away"] === true;
1122
- if (!active) {
1123
- return {
1124
- kind: "missing",
1125
- reason:
1126
- `telegram_ask unavailable on local ticks: no notifyMode in ${path} — ` +
1127
- `set it to "always" and give it a destination (notifyChat, or topicsChat for a forum)`,
1128
- };
1129
- }
1130
- const destination = access["notifyChat"] ?? access["topicsChat"];
1131
- if (typeof destination !== "string" || destination.length === 0) {
1132
- return {
1133
- kind: "missing",
1134
- reason:
1135
- `telegram_ask unavailable on local ticks: notifyMode is set but ${path} names no destination — ` +
1136
- "set notifyChat to the paired owner id (or topicsChat for a forum)",
1137
- };
1138
- }
1139
- return { kind: "ready" };
1140
- }
1141
1091
 
1142
1092
  function readBotToken(): string | undefined {
1143
1093
  const env = process.env["TELEGRAM_BOT_TOKEN"];
@@ -1205,6 +1155,8 @@ export async function probeTelegramHealth(
1205
1155
  // Inbound first: a bridge that is down says nothing about the approval
1206
1156
  // surface, and stacking two remedies on one row buries the one to act on.
1207
1157
  if (channel.kind === "down") return { kind: "degraded", detail: `${username}; inbound ${channel.reason}` };
1158
+ // The token is already proven: `getMe` succeeded above. What is left is
1159
+ // whether an answer could come back, which is the access file's business.
1208
1160
  const approval = readApprovalSurface(accessPath);
1209
1161
  if (approval.kind === "missing") {
1210
1162
  return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
@@ -46,8 +46,9 @@
46
46
 
47
47
  import { spawnSync } from "node:child_process";
48
48
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
- import { isAbsolute, join, resolve } from "node:path";
49
+ import { dirname, isAbsolute, join, resolve } from "node:path";
50
50
  import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
51
+ import { hasBotToken, readApprovalSurface, TELEGRAM_APPROVAL_TOOL } from "./approval-surface.ts";
51
52
  import {
52
53
  briefPathForProject,
53
54
  policyPathForProject,
@@ -340,15 +341,9 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
340
341
  export const TICK_DELIVERY_RULE =
341
342
  "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
342
343
 
343
- /**
344
- * The tool the package floor names for the Learning-loop yes/no amendment
345
- * approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`).
346
- *
347
- * Held as a constant because two things must agree on it: the prose that tells
348
- * the turn to call it, and the preflight that checks whether it is callable at
349
- * all.
350
- */
351
- export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
344
+ /** Re-exported so the tick's own contract stays readable from one file: the
345
+ * constant itself lives beside the check that decides whether it is callable. */
346
+ export { TELEGRAM_APPROVAL_TOOL };
352
347
 
353
348
  /**
354
349
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -917,21 +912,44 @@ export function tickDecision(input: {
917
912
  }
918
913
 
919
914
  /**
920
- * Whether the Telegram bridge can still reach a person: enabled, with exactly
921
- * one paired owner.
915
+ * Whether the Telegram bridge can still reach a person: a bot token, enabled,
916
+ * with exactly one paired owner.
922
917
  *
923
918
  * Fail-closed, and every failure mode collapses to the same answer on purpose —
924
919
  * missing file, truncated write, hand-edit that dropped `enabled`, a second
925
- * chat id pasted in, or the pairing revoked. Distinguishing them would only
926
- * tempt a future reader into treating one of them as benign, and none of them
927
- * are: each one means a tier-2 escalation lands nowhere.
920
+ * chat id pasted in, the pairing revoked, or no token for the bot to send with.
921
+ * Distinguishing them would only tempt a future reader into treating one of them
922
+ * as benign, and none of them are: each one means a tier-2 escalation lands
923
+ * nowhere.
924
+ *
925
+ * The token belongs in this gate rather than further down. Without one nothing
926
+ * outbound works, so a tick would carry a delivery rule ordering a
927
+ * `telegram_send` that cannot happen and an approval fallback naming the same
928
+ * unusable tool — an unattended fleet dispatching with no way to page anybody,
929
+ * which is the one thing the arm handshake exists to prevent. A missing token
930
+ * fails the whole channel; the narrower approval preflight is reserved for a
931
+ * bridge that can send but cannot ask.
928
932
  *
929
- * Re-read on every tick rather than cached at session start, because the bridge
930
- * is reconfigured by a long-lived operator out-of-band and a heartbeat that
931
- * trusted a startup snapshot would keep dispatching for days after the channel
932
- * went away.
933
+ * Re-read on every tick, and specifically *not* snapshotted at session start,
934
+ * because of what this gate is actually about. Tier 2 is paged by conductor
935
+ * itself: `escalate.ts` reads the same `.env` and calls `api.telegram.org`
936
+ * directly, never through omp-telegram's bridge. So the question "can an
937
+ * escalation still reach a person" is answered by the file as it stands now, and
938
+ * an operator who fixes a missing token has fixed paging immediately — a startup
939
+ * snapshot would keep a working fleet silent until somebody restarted the
940
+ * session, which is a worse failure than the one it would prevent. Re-reading
941
+ * also catches the reverse: a channel that goes away mid-session stops the
942
+ * heartbeat on the next tick rather than days later.
943
+ *
944
+ * What the file cannot answer is whether *omp-telegram* holds a token. It binds
945
+ * one in `startBot()` at session start and rebinds only on `/telegram token` and
946
+ * `/telegram on`, so a token added out-of-band leaves the bridge's own tools —
947
+ * `telegram_ask`, `telegram_send` — dead until one of those runs. That is real,
948
+ * and it is deliberately not modelled here: this gate protects paging, the
949
+ * README says to reload the bridge, and `omp-conductor status` shows the row.
933
950
  */
934
951
  function channelIsUp(path: string): boolean {
952
+ if (!hasBotToken(dirname(path))) return false;
935
953
  let parsed: unknown;
936
954
  try {
937
955
  parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -1115,13 +1133,32 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1115
1133
  }
1116
1134
  }
1117
1135
 
1118
- // The floor's approval primitive, checked against the live mounted set at the
1119
- // moment this tick is composed not once at session start, because the
1120
- // mounted set is exactly what differed between two consecutive turns of the
1121
- // same session on 2026-08-09 (#114). Appended last, after the friction
1122
- // digest: the digest is what provokes an amendment, so the sentence that says
1123
- // the amendment cannot be approved here is the one that should read last.
1124
- if (!pi.getActiveTools().includes(TELEGRAM_APPROVAL_TOOL)) {
1136
+ // The floor's approval primitive, decided from configuration rather than from
1137
+ // the live mounted setand that is a correction, not a shortcut. The first
1138
+ // attempt at this read `pi.getActiveTools()` here, which is wrong at exactly
1139
+ // this point in the lifecycle: omp-telegram mounts `telegram_ask` in
1140
+ // `before_agent_start` and takes it away again in `agent_end`
1141
+ // (`restorePromptTools`), so it exists only *during* a turn. This runs between
1142
+ // turns, composing the prompt that is about to start one, so the live set
1143
+ // never contains the tool — a correctly configured fleet would have been told
1144
+ // the approval was unavailable on every single tick, and the error line meant
1145
+ // to flag a real fault would have fired 144 times a day saying nothing.
1146
+ //
1147
+ // What decides the mounting is knowable in advance and is shared with the
1148
+ // status row, so the two cannot disagree: a locally injected tick has no
1149
+ // `<telegram-message>` wrapper, so omp-telegram can only resolve a target
1150
+ // through `notifyTarget()` — `notifyMode` plus a destination, both in the
1151
+ // access file. Appended last, after the friction digest: the digest is what
1152
+ // provokes an amendment, so the sentence saying the amendment cannot be
1153
+ // approved here is the one that should read last.
1154
+ //
1155
+ // No access file configured means no fleet channel to judge, so nothing is
1156
+ // claimed: the channel gate above already treats that as "not the fleet". A
1157
+ // missing bot token is likewise not this check's business — it fails the
1158
+ // channel gate outright, so a tick that reaches here can already send, and the
1159
+ // only open question is whether an answer can come back.
1160
+ const approval = config.accessFile === undefined ? undefined : readApprovalSurface(config.accessFile);
1161
+ if (approval?.kind === "missing") {
1125
1162
  content = `${content}\n${TICK_APPROVAL_UNAVAILABLE_RULE}`;
1126
1163
  if (!session.approvalToolMissingLogged) {
1127
1164
  session.approvalToolMissingLogged = true;
@@ -1129,20 +1166,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1129
1166
  // inbound configured)` throughout the incident, so nothing else told the
1130
1167
  // operator the approval contract was unsatisfiable. A line buried at
1131
1168
  // info, beside one "tick sent" per interval, would not be found.
1132
- //
1133
- // It names the cause, not just the symptom, because the remedy is a file
1134
- // the operator owns: omp-telegram mounts the tool only for a turn whose
1135
- // prompt resolves a notify target, and a locally injected tick resolves
1136
- // one only through `notifyTarget()` — `notifyMode` "away" or "always",
1137
- // plus a destination.
1138
- pi.logger.error(
1139
- `[omp-conductor] ${TELEGRAM_APPROVAL_TOOL} is not mounted on this tick surface: omp-telegram mounts it ` +
1140
- "only for a turn that resolves a notify target, and a locally injected tick resolves one only when " +
1141
- `notifyMode is "away" or "always" with a destination (notifyChat, or topicsChat for a forum) in ` +
1142
- `${config.accessFile ?? "the omp-telegram access.json"} — until then ticks instruct the orchestrator ` +
1143
- "to deliver amendment questions with telegram_send and never to assume an answer",
1144
- { tool: TELEGRAM_APPROVAL_TOOL, ...(config.accessFile === undefined ? {} : { accessFile: config.accessFile }) },
1145
- );
1169
+ pi.logger.error(`[omp-conductor] ${approval.reason}`, {
1170
+ tool: TELEGRAM_APPROVAL_TOOL,
1171
+ accessFile: config.accessFile,
1172
+ });
1146
1173
  }
1147
1174
  }
1148
1175