omp-conductor 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,6 +204,26 @@ Also required on the host:
204
204
  unconfigured rather than guessed at, on the grounds that a health row which
205
205
  reads green over a broken contract is worse than one that overstates a fault.
206
206
 
207
+ - **A fleet that answers instead of narrating** needs one key, set once:
208
+ `/telegram set profile daemon` (omp-telegram 0.11.0 or newer). Without it the
209
+ bridge behaves as it does on a laptop: it finalizes a real Telegram message
210
+ per assistant turn for as long as a conversation is active — so one answer
211
+ arrives as several messages, and a message that lands mid-tick keeps relaying
212
+ that tick's internal turns — and it posts every local run's closing text to
213
+ `notifyChat`, which on a host whose runs are heartbeat ticks means each tick's
214
+ working prose. The profile switches all of it off at the transport: text
215
+ reaches Telegram only through `telegram_send` / `telegram_ask`, the idle post
216
+ is suppressed, and `telegram_ask` stays mounted and aimed at the paired owner
217
+ on every turn — including a locally injected tick, so it also removes the need
218
+ for `notifyMode` above. Approval and blocked-input pings still fire; those
219
+ mean a human is needed, which is the point of the channel.
220
+
221
+ `omp-conductor status` reports an interactive profile on the `telegram` row,
222
+ and every tick composed on one carries a prompt line saying so. Note the two
223
+ settings pull against each other before the profile exists: setting
224
+ `notifyMode` to make `telegram_ask` mountable is exactly what arms the idle
225
+ post, so the correctly askable fleet was also the loud one.
226
+
207
227
  With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
208
228
  that configuration: it is supported, just slower to reach you.
209
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
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,17 @@
33
33
  * The legacy `away: true` boolean counts: `loadAccess()` migrates it to
34
34
  * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
35
35
  * and must not be reported as broken.
36
+ *
37
+ * Since omp-telegram 0.11.0 there is a second way for a locally injected turn to
38
+ * resolve a target, and it counts here for the same reason the legacy boolean
39
+ * does: `profile: "daemon"` makes `notifyTarget()` resolve without `notifyMode`
40
+ * at all, because the profile *is* the headless contract — explicit-only
41
+ * outbound, no idle notify post, and `telegram_ask` targeted at the paired owner
42
+ * on every turn including a cron tick. A fleet carrying it has the surface #114
43
+ * was about, so demanding `notifyMode` beside it would be the false alarm this
44
+ * file exists to avoid. The `notifyMode` paths are all still live: an older
45
+ * plugin ignores the `profile` key entirely, and a host running one keeps
46
+ * working unchanged.
36
47
  */
37
48
 
38
49
  import { readFileSync } from "node:fs";
@@ -46,6 +57,16 @@ export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
46
57
 
47
58
  export type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
48
59
 
60
+ /** Whether the Telegram bridge is running the headless contract, or merely
61
+ * configured well enough to answer a question.
62
+ *
63
+ * Two questions, not one, which is why this is a separate answer from
64
+ * {@link ApprovalSurface}: a fleet can be perfectly *askable* and still be
65
+ * narrating every turn into its operator's chat. `interactive` carries the
66
+ * reason because both readers of it — a tick prompt and a status row — have to
67
+ * name the remedy, and one spelling of it keeps them in agreement. */
68
+ export type DaemonProfileSurface = { kind: "daemon" } | { kind: "interactive"; reason: string };
69
+
49
70
  /** One checked read of a JSON property, so nothing below asserts a shape the
50
71
  * parse never proved. Anything that is not a plain object, or a key that is
51
72
  * absent, answers undefined — which every caller here already treats as
@@ -156,7 +177,20 @@ export function readApprovalSurface(path: string): ApprovalSurface {
156
177
  };
157
178
  }
158
179
  const mode = field(access, "notifyMode");
159
- const active = mode === "away" || mode === "always" || field(access, "away") === true;
180
+ // Three spellings of the same fact, and none of them is redundant.
181
+ // `notifyMode` is what an interactive host sets; `away: true` is the retired
182
+ // boolean `loadAccess()` migrates on read; `profile: "daemon"` is the headless
183
+ // contract, which omp-telegram ≥ 0.11.0 treats as its own reason to resolve a
184
+ // notify target (`notifyTarget()` gates on `notifyMode || profile === "daemon"`
185
+ // and `before_agent_start` mounts `telegram_ask` on the same disjunction). A
186
+ // daemon-profile fleet with no `notifyMode` is therefore *more* answerable than
187
+ // the configuration #114 asked for, not less, and reporting it broken would be
188
+ // the every-interval false alarm this file was written to prevent.
189
+ const active =
190
+ mode === "away" ||
191
+ mode === "always" ||
192
+ field(access, "away") === true ||
193
+ field(access, "profile") === "daemon";
160
194
  if (!active) {
161
195
  return {
162
196
  kind: "missing",
@@ -251,3 +285,58 @@ export function readApprovalSurface(path: string): ApprovalSurface {
251
285
  }
252
286
  return { kind: "ready" };
253
287
  }
288
+
289
+ /**
290
+ * Reads the same access file and answers the other question: does visible
291
+ * assistant text stay out of the operator's chat?
292
+ *
293
+ * A separate read rather than a field on {@link ApprovalSurface}, because the
294
+ * two faults have different remedies and a caller may care about only one. This
295
+ * one is about *noise*: without the profile, omp-telegram relays a real Telegram
296
+ * message per assistant turn for as long as a conversation is marked active
297
+ * (`outbound.ts` `onTurnEnd`), and — because the fleet sets `notifyMode` for the
298
+ * approval surface above — posts every local run's closing text to the notify
299
+ * chat from its `agent_end` handler. So a correctly *askable* fleet is exactly
300
+ * the one that narrates: the two settings pull against each other, which is why
301
+ * both are checked and only the profile silences the second.
302
+ *
303
+ * One key decides it, deliberately. `profile: "daemon"` is omp-telegram ≥ 0.11.0's
304
+ * whole headless contract — explicit-only outbound, no idle/final notify post,
305
+ * `telegram_ask` targeted at the paired owner on every turn — so verifying it is
306
+ * one comparison rather than three inferences about `streaming`, `notifyMode` and
307
+ * turn origin that would drift the moment the plugin changes. Anything other
308
+ * than the literal string is `interactive`: `loadAccess()` drops every other
309
+ * value to undefined, so `"DAEMON"` and `5` are not the contract on the host
310
+ * either, and reporting them as one would promise a suppression nothing applied.
311
+ *
312
+ * Never throws, and never writes: conductor reads this file, the operator's
313
+ * `/telegram set profile daemon` is what changes it. An unreadable or corrupt
314
+ * file answers `interactive` for the reason the whole module fails closed — a
315
+ * green signal over an unproven contract is what #114 was.
316
+ */
317
+ export function readDaemonProfile(accessPath: string): DaemonProfileSurface {
318
+ const remedy = "assistant text can auto-relay to Telegram — run `/telegram set profile daemon`";
319
+ let access: unknown;
320
+ try {
321
+ access = JSON.parse(readFileSync(accessPath, "utf8"));
322
+ } catch {
323
+ return {
324
+ kind: "interactive",
325
+ reason: `cannot read or parse ${accessPath}, so no telegram profile is proven; ${remedy}`,
326
+ };
327
+ }
328
+ if (access === null || typeof access !== "object" || Array.isArray(access)) {
329
+ return {
330
+ kind: "interactive",
331
+ reason: `${accessPath} is not an object, so no telegram profile is proven; ${remedy}`,
332
+ };
333
+ }
334
+ const profile = field(access, "profile");
335
+ if (profile === "daemon") return { kind: "daemon" };
336
+ // An absent key reads as "default" rather than "undefined": that is the word
337
+ // the plugin's own `/telegram status` line and its `profile: daemon | default`
338
+ // help text use, and an operator matching a status row against this reason
339
+ // should not have to translate.
340
+ const observed = profile === undefined ? "default" : JSON.stringify(profile);
341
+ return { kind: "interactive", reason: `telegram profile is ${observed}; ${remedy}` };
342
+ }
@@ -208,18 +208,23 @@ sent that way is undetectable when it does not arrive.
208
208
 
209
209
  ## Human messages
210
210
 
211
- A human writing to you between ticks is not a tick. Answer the question they
212
- actually asked, in one message, from evidence you already hold or go and fetch.
213
-
214
- Then stop. Do not continue loop narration in the same reply, and do not restate
215
- in-progress work unless they asked for it. The loop resumes on the next tick.
211
+ A human writing to you between ticks is not a tick. Answer with a **single
212
+ `telegram_send` call** one message, the answer only, from evidence you already
213
+ hold or go and fetch. Never answer as plain end-of-turn text: on this session,
214
+ text you merely write reaches nobody if you do not call `telegram_send`, the
215
+ person gets silence. While handling any turn, produce no visible commentary
216
+ between tool calls — reasoning stays in thinking, actions stay in tools.
217
+
218
+ If the answer needs a decision from the operator (a choice, a yes/no, an
219
+ approval), ask it with `telegram_ask` — never a numbered-options message via
220
+ `telegram_send`, and never the generic `ask` UI. A cancelled or errored
221
+ `telegram_ask` is a delivery failure, not an answer.
216
222
 
217
223
  A message may also reach you **mid-tick** (delivery is steering: it arrives
218
224
  between two of your tool calls). Treat it as an interrupt, not a new tick:
219
- answer it immediately with `telegram_send` in one message, then return to the
220
- duty you were in the middle of and finish it. Never abandon or restart the tick
221
- because a message arrived, and never batch the answer "for the report" — the
222
- person is waiting now.
225
+ `telegram_send` the answer immediately, then return to the duty you were in the
226
+ middle of and finish it. Never abandon or restart the tick because a message
227
+ arrived, and never batch the answer "for the report" — the person is waiting now.
223
228
 
224
229
  ## Escalation tiers
225
230
 
@@ -74,10 +74,11 @@ Your report scope is **`{{REPORT_SCOPE}}`**. All three scopes, spelled out:
74
74
  issue you pulled off the queue, a cap that stopped the fleet. A tick where
75
75
  nothing changed still says nothing — "no change" is not an event.
76
76
 
77
- **Delivery.** Your end-of-turn text reaches your operator only on a turn that
78
- *began* as an inbound Telegram message. A tick did not: it is injected locally,
79
- so a report you merely write at the end of one is read by nobody, however well
80
- you wrote it. Hand every reportable event to the conductor's outbox instead:
77
+ **Delivery.** Never rely on end-of-turn text reaching anyone. The only delivery
78
+ paths are `omp-conductor report` (reports persisted, daemon-retried),
79
+ `telegram_send` (a person who is waiting), and `telegram_ask` (a decision).
80
+ Everything else is noise or silence. Hand every reportable event to the
81
+ conductor's outbox:
81
82
 
82
83
  ```
83
84
  omp-conductor report --text "<the whole report>" # a material event
package/src/fleet.ts CHANGED
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
31
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
32
- import { readApprovalSurface } from "./approval-surface.ts";
32
+ import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
33
33
  import { inspectBriefLayout } from "./brief-upgrade.ts";
34
34
  import { dbPath, openStore } from "./store.ts";
35
35
  import { renderBriefForProject } from "./setup.ts";
@@ -1303,6 +1303,24 @@ export async function probeTelegramHealth(
1303
1303
  if (approval.kind === "missing") {
1304
1304
  return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
1305
1305
  }
1306
+ // Last, and only once the surface is answerable: whether it is *quiet*.
1307
+ //
1308
+ // The order is the order an operator should fix things in, and one row carries
1309
+ // one remedy. A missing token means nothing outbound works, so the approval
1310
+ // surface is not worth discussing; a missing approval surface means an
1311
+ // amendment cannot be asked, which outranks noise; an interactive profile is
1312
+ // real but strictly the least severe — the fleet works, it is just loud. And
1313
+ // the two lower checks are not independent of each other: setting `notifyMode`
1314
+ // to fix the approval surface is what *arms* the `agent_end` notify post this
1315
+ // one warns about, so a fleet that fixes them in the other order would see the
1316
+ // noise appear as the reward for fixing the silence.
1317
+ const profile = readDaemonProfile(accessPath);
1318
+ if (profile.kind === "interactive") {
1319
+ return {
1320
+ kind: "degraded",
1321
+ detail: `${username}; inbound configured; interactive profile relays assistant text — /telegram set profile daemon`,
1322
+ };
1323
+ }
1306
1324
  return { kind: "ok", detail: `${username}; inbound configured; telegram_ask available` };
1307
1325
  }
1308
1326
 
@@ -52,6 +52,7 @@ import {
52
52
  bridgeTokenBound,
53
53
  hasBotToken,
54
54
  readApprovalSurface,
55
+ readDaemonProfile,
55
56
  TELEGRAM_APPROVAL_TOOL,
56
57
  } from "./approval-surface.ts";
57
58
  import {
@@ -363,12 +364,27 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
363
364
  /**
364
365
  * The delivery clause, appended to every default tick prompt.
365
366
  *
366
- * End-of-turn text streams to the operator's Telegram only on a turn that
367
- * *began* as an inbound Telegram message. A heartbeat tick is injected locally,
368
- * so it is never such a turn, and a session that believes otherwise reports
369
- * into a void: on 2026-08-06 the fleet this extension runs produced a release
370
- * report and two tier-2 escalations as end-of-turn text, and not one of the
371
- * three reached anybody.
367
+ * A tick's end-of-turn text does not reach the operator as a *report*, and the
368
+ * rule's job is to stop a session believing otherwise: on 2026-08-06 the fleet
369
+ * this extension runs produced a release report and two tier-2 escalations as
370
+ * end-of-turn text, and not one of the three reached anybody.
371
+ *
372
+ * This comment used to explain that by saying end-of-turn text streams to
373
+ * Telegram only on a turn that *began* as an inbound Telegram message. That
374
+ * premise is false, and the correction matters because it flips the reason:
375
+ * omp-telegram's `agent_end` handler posts `finalText` to the notify chat on
376
+ * every run that did *not* come from Telegram, whenever `notifyMode` is set —
377
+ * and the fleet must set it, because {@link readApprovalSurface} needs it for
378
+ * the approval surface. So a tick's closing prose *was* reaching the operator
379
+ * all along: not as a delivered report, as an unlogged, unretried, untracked
380
+ * ping in the middle of whatever else was in that chat. Both the void and the
381
+ * ping are the same fault seen from two sides — text is not a delivery channel.
382
+ *
383
+ * omp-telegram 0.11.0's `profile: "daemon"` removes it in the other direction:
384
+ * explicit-only outbound, and the idle/final notify post suppressed outright, so
385
+ * a tick's visible text goes nowhere at all. That is the configuration the fleet
386
+ * runs (see {@link TICK_NARRATION_RULE}, which warns when it is absent), and it
387
+ * makes this rule's demand literal rather than merely prudent.
372
388
  *
373
389
  * The clause used to name `telegram_send`, and that held — but it was still an
374
390
  * instruction where a mechanism was needed: the same miss recurs whenever the
@@ -418,6 +434,38 @@ export const TICK_APPROVAL_UNAVAILABLE_RULE =
418
434
  `If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
419
435
  `never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
420
436
 
437
+ /**
438
+ * Appended to every tick — the shipped prompt or the operator's own — composed
439
+ * on a bridge that is not in omp-telegram's `profile: "daemon"`.
440
+ *
441
+ * The running commentary an operator sees is *mechanical*, not disobedience, and
442
+ * that is why a prompt line is the wrong permanent fix and the right interim one.
443
+ * Two verified paths carry visible text out without anyone calling a tool:
444
+ * `outbound.ts` `onTurnEnd()` finalizes one real Telegram message per assistant
445
+ * turn for as long as the chat is marked active — so a multi-step answer arrives
446
+ * as several messages, and a message that lands mid-tick keeps the chat active
447
+ * for the rest of that run, relaying every subsequent tick-internal turn — and
448
+ * the `agent_end` handler posts the closing text of every *local* run to the
449
+ * notify chat whenever `notifyMode` is set. The fleet has to set `notifyMode`
450
+ * (see {@link TICK_APPROVAL_UNAVAILABLE_RULE}), so the correctly-askable fleet is
451
+ * exactly the narrating one. `profile: "daemon"` closes both at the transport:
452
+ * explicit-only outbound, notify post suppressed.
453
+ *
454
+ * So this rule is a stopgap with an expiry date, and it says so: it names the
455
+ * one command that removes it. Until then the only defence is discipline the
456
+ * turn can actually exercise — no visible text between tool calls, and one
457
+ * `telegram_send` for a human answer rather than prose that leaks a turn at a
458
+ * time. It fires *because* the mechanism is absent, so a fleet that has run
459
+ * `/telegram set profile daemon` never sees it.
460
+ *
461
+ * Appended to a configured `message` too, for {@link TICK_APPROVAL_UNAVAILABLE_RULE}'s
462
+ * reason exactly: an operator's prompt owns the reporting contract, but it cannot
463
+ * consent on the orchestrator's behalf to what the transport does with its text.
464
+ * Transport truth is not the operator's prompt to waive.
465
+ */
466
+ export const TICK_NARRATION_RULE =
467
+ "This session's Telegram bridge is NOT in daemon profile, so visible assistant text can auto-relay to your operator's chat (per-turn messages while a Telegram conversation is active, and end-of-run notify posts on local runs). Until the operator runs `/telegram set profile daemon`: produce no visible text between tool calls, and answer any human message with a single telegram_send call only.";
468
+
421
469
  function frictionLabel(kind: FrictionSignal["kind"]): string {
422
470
  if (kind.startsWith("admission:")) return `admission hold ${kind.slice("admission:".length)}`;
423
471
  if (kind === "feedback:escalation-should-digest") return "escalations classified as digest material";
@@ -1254,6 +1302,28 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1254
1302
  }
1255
1303
  }
1256
1304
 
1305
+ // The other half of transport truth, read from the same file at the same
1306
+ // moment: whether visible text stays out of the operator's chat. Appended
1307
+ // after the approval line because it is the weaker instruction of the two —
1308
+ // the approval rule stops an invented approval, this one asks the turn to keep
1309
+ // quiet — and a prompt should end on the sentence that must not be missed.
1310
+ //
1311
+ // Gated on `bridgeTokenAtStart` for the same reason the approval read is, and
1312
+ // the reasoning is the mirror image of it: with no token bound, omp-telegram
1313
+ // sends *nothing*, so the two relay paths this rule warns about cannot fire
1314
+ // either. A narration warning on a dead bridge would describe a hazard that
1315
+ // does not exist, on top of an approval warning that already names the one
1316
+ // command worth running (`/telegram on`) — two remedies on one prompt, and the
1317
+ // operator acts on neither. So the rule fires only where the leak is real: a
1318
+ // live bridge whose profile is not `daemon`.
1319
+ //
1320
+ // No `accessFile` means no fleet bridge to judge, exactly as above.
1321
+ const profile =
1322
+ config.accessFile === undefined || !session.bridgeTokenAtStart
1323
+ ? undefined
1324
+ : readDaemonProfile(config.accessFile);
1325
+ if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
1326
+
1257
1327
  try {
1258
1328
  pi.sendMessage(
1259
1329
  { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },