omp-conductor 0.12.0 → 0.14.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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Mechanical operator availability in an IANA timezone (#273).
3
+ *
4
+ * This module owns the clock decision. Callers supply `now`; neither a model nor
5
+ * process-local memory decides whether an interruption is allowed. UTC-minute
6
+ * scanning for the next transition is intentional: evaluating the same local
7
+ * predicate across real instants handles skipped and repeated DST wall-clock
8
+ * minutes without inventing an offset conversion of our own.
9
+ */
10
+
11
+ import { WEEKDAYS, type InterruptCategory, type ReportingPolicy, type Weekday, type WeeklyAvailability } from "./types.ts";
12
+
13
+ const MINUTE_MS = 60_000;
14
+ const TRANSITION_HORIZON_MS = 15 * 24 * 60 * MINUTE_MS;
15
+
16
+ const formatters = new Map<string, Intl.DateTimeFormat>();
17
+ interface CachedTransition {
18
+ from: number;
19
+ until: number;
20
+ open: boolean;
21
+ nextTransitionAt?: number;
22
+ }
23
+
24
+ const transitions = new WeakMap<WeeklyAvailability, CachedTransition>();
25
+
26
+
27
+ function formatter(timezone: string): Intl.DateTimeFormat {
28
+ const existing = formatters.get(timezone);
29
+ if (existing !== undefined) return existing;
30
+ const created = new Intl.DateTimeFormat("en-GB", {
31
+ timeZone: timezone,
32
+ weekday: "short",
33
+ year: "numeric",
34
+ month: "2-digit",
35
+ day: "2-digit",
36
+ hour: "2-digit",
37
+ minute: "2-digit",
38
+ hourCycle: "h23",
39
+ });
40
+ formatters.set(timezone, created);
41
+ return created;
42
+ }
43
+
44
+ interface LocalMinute {
45
+ day: Weekday;
46
+ date: string;
47
+ clock: string;
48
+ }
49
+
50
+ function localMinute(at: number, timezone: string): LocalMinute {
51
+ const parts = formatter(timezone).formatToParts(new Date(at));
52
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
53
+ parts.find((part) => part.type === type)?.value ?? "";
54
+ return {
55
+ day: get("weekday").slice(0, 3).toLowerCase() as Weekday,
56
+ date: `${get("year")}-${get("month")}-${get("day")}`,
57
+ clock: `${get("hour")}:${get("minute")}`,
58
+ };
59
+ }
60
+
61
+ function previousDay(day: Weekday): Weekday {
62
+ const index = WEEKDAYS.indexOf(day);
63
+ return WEEKDAYS[(index + WEEKDAYS.length - 1) % WEEKDAYS.length]!;
64
+ }
65
+
66
+ /** Whether `at` falls inside the configured local weekly window. */
67
+ export function availabilityOpen(window: WeeklyAvailability | undefined, at: number): boolean {
68
+ if (window === undefined) return true;
69
+ const local = localMinute(at, window.timezone);
70
+ if (window.start < window.end) {
71
+ return window.days.includes(local.day) && local.clock >= window.start && local.clock < window.end;
72
+ }
73
+ // Overnight: a selected day opens at `start` and remains open on the next
74
+ // local day until `end`.
75
+ return (
76
+ (window.days.includes(local.day) && local.clock >= window.start) ||
77
+ (window.days.includes(previousDay(local.day)) && local.clock < window.end)
78
+ );
79
+ }
80
+
81
+ export type InterruptDisposition = "interrupt" | "digest" | "availability";
82
+
83
+ export function availabilityDisposition(
84
+ window: WeeklyAvailability | undefined,
85
+ category: InterruptCategory,
86
+ at: number,
87
+ ): Exclude<InterruptDisposition, "digest"> {
88
+ if (window === undefined || availabilityOpen(window, at) || window.bypass.includes(category)) {
89
+ return "interrupt";
90
+ }
91
+ return "availability";
92
+ }
93
+
94
+ /**
95
+ * Decide one tier-2 category. `digest` means the category policy itself defers
96
+ * it; `availability` means it was otherwise interruptible and may be released
97
+ * when the configured window next opens.
98
+ */
99
+ export function interruptDisposition(
100
+ policy: ReportingPolicy | undefined,
101
+ category: InterruptCategory,
102
+ at: number,
103
+ ): InterruptDisposition {
104
+ if (policy !== undefined && !policy.interruptOn.includes(category)) return "digest";
105
+ return availabilityDisposition(policy?.availability, category, at);
106
+ }
107
+
108
+ export interface AvailabilityState {
109
+ mode: "always" | "working" | "quiet";
110
+ timezone?: string;
111
+ nextTransitionAt?: number;
112
+ bypass: InterruptCategory[];
113
+ }
114
+
115
+ /** Current mode plus the first real instant at which that mode changes. */
116
+ export function availabilityState(policy: ReportingPolicy | undefined, now: number): AvailabilityState {
117
+ const window = policy?.availability;
118
+ if (window === undefined) return { mode: "always", bypass: [] };
119
+
120
+ const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
121
+ let cached = transitions.get(window);
122
+ if (cached === undefined || from < cached.from || from >= cached.until) {
123
+ const open = availabilityOpen(window, now);
124
+ const end = now + TRANSITION_HORIZON_MS;
125
+ let cursor = from + MINUTE_MS;
126
+ while (cursor <= end && availabilityOpen(window, cursor) === open) cursor += MINUTE_MS;
127
+ cached = {
128
+ from,
129
+ until: cursor,
130
+ open,
131
+ ...(cursor > end ? {} : { nextTransitionAt: cursor }),
132
+ };
133
+ transitions.set(window, cached);
134
+ }
135
+
136
+ const { open, nextTransitionAt } = cached;
137
+ return {
138
+ mode: open ? "working" : "quiet",
139
+ timezone: window.timezone,
140
+ ...(nextTransitionAt === undefined ? {} : { nextTransitionAt }),
141
+ bypass: [...window.bypass],
142
+ };
143
+ }
144
+
145
+ /** Stable operator-facing local timestamp for status and tick prompts. */
146
+ export function formatZonedMinute(at: number, timezone: string): string {
147
+ const local = localMinute(at, timezone);
148
+ return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
149
+ }
150
+
151
+ /** One prompt sentence; the runtime gate, not this prose, owns the decision. */
152
+ export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
153
+ const state = availabilityState(policy, now);
154
+ if (state.mode === "always") {
155
+ return "Operator availability (mechanical): 24-hour interrupts; no working-hours window is configured.";
156
+ }
157
+ const until =
158
+ state.nextTransitionAt === undefined || state.timezone === undefined
159
+ ? "the next configured transition"
160
+ : formatZonedMinute(state.nextTransitionAt, state.timezone);
161
+ const bypass = state.bypass.length === 0 ? "none" : state.bypass.join(", ");
162
+ return state.mode === "working"
163
+ ? `Operator availability (mechanical): working until ${until}; outside-hours bypass: ${bypass}.`
164
+ : `Operator availability (mechanical): quiet until ${until}; only these categories bypass quiet hours: ${bypass}.`;
165
+ }
package/src/board.ts CHANGED
@@ -95,7 +95,7 @@ const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
95
95
  * that has to stay visible — #173. Differs from {@link LIVE_LANES} in that
96
96
  * these are rows, never current work, and from MERGED in that they are not a
97
97
  * happy resolution. */
98
- const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "orphaned"]);
98
+ const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "stopped", "orphaned"]);
99
99
 
100
100
  /** Whether a terminal blocked/failed run is wearing its own state label. When
101
101
  * the label is absent the run row is the only record of what happened, so the
@@ -154,6 +154,7 @@ to notice it unaided.
154
154
  | `undisclosed-file` | The PR touched a file the report never mentioned. | Open the diff for that file. An undisclosed edit is usually incidental — a lockfile, a formatter — and occasionally the whole story. |
155
155
  | `changed-line-missing` | The report disclosed nothing at all. | Read the diff before merging; you have no summary of it. |
156
156
  | `unmatched-claim` | The report named a file the PR never touched. | Weak on its own. Two or three together mean the report was written from memory rather than from `git diff`, so trust the rest of it less. |
157
+ | `report-format-unparsed` | The audit could not parse the report's format. | Not a trust signal against the worker — read the diff directly. |
157
158
  | `test-file-deleted` | A test file left the tree and no rename explains it. | The one that most deserves a human. Was the behaviour it defended deleted too, or only its test? |
158
159
  | `test-disabled` | A `.skip` / `.only` / `xit` / `t.Skip` marker was added. | Ask what turned red. A skip added in the same PR as the change it stopped failing is the shape to look for. |
159
160
  | `assertions-removed` | Assertions were commented out, or more left a file than entered it. | Compare against the issue's acceptance criteria: an assertion removed because the spec changed is fine, one removed because it failed is not. |
@@ -230,25 +231,52 @@ Keep the queue worth draining.
230
231
  See **Reporting** below. That section is yours, and it is the only thing that
231
232
  decides whether this tick ends in a message or in silence.
232
233
 
234
+ Whenever the reporting policy defers material outcomes to a digest, record each
235
+ ordinary outcome as soon as it happens:
236
+
237
+ ```bash
238
+ omp-conductor event record \
239
+ --category merge \
240
+ --summary "#42 merged" \
241
+ --evidence "https://github.com/acme/api/pull/42"
242
+ ```
243
+
244
+ This command sends nothing. It writes the outcome to the durable digest ledger.
245
+ Use a short lowercase category, a one-line summary, and the issue, PR, release,
246
+ run, or URL that proves the result. Do not keep an outcome only in session memory
247
+ or in a Markdown scratch file. A later due-digest prompt lists the owed rows
248
+ with their ids. Include the rows you use by passing its printed `--events` and
249
+ `--notices` arguments to `omp-conductor report --kind digest`; omitted rows stay
250
+ owed. When policy permits material outcomes to interrupt, report them directly
251
+ instead.
252
+
233
253
  *How* a report is delivered is not yours, and is not negotiable: run
234
- `omp-conductor report --text "<the whole report>"` (add `--kind digest` for the
235
- daily digest). It persists the text before anything is sent and prints a report
236
- report id; the daemon retries until it lands and `omp-conductor status` lists whatever
237
- has not. Writing a report as end-of-turn text on a tick reaches nobody — that is
238
- how a suite release and two tier-2 escalations went missing on 2026-08-06 — and
239
- `telegram_send` reaches somebody but leaves no record that it did, so a report
240
- sent that way is undetectable when it does not arrive.
254
+ `omp-conductor report --text "<the whole report>"` (add `--kind digest` plus
255
+ the ledger row ids printed in the tick prompt for a digest). It persists the
256
+ text before anything is sent and prints a durable handoff id: a report id when
257
+ delivery is allowed, or a held-notice id until a digest or working-hours
258
+ catch-up claims it. The daemon owns delivery from there, and
259
+ `omp-conductor status` lists whatever it still owes.
260
+ Writing a report as end-of-turn text on a tick reaches nobody — that is how a suite release
261
+ and two tier-2 escalations went missing on 2026-08-06 — and `telegram_send`
262
+ reaches somebody but leaves no record that it did, so a report sent that way is
263
+ undetectable when it does not arrive.
241
264
 
242
265
  A report is an update. It never contains a request: no "needs you" header, no
243
- "let me know", no embedded options. Anything needing a decision, approval or
244
- answer leaves as its own ask (`telegram_ask`) at the moment it is known — the
245
- question in one sentence, your recommendation, and the options with their
246
- consequences, the recommended one marked. Batch several questions into one
247
- ask (the surface takes up to five); never one call per question, and never a
248
- numbered menu typed into a plain message. Still open a `decision` row for
249
- anything you ask: the ask is how it reaches a human, the row is what stops it
250
- being forgotten. In both directions the delivery contract is explicit: a
251
- message you did not explicitly send is a message that did not arrive.
266
+ "let me know", no embedded options. Use `telegram_ask` for each decision,
267
+ approval, or answer that you need. Include one sentence for the question, your
268
+ recommendation, and the options with their consequences. Mark the recommended
269
+ option. Batch several questions into one ask (the surface takes up to five);
270
+ never make one call per question, and never type a numbered menu into a plain
271
+ message. The tool shows each question on the terminal and Telegram, then accepts
272
+ the first answer from either surface. A returned answer proves an answer, not
273
+ Telegram delivery. If the question must demonstrably reach the operator through
274
+ Telegram, send it separately with `telegram_send` and prefix its text
275
+ `QUESTION:` so the autonomous-tick gate applies the decision category. Still
276
+ open a `decision` row for anything you ask: the ask collects the answer, and the
277
+ row stops it from being forgotten. In both directions, the delivery
278
+ contract is explicit: a message you did not explicitly send is a message that
279
+ did not arrive.
252
280
 
253
281
  ## Human messages
254
282
 
@@ -259,10 +287,11 @@ text you merely write reaches nobody — if you do not call `telegram_send`, the
259
287
  person gets silence. While handling any turn, produce no visible commentary
260
288
  between tool calls — reasoning stays in thinking, actions stay in tools.
261
289
 
262
- If the answer needs a decision from the operator (a choice, a yes/no, an
263
- approval), ask it with `telegram_ask` never a numbered-options message via
264
- `telegram_send`, and never the generic `ask` UI. A cancelled or errored
265
- `telegram_ask` is a delivery failure, not an answer.
290
+ If the answer needs a decision from the operator (a choice, a yes/no, or an
291
+ approval), ask it with `telegram_ask`. Never send numbered options through
292
+ `telegram_send`, and never use the generic `ask` UI. The tool returns the first
293
+ answer from the terminal or Telegram. A returned answer proves an answer, not
294
+ Telegram delivery. A cancelled or errored `telegram_ask` is not an answer.
266
295
 
267
296
  A message may also reach you **mid-tick** (delivery is steering: it arrives
268
297
  between two of your tool calls). Treat it as an interrupt, not a new tick:
@@ -280,6 +309,13 @@ arrived, and never batch the answer "for the report" — the person is waiting n
280
309
  Promote rather than improvise. Escalating is a successful outcome; guessing is
281
310
  not.
282
311
 
312
+ The tick prompt carries the runtime's mechanical operator-availability state.
313
+ Escalate tier 2 normally; do not infer working hours or bypass the configured
314
+ gate yourself. Outside the configured window, the daemon holds non-bypass
315
+ categories durably for the daily digest or one catch-up report when the window
316
+ opens. Configured bypass categories still page immediately. With no window,
317
+ interrupt behavior remains 24-hour.
318
+
283
319
  ## Hard boundaries
284
320
 
285
321
  Not yours to relax:
@@ -351,6 +387,7 @@ the same checks and the same ledger rows, through the CLI:
351
387
  omp-conductor verb conductor_pr_merge --arg prUrl=<url> --arg headSha=<sha> --arg reason=<reason>
352
388
  omp-conductor verb conductor_label --arg issueUrl=<url> --arg label=<name> --arg action=add --arg reason=<reason>
353
389
  omp-conductor verb conductor_release --arg shape=git-tag --arg repo=<name> --arg reason=<reason> --arg tag=<tag>
390
+ omp-conductor verb conductor_pr_status --arg prUrl=<url>
354
391
  omp-conductor verb conductor_pr_update_branch --arg prUrl=<url>
355
392
  omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
356
393
  ```
@@ -368,8 +405,37 @@ sessions do not.
368
405
  | `conductor_label` | always | The label is one this project declared. Lifecycle labels (`agent:in-progress`, `agent:blocked`, `agent:failed`) are refused — those stay the dispatcher's, and `omp-conductor unblock` is how you clear them. |
369
406
  | `conductor_release` | `authority.release` is yours | You are the configured holder, the shape is granted, the artefact or environment was declared, and the release preconditions hold. |
370
407
 
371
- **`conductor_pr_merge` wants the SHA you believe you are merging.** Read it with
372
- `conductor_pr_status` and pass it. The dispatcher re-reads the live head
408
+ ### Stopping and pausing
409
+
410
+ Four controls stop different work:
411
+
412
+ - **Pause one worker:** `omp-conductor worker pause <issue>` cooperatively drains
413
+ the active turn to harness idle, freezes its remaining wall clock, and keeps
414
+ the same live run, session, attempt and worktree. Its slot stays occupied and
415
+ its lifecycle labels stay in place. `omp-conductor worker resume <issue>`
416
+ continues that same session with a prompt to re-check its last action before
417
+ proceeding.
418
+ - **End one worker:** `omp-conductor worker stop <issue> --reason TEXT`
419
+ terminally settles a running or paused run as `stopped`. It salvages dirty
420
+ work, releases the worker slot, and removes `agent:in-progress`; it does not
421
+ spend a failure or continuation budget. Use it when the operator explicitly
422
+ ends obsolete or already-delivered work. A salvage failure keeps the tree and
423
+ names the path; recover that copy before any forced unblock.
424
+ - **Fleet dispatch:** `omp-conductor pause` stops new claims and work-starting
425
+ mutations. Work admitted before the pause may still complete, and completion
426
+ verbs and releases remain available.
427
+ - **Orchestrator ticks:** `omp-conductor disarm` removes the operator-owned
428
+ `ARMED` marker so ticks skip. It does not pause workers or stop processes.
429
+
430
+ Per-worker pause is not SIGSTOP/SIGCONT, fleet pause, unblock/requeue, or a
431
+ durable restart boundary. Daemon loss still follows the normal salvage and
432
+ orphan handling. Pause only when the operator asks to park one worker, or when
433
+ one live run must quiesce before resolving a proven shared-state collision.
434
+ Never pause routine work speculatively.
435
+
436
+ **`conductor_pr_merge` wants the SHA you believe you are merging.** Call
437
+ `conductor_pr_status` with the PR URL; its reply includes the current full SHA.
438
+ Pass that SHA to `conductor_pr_merge`. The dispatcher re-reads the live head
373
439
  immediately before merging and refuses on any mismatch, naming both SHAs —
374
440
  because any push since you looked invalidates the green you saw. A refusal there
375
441
  is the mechanism working: re-read, re-check, call again.
@@ -428,12 +494,13 @@ The protocol, in order:
428
494
  stand, then the lines you propose. A diff, not a description of one. This full
429
495
  text is what you *apply* on a yes — it is not what you send.
430
496
  2. **Ask, once — a single yes/no question, written for a phone.** Explicitly
431
- call `telegram_ask`; never use the generic `ask` UI. Confirm that the tool
432
- delivered the question to the configured Telegram chat. It is mounted on a
433
- locally injected tick only once your operator has configured the bridge's
434
- notify destination, and a tick that lacks it says so in its own text. When it
435
- does, send the same single yes/no question with `telegram_send` and treat
436
- your operator's later reply as the answer never assume one. Telegram renders
497
+ call `telegram_ask`; never use the generic `ask` UI. The tool shows the
498
+ question on the terminal and Telegram, then returns the first answer from
499
+ either surface. A returned answer proves an answer, not Telegram delivery.
500
+ If the proposal must demonstrably reach the operator through Telegram, send
501
+ the compact question separately with `telegram_send`. If `telegram_ask` is
502
+ unavailable, send the same single yes/no question with `telegram_send`.
503
+ Wait for the operator's later reply, and never assume one. Telegram renders
437
504
  none of your markdown, so asterisks and backticks arrive as literal characters:
438
505
  - Lead with one plain sentence: what changes, and why, in your own words.
439
506
  - Then show only the lines that actually change, compact, under two short
@@ -59,39 +59,48 @@ and spot issues that would collide. Ask an omp session to read
59
59
 
60
60
  ## Reporting
61
61
 
62
- Your report scope is **`{{REPORT_SCOPE}}`**. All three scopes, spelled out:
63
-
64
- - **`escalations`** you speak when a human is needed, and once a day otherwise.
65
- That is: every tier-2 escalation immediately, carrying the issue link and the
66
- single question; plus one daily digest naming what merged, what is green and
67
- waiting on a merge, and what is stuck and why. Every other tick is silent.
68
- - **`decisions`** tier-2 escalations immediately, and a fleet-stopping
69
- condition immediately; every other material event is held and delivered as one
70
- message with the next tick report. A merge at 10:04 and a green PR at 10:09
71
- arrive together at the 10:15 tick, not as two pings.
62
+ The live report scope and availability window come from conductor config on
63
+ every tick; the tick's `Reporting` and `Operator availability` lines are
64
+ authoritative. All four scopes, spelled out:
65
+
66
+ - **`escalations`** you speak when a human is needed, and otherwise wait for
67
+ the configured digest. That is: every tier-2 escalation when operator
68
+ availability permits, carrying the issue link and the single question; plus
69
+ a digest naming what merged, what is green and waiting on a merge, and what is
70
+ stuck and why.
71
+ - **`decisions`** tier-2 escalations and fleet-stopping conditions interrupt
72
+ when operator availability permits; every other material event is held and
73
+ delivered in the configured digest instead of as its own ping.
72
74
  - **`material`** — everything in `escalations`, plus each material event as it
73
- happens: a run reaching a green PR (with the link), a run that failed twice, an
74
- issue you pulled off the queue, a cap that stopped the fleet. A tick where
75
- nothing changed still says nothing "no change" is not an event.
76
-
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:
75
+ happens when operator availability permits: a run reaching a green PR (with
76
+ the link), a run that failed twice, an issue you pulled off the queue, a cap
77
+ that stopped the fleet. Outside configured hours, non-bypass interruptions
78
+ wait for the next configured digest or opening. A tick where nothing changed
79
+ still says nothing.
80
+ - **`quiet`** — tier-2 escalations, fleet stops, and confirmed failures may
81
+ interrupt when operator availability permits; everything else waits for one
82
+ daily rollup.
83
+
84
+ **Delivery.** Never rely on end-of-turn text reaching anyone. The provable
85
+ delivery paths are `omp-conductor report` (reports — persisted and retried by
86
+ the daemon) and `telegram_send` (direct messages). `telegram_ask` is the decision
87
+ primitive, not Telegram delivery evidence. Everything else is noise or silence.
88
+ Hand every reportable event to the conductor's outbox:
82
89
 
83
90
  ```
84
91
  omp-conductor report --text "<the whole report>" # a material event
85
92
  omp-conductor report --text "<the whole digest>" --kind digest
86
93
  ```
87
94
 
88
- The command persists the text *before* anything is sent and prints a report id;
89
- from there the daemon owns delivery and retries until it lands, so a report
90
- survives you being compacted, interrupted, or restarted mid-sentence. That is
91
- the difference between a report and a claim about one: check for the report id,
92
- and never say something was reported without it. Write plain text — Telegram
93
- renders none of your markdown, so asterisks and backticks arrive as literal
94
- characters and a pasted section becomes a wall.
95
+ The command persists the text *before* anything is sent and prints a durable
96
+ handoff id. During quiet hours a material report becomes a held-notice id for
97
+ the next digest or working-hours catch-up; otherwise it becomes a report id and
98
+ the daemon retries delivery until it lands. Both survive you being compacted,
99
+ interrupted, or restarted mid-sentence. That is the difference between a report
100
+ and a claim about one: check for the handoff id, and never say something was
101
+ reported without it. Write plain text Telegram renders none of your markdown,
102
+ so asterisks and backticks arrive as literal characters and a pasted section
103
+ becomes a wall.
95
104
 
96
105
  The digest is at-most-once per day and the ledger decides that, not your memory:
97
106
  a second `--kind digest` on the same day is refused and tells you which report
@@ -100,12 +109,15 @@ was lost mid-send is retried and arrives marked as a possible repeat; that is
100
109
  deliberate, and a duplicate you can spot by its report id is the cheaper of the
101
110
  two mistakes. `omp-conductor status` lists anything still undelivered.
102
111
 
103
- `telegram_send` is still the right call for talking to a person who is waiting —
104
- an answer to their message, or a question of your own. It is not a report: it
105
- leaves no record that anything went out. And a `cancelled` or errored
106
- `telegram_ask` is a delivery failure, not an answer: re-deliver the question with
107
- with `telegram_send`, or report the channel as broken. It is never "asked once, no
108
- reply, dropped".
112
+ `telegram_send` is still the right call for direct delivery to a person who is
113
+ waiting — an answer to their message, or a question of your own. It is not a
114
+ report: it leaves no record that anything went out. `telegram_ask` is the right
115
+ call for a decision. It shows the question on the terminal and Telegram, then
116
+ returns the first answer from either surface. A returned answer proves an
117
+ answer, not Telegram delivery. A cancelled or errored `telegram_ask` is not an
118
+ answer: re-deliver the question with `telegram_send`, prefixing its text
119
+ `QUESTION:` so an autonomous tick applies the decision category, or report the
120
+ channel as broken. It is never "asked once, no reply, dropped".
109
121
 
110
122
  Reports never carry questions: anything needing an answer goes out as its own
111
123
  ask, with a recommendation and options.