omp-conductor 0.19.6 → 0.20.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.
Files changed (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
package/src/ask.ts CHANGED
@@ -1,51 +1,41 @@
1
1
  /**
2
- * The bounded ask surface for an orchestrator tick (#438).
2
+ * The durable, parking ask surface for an orchestrator tick (#438).
3
3
  *
4
4
  * On 2026-08-16 the orchestrator called `telegram_ask` for a genuine tier-2
5
5
  * contract decision (PR #432 / issue #368) and the operator was asleep. The
6
6
  * tool does not time out: it blocked the turn for just over six hours, and a
7
7
  * tick prompt timestamped 01:14Z did not execute until after the answer at
8
8
  * 07:04Z. Every duty behind the ask — draining, merging, grooming — stopped
9
- * with it. `telegram_ask` is omp-telegram's tool and this package cannot tell
10
- * it how long it may wait, so an autonomous tick gets a different ask surface:
11
- * {@link ASK_TOOL}. It records the question durably (a decision row, plus the
12
- * same delivery path the `message` command uses), waits at most a configured
13
- * ceiling — minutes by default, never hours — and then resolves the declared
14
- * non-answer outcome itself:
9
+ * with it. The first fix bounded the wait to minutes. This surface removes the
10
+ * wait entirely, because the session split made waiting pointless as well as
11
+ * dangerous:
15
12
  *
16
- * - `auto-proceed`: the recommended option is applied and the decision row is
17
- * resolved with `"<option> (auto-applied on ask timeout)"`, so the record can
18
- * never be read back as a human choice;
19
- * - `park`: the row stays open and pending re-surfaced in every tick prompt —
20
- * and the caller is told to take the blocked item out of the claimable queue
21
- * with its state recorded.
13
+ * - the tick brain is **headless** it never receives operator turns, so an
14
+ * answer could not land in the asking session even if the operator were
15
+ * awake, and a duty cycle must never block on a human;
16
+ * - operator conversation lives in a separate 24/7 **console** session, which
17
+ * answers by resolving the decision row (`omp-conductor decision resolve
18
+ * <id> --answer "…"`) — the same store write the dashboard and the CLI use,
19
+ * and 409-safe by `resolveDecision`'s open-only guard.
22
20
  *
23
- * A timeout is "nobody answered yet". It is never a cancelled ask, an errored
24
- * ask, or an operator "no": the only writes it makes are the auto-apply
25
- * resolution (which names itself as auto-applied) or none at all. The existing
26
- * seven-day decision expiry bounds a parked row exactly like any other open
27
- * decision nothing here reopens that clock.
21
+ * So the ask does exactly three things: it records the question durably (a
22
+ * decision row, verbatim), delivers it once through the sanctioned path, and
23
+ * returns immediately with the row id, the resolve command, and the
24
+ * instruction to park the blocked work in the same turn. It never resolves a
25
+ * row itself: there is no timeout to auto-apply, so nothing here can ever be
26
+ * read back as a human choice that never happened.
27
+ *
28
+ * A parked row is bounded without any new machinery: `expireDueDecisions`
29
+ * (daemon.ts) sweeps unanswered decisions after seven days, exactly as it does
30
+ * for every other open question.
28
31
  */
29
32
 
30
- import type { DecisionRecord, DecisionState, InterruptCategory, Store } from "./types.ts";
33
+ import type { InterruptCategory, Store } from "./types.ts";
31
34
  import { INTERRUPT_CATEGORIES } from "./types.ts";
32
35
 
33
- /** The bounded ask tool this package registers on an orchestrator session. */
36
+ /** The durable ask tool this package registers on an orchestrator session. */
34
37
  export const ASK_TOOL = "conductor_ask";
35
38
 
36
- /** The two legal non-answer outcomes, declared by the ask, never inferred. */
37
- export const ASK_TIMEOUT_OUTCOMES = ["auto-proceed", "park"] as const;
38
- export type AskTimeoutOutcome = (typeof ASK_TIMEOUT_OUTCOMES)[number];
39
-
40
- /**
41
- * The ceiling bounds. Minutes by default, never hours; an ask that is issued
42
- * without a `timeoutSeconds` gets {@link DEFAULT_ASK_TIMEOUT_SECONDS}, and one
43
- * that names a ceiling outside the bounds is refused rather than obeyed.
44
- */
45
- export const MIN_ASK_TIMEOUT_SECONDS = 60;
46
- export const MAX_ASK_TIMEOUT_SECONDS = 3_600;
47
- export const DEFAULT_ASK_TIMEOUT_SECONDS = 300;
48
-
49
39
  /**
50
40
  * The shape ceilings for a decision row's question (#593). A question is the
51
41
  * bare ask an operator reads on a phone — one sentence, the recommendation, the
@@ -66,36 +56,6 @@ export type QuestionShape =
66
56
  | { ok: true }
67
57
  | { ok: false; problem: string };
68
58
 
69
- /**
70
- * The suffix a decision row carries when nobody human chose the answer. Fixed
71
- * wording on purpose — the same reason `types.ts` gives for closed vocabularies
72
- * everywhere else: a marker a tick or an operator has to match by reading it
73
- * must be one no two spellings disagree on.
74
- */
75
- export const AUTO_APPLIED_SUFFIX = "(auto-applied on ask timeout)";
76
-
77
- /**
78
- * The fixed sentence a decision row carries when the question could not be
79
- * posted through a selectable surface and went out as plain text instead
80
- * (#722). The row is the record a later reader judges "did the operator answer
81
- * this?" from, so it must say plainly that a prose reply is not a selection:
82
- * nothing in this pipeline ever maps free text back to an option, and the
83
- * marker is what stops a reader from treating a chat reply as the row's
84
- * answer. It rides the row's `blocks` free-text slot — the one place a row
85
- * carry durable per-row prose that renders in every digest — without
86
- * rewriting the archived question (which must stay verbatim, #741).
87
- */
88
- export const DEGRADED_DELIVERY_MARKER =
89
- "question delivered as plain text (no selectable surface); a reply does not resolve it — resolve or withdraw the row by hand";
90
-
91
- /** The `blocks` text for a degraded ask: the block list plus the marker. */
92
- export function degradedAskBlocks(blocks: string | undefined): string | undefined {
93
- return blocks === undefined ? DEGRADED_DELIVERY_MARKER : `${blocks}; ${DEGRADED_DELIVERY_MARKER}`;
94
- }
95
-
96
- /** How often the bounded wait re-reads the decision row while waiting. */
97
- export const ASK_POLL_MS = 1_000;
98
-
99
59
  /** One option the operator may pick, as rendered in the delivered question. */
100
60
  export interface AskOption {
101
61
  label: string;
@@ -106,16 +66,12 @@ export interface AskOption {
106
66
  export interface AskRequest {
107
67
  /** The question verbatim, as it is archived on the decision row. */
108
68
  question: string;
109
- /** What the ask does when nobody answers within the ceiling. */
110
- onTimeout: AskTimeoutOutcome;
111
- /** Optional ceiling in whole seconds; defaults to the configured ceiling. */
112
- timeoutSeconds?: number;
113
69
  /** What is waiting on the answer, for the decision row and the digest. */
114
70
  blocks?: string;
115
71
  /**
116
- * The option applied by `auto-proceed`; required exactly for that outcome.
117
- * When {@link AskRequest.options} are supplied it must name one of their
118
- * labels — the label as delivered, never an index.
72
+ * The option you recommend, rendered in the delivered question. When
73
+ * {@link AskRequest.options} are supplied it must name one of their labels —
74
+ * the label as delivered, never an index.
119
75
  */
120
76
  recommended?: string;
121
77
  /** The choices shown to the operator, with {@link AskRequest.recommended} named. */
@@ -132,6 +88,16 @@ function isInterruptCategory(value: unknown): value is InterruptCategory {
132
88
  return typeof value === "string" && (INTERRUPT_CATEGORIES as readonly string[]).includes(value);
133
89
  }
134
90
 
91
+ /**
92
+ * The command that answers one row. One spelling for every result text and
93
+ * prompt line, for the reason `types.ts` gives for closed vocabularies
94
+ * everywhere else: a command an operator or a console session copies verbatim
95
+ * must not exist in two shapes, one of which the CLI refuses.
96
+ */
97
+ export function decisionResolveCommand(id: string): string {
98
+ return `omp-conductor decision resolve ${id} --answer "<their answer>"`;
99
+ }
100
+
135
101
  /**
136
102
  * Refuse a question that outgrew the phone-sized decision row. Counts the
137
103
  * trimmed question — the exact text archived on the row — against
@@ -165,9 +131,7 @@ export function validateQuestionShape(question: string): QuestionShape {
165
131
  /**
166
132
  * Validate one raw tool call. Strict like the verb arguments: an unknown shape
167
133
  * is refused with the reason, never coerced into a default that could hide what
168
- * the asker actually sent. The one tolerant field is the omitted `timeoutSeconds`
169
- * — the issue's whole point is that the ceiling holds even when the model does
170
- * not name one.
134
+ * the asker actually sent.
171
135
  */
172
136
  export function parseAskRequest(raw: unknown): AskParse {
173
137
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -190,46 +154,11 @@ export function parseAskRequest(raw: unknown): AskParse {
190
154
  };
191
155
  }
192
156
 
193
- const onTimeoutRaw = input["on-timeout"];
194
- if (
195
- typeof onTimeoutRaw !== "string" ||
196
- !(ASK_TIMEOUT_OUTCOMES as readonly string[]).includes(onTimeoutRaw)
197
- ) {
198
- return {
199
- ok: false,
200
- problem: `conductor_ask needs "on-timeout": one of ${ASK_TIMEOUT_OUTCOMES.join(" or ")} — what happens when nobody answers within the ceiling`,
201
- };
202
- }
203
- const onTimeout = onTimeoutRaw as AskTimeoutOutcome;
204
-
205
157
  const recommendedRaw = input["recommended"];
206
158
  const recommended =
207
159
  typeof recommendedRaw === "string" && recommendedRaw.trim().length > 0
208
160
  ? recommendedRaw.trim()
209
161
  : undefined;
210
- if (onTimeout === "auto-proceed" && recommended === undefined) {
211
- return {
212
- ok: false,
213
- problem:
214
- 'conductor_ask with "on-timeout": "auto-proceed" needs "recommended": the option to apply when nobody answers, ' +
215
- "because the decision row must record what was auto-applied",
216
- };
217
- }
218
-
219
- const timeoutRaw = input["timeoutSeconds"];
220
- let timeoutSeconds: number | undefined;
221
- if (timeoutRaw !== undefined) {
222
- if (typeof timeoutRaw !== "number" || !Number.isInteger(timeoutRaw)) {
223
- return { ok: false, problem: "conductor_ask timeoutSeconds must be a whole number of seconds when present" };
224
- }
225
- if (timeoutRaw < MIN_ASK_TIMEOUT_SECONDS || timeoutRaw > MAX_ASK_TIMEOUT_SECONDS) {
226
- return {
227
- ok: false,
228
- problem: `conductor_ask timeoutSeconds must be between ${MIN_ASK_TIMEOUT_SECONDS} and ${MAX_ASK_TIMEOUT_SECONDS}`,
229
- };
230
- }
231
- timeoutSeconds = timeoutRaw;
232
- }
233
162
 
234
163
  const blocksRaw = input["blocks"];
235
164
  const blocks = typeof blocksRaw === "string" && blocksRaw.trim().length > 0 ? blocksRaw.trim() : undefined;
@@ -265,9 +194,8 @@ export function parseAskRequest(raw: unknown): AskParse {
265
194
  // label exactly as the operator reads it — never an index (that is
266
195
  // telegram_ask's convention, not this tool's) and never free text when a
267
196
  // menu exists. Refused at parse time so the incoherent ask leaves no trace:
268
- // on `auto-proceed` its text becomes the recorded auto-applied answer, and a
269
- // value that named no option would resolve a decision row to something that
270
- // was never a choice (#740).
197
+ // the recommendation is delivered as one of the choices, and a value naming
198
+ // none of them is a question nobody can answer by picking (#740).
271
199
  if (options !== undefined && recommended !== undefined) {
272
200
  const labels = options.map((option) => option.label);
273
201
  if (!labels.includes(recommended)) {
@@ -292,8 +220,6 @@ export function parseAskRequest(raw: unknown): AskParse {
292
220
  ok: true,
293
221
  request: {
294
222
  question: question.trim(),
295
- onTimeout,
296
- ...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
297
223
  ...(blocks === undefined ? {} : { blocks }),
298
224
  ...(recommended === undefined ? {} : { recommended }),
299
225
  ...(options === undefined ? {} : { options }),
@@ -306,6 +232,10 @@ export function parseAskRequest(raw: unknown): AskParse {
306
232
  * The JSON Schema the harness advertises for {@link ASK_TOOL}, shaped like the
307
233
  * verb parameter schemas: plain JSON Schema, `additionalProperties: false`,
308
234
  * required lists spelled out.
235
+ *
236
+ * No timeout property of any kind, and deliberately no conditional
237
+ * requirement: the ask has exactly one behaviour — file, deliver, park — so
238
+ * the model has nothing to declare about what happens when nobody answers.
309
239
  */
310
240
  export function askParameterSchema(): Record<string, unknown> {
311
241
  return {
@@ -316,20 +246,10 @@ export function askParameterSchema(): Record<string, unknown> {
316
246
  description:
317
247
  "The question, written for a phone: one plain sentence, your recommendation, and the options with their consequences.",
318
248
  },
319
- "on-timeout": {
320
- type: "string",
321
- enum: [...ASK_TIMEOUT_OUTCOMES],
322
- description:
323
- "What happens when nobody answers within the ceiling: auto-proceed applies the recommended option and resolves the decision row naming the auto-application; park leaves the row open and pending, re-surfaced every tick.",
324
- },
325
- timeoutSeconds: {
326
- type: "number",
327
- description: `Ceiling before the ask times out. Optional — an ask issued without one still gets the default ceiling (${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget). Range ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}.`,
328
- },
329
249
  recommended: {
330
250
  type: "string",
331
251
  description:
332
- "The option applied on auto-proceed; required when on-timeout is auto-proceed because the row must record what was auto-applied. When options are supplied this must be one of their labels — the label as delivered to the operator, not an index.",
252
+ "The option you recommend, rendered in the delivered question. When options are supplied this must be one of their labels — the label as delivered to the operator, not an index.",
333
253
  },
334
254
  blocks: {
335
255
  type: "string",
@@ -354,22 +274,8 @@ export function askParameterSchema(): Record<string, unknown> {
354
274
  description: "Escalation category for the delivered question. Optional; defaults to decision-needed.",
355
275
  },
356
276
  },
357
- required: ["question", "on-timeout"],
277
+ required: ["question"],
358
278
  additionalProperties: false,
359
- // The one conditional requirement the tool has (#722): an ask that
360
- // declares auto-proceed must name the option it would auto-apply, because
361
- // the decision row must record what was applied and who applied it. Stated
362
- // in the schema (not only at call time) so the model sees it next to the
363
- // "on-timeout" property it conditions on.
364
- allOf: [
365
- {
366
- if: {
367
- properties: { "on-timeout": { const: "auto-proceed" } },
368
- required: ["on-timeout"],
369
- },
370
- then: { required: ["recommended"] },
371
- },
372
- ],
373
279
  };
374
280
  }
375
281
 
@@ -401,26 +307,6 @@ export function questionnaireParameterSchema(): Record<string, unknown> {
401
307
  };
402
308
  }
403
309
 
404
- /**
405
- * The ceiling for one ask, in whole seconds. The model may name one, but the
406
- * enforcement never depends on it: an ask without a `timeoutSeconds` gets the
407
- * configured ceiling, and both are capped at the turn budget so the ask can
408
- * never outlive the turn it runs in.
409
- */
410
- export function resolveAskCeilingSeconds(
411
- requestedSeconds: number | undefined,
412
- configuredSeconds: number | undefined,
413
- turnBudgetSeconds: number,
414
- ): number {
415
- const base = requestedSeconds ?? configuredSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS;
416
- return Math.min(base, Math.max(MIN_ASK_TIMEOUT_SECONDS, turnBudgetSeconds));
417
- }
418
-
419
- /** The row resolution for an auto-applied outcome — exactly the acceptance text. */
420
- export function autoApplyResolution(recommended: string): string {
421
- return `${recommended} ${AUTO_APPLIED_SUFFIX}`;
422
- }
423
-
424
310
  /** The question exactly as it is delivered, durable prefix and all. */
425
311
  export function askMessageFor(request: AskRequest): string {
426
312
  const lines = [`QUESTION: ${request.question}`];
@@ -443,8 +329,7 @@ export function askMessageFor(request: AskRequest): string {
443
329
  export const QUESTIONNAIRE_TOOL = "conductor_questionnaire";
444
330
 
445
331
  /** How many items one delivery may carry. A questionnaire longer than this is
446
- * not a questionnaire, it is an interview the operator will abandon halfway
447
- * and the ceiling that bounds the whole group is one ask's, not one per item. */
332
+ * not a questionnaire, it is an interview the operator will abandon halfway. */
448
333
  export const MAX_QUESTIONNAIRE_ITEMS = 6;
449
334
 
450
335
  /**
@@ -453,16 +338,15 @@ export const MAX_QUESTIONNAIRE_ITEMS = 6;
453
338
  *
454
339
  * It is deliberately N {@link AskRequest}s plus a spec binding rather than a
455
340
  * new question shape. Every per-item semantic a spec-out needs — options, a
456
- * recommendation, `auto-proceed` versus `park`, the shape ceiling, the
457
- * auto-applied marker already exists and is already tested here; what did not
458
- * exist is asking six of them once. Re-deriving those semantics for a
459
- * "questionnaire item" would be the same contract in two places, and the second
460
- * copy is the one that drifts.
341
+ * recommendation, the shape ceiling, the durable row — already exists and is
342
+ * already tested here; what did not exist is asking six of them once.
343
+ * Re-deriving those semantics for a "questionnaire item" would be the same
344
+ * contract in two places, and the second copy is the one that drifts.
461
345
  */
462
346
  export interface QuestionnaireRequest {
463
347
  /** The issue these questions spec out; their answers are its provenance. */
464
348
  specIssue: number;
465
- /** The items, in delivery order. Each is an ordinary bounded ask. */
349
+ /** The items, in delivery order. Each is an ordinary durable ask. */
466
350
  items: AskRequest[];
467
351
  }
468
352
 
@@ -537,266 +421,11 @@ export function questionnaireMessageFor(request: QuestionnaireRequest): string {
537
421
  );
538
422
  }
539
423
  if (item.recommended !== undefined) parts.push(` rec: ${item.recommended}`);
540
- // Silence is a real outcome, so it is stated per item rather than once for
541
- // the group: a questionnaire may legitimately mix "proceed on my
542
- // recommendation" with "this one waits for you".
543
- parts.push(
544
- ` on silence: ${
545
- item.onTimeout === "auto-proceed" ? `applies ${item.recommended ?? "the recommendation"}` : "stays open"
546
- }`,
547
- );
548
424
  lines.push(parts.join("\n"));
549
425
  }
550
426
  return lines.join("\n");
551
427
  }
552
428
 
553
- /** What the ceiling did to one still-open item. */
554
- export type QuestionnaireItemTimeout =
555
- | { kind: "auto-applied"; id: string; resolution: string }
556
- | { kind: "parked"; id: string };
557
-
558
- /**
559
- * Apply the ceiling to a questionnaire, per item (#947).
560
- *
561
- * Pure, and per item on purpose. The group is one delivery but N decisions: an
562
- * item the operator answered keeps that answer, an `auto-proceed` item resolves
563
- * with the existing auto-applied marker so the record never reads as a human
564
- * choice, and a `park` item stays open. Resolving the group atomically — all or
565
- * nothing — would discard the answers the operator did give, which is the
566
- * second silent fake this feature has to avoid.
567
- */
568
- export function resolveQuestionnaireTimeouts(
569
- items: readonly { id: string; request: AskRequest; answered: boolean }[],
570
- ): QuestionnaireItemTimeout[] {
571
- const outcomes: QuestionnaireItemTimeout[] = [];
572
- for (const item of items) {
573
- if (item.answered) continue;
574
- if (item.request.onTimeout === "auto-proceed" && item.request.recommended !== undefined) {
575
- outcomes.push({ kind: "auto-applied", id: item.id, resolution: autoApplyResolution(item.request.recommended) });
576
- continue;
577
- }
578
- // `park` — and the impossible-by-parse `auto-proceed` with no
579
- // recommendation, which parks rather than inventing one.
580
- outcomes.push({ kind: "parked", id: item.id });
581
- }
582
- return outcomes;
583
- }
584
-
585
- /**
586
- * The interactive surface a bounded ask posts through when one is available
587
- * (#722): the same Bot API `reply_markup` `telegram_ask` posts, whose taps are
588
- * routed by the running omp-telegram bridge. The bridge acknowledges the tap
589
- * and writes an answer envelope into the shared prompts directory; this handle
590
- * exists only to post the question, translate inbound envelopes into a decision
591
- * row resolution, and clean up. It is optional by design — a caller without
592
- * one degrades exactly as before, to the durable plain-text path with the
593
- * degraded marker on the row.
594
- */
595
- export interface AskInteractiveDelivery {
596
- /**
597
- * Why no interactive surface is available for this ask, or undefined when it
598
- * can post. Pure: no files, no network, no side effects — the caller decides
599
- * the row note before it creates the row.
600
- */
601
- unavailableReason(request: AskRequest): string | undefined;
602
- /**
603
- * Post the selectable question to the operator. Never throws: every failure
604
- * comes back as `{ ok: false, reason }` so the caller can fall back to the
605
- * durable text path. `decisionId` is the row id and the protocol nonce, so
606
- * the pending request and its answer are addressable by the row.
607
- */
608
- post(request: AskRequest, decisionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
609
- /**
610
- * Translate an inbound answer (a button tap, or a reply to a free-text ask)
611
- * into the decision row. Called on every poll while the ask waits; no-op
612
- * until an answer exists, idempotent afterwards (the row's double-resolve
613
- * guard makes the second write a no-op anyway).
614
- */
615
- collect(decisionId: string): void;
616
- /**
617
- * Best-effort cleanup once the wait ends, any outcome: remove the prompt
618
- * request and its answer from the shared dir, and strip the keyboard off the
619
- * posted message so a stale tap reads as expired rather than live.
620
- */
621
- close(decisionId: string): void;
622
- }
623
-
624
- /** One inline-keyboard button, as the Bot API renders it. */
625
- export interface AskKeyboardButton {
626
- text: string;
627
- callback_data: string;
628
- }
629
-
630
- /** The `reply_markup` of one interactive ask, one option per row plus Cancel. */
631
- export type AskKeyboardMarkup = { inline_keyboard: AskKeyboardButton[][] };
632
-
633
- /** The rendered interactive ask: the message text and its keyboard. */
634
- export interface AskInteractiveRender {
635
- text: string;
636
- markup: AskKeyboardMarkup;
637
- }
638
-
639
- /** Shorten a button label to Telegram's inline-button limit, like omp-telegram. */
640
- function clipButton(text: string, max: number): string {
641
- const clean = text.replace(/[\r\n]+/g, " ").trim();
642
- return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
643
- }
644
-
645
- /**
646
- * The interactive render of one bounded ask: the question as a normal ask
647
- * message (numbered options with their consequences when descriptions exist,
648
- * the same shape `telegram_ask` posts), and a keyboard whose callbacks speak
649
- * the shared `qa:<nonce>:<action>[:<index>]` protocol (`prompts.ts` in
650
- * omp-telegram), so the bridge answers the ask with the *label* of the tapped
651
- * option — never an index and never the model's numbering. Cancel closes the
652
- * ask as withdrawn, exactly like a `decision withdraw`.
653
- */
654
- export function renderInteractiveAsk(request: AskRequest, nonce: string): AskInteractiveRender {
655
- const options = request.options ?? [];
656
- const lines = [request.question.trim()];
657
- if (options.length === 0) {
658
- lines.push("", "Reply with your answer as the next message, or /cancel.");
659
- } else if (options.some((option) => option.description !== undefined)) {
660
- lines.push("");
661
- options.forEach((option, index) => {
662
- const recommended = option.label === request.recommended ? " (recommended)" : "";
663
- const description = option.description === undefined ? "" : ` — ${option.description}`;
664
- lines.push(`${index + 1}. ${option.label}${recommended}${description}`);
665
- });
666
- }
667
- const rows = options.map((option, index) => [
668
- { text: clipButton(option.label, 60), callback_data: `qa:${nonce}:s:${index}` },
669
- ]);
670
- rows.push([{ text: "Cancel", callback_data: `qa:${nonce}:x` }]);
671
- return { text: lines.join("\n"), markup: { inline_keyboard: rows } };
672
- }
673
-
674
- /** One answer within an answered envelope — the bridge's prompt answer shape. */
675
- export interface AskAnswer {
676
- id: string;
677
- question: string;
678
- /** The chosen option labels — the tap resolves the row with these, verbatim. */
679
- selectedOptions: string[];
680
- /** The reply to a free-text ask, when the question had no options. */
681
- customInput?: string;
682
- }
683
-
684
- /** One answer envelope from the shared prompts dir (`<nonce>.answer.json`). */
685
- export interface AskAnswerEnvelope {
686
- outcome:
687
- | { status: "answered"; answers: AskAnswer[] }
688
- | { status: "cancelled" | "expired" | "aborted" };
689
- }
690
-
691
- /**
692
- * Parse one prompt answer envelope. Malformed or unreadable input is
693
- * undefined — "no answer yet" is never a crash.
694
- */
695
- export function parseAskAnswerEnvelope(raw: unknown): AskAnswerEnvelope | undefined {
696
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
697
- const outcome = (raw as Record<string, unknown>)["outcome"];
698
- if (outcome === null || typeof outcome !== "object" || Array.isArray(outcome)) return undefined;
699
- const record = outcome as Record<string, unknown>;
700
- const status = record["status"];
701
- if (status === "answered") {
702
- const answers = record["answers"];
703
- if (!Array.isArray(answers)) return undefined;
704
- const parsed: AskAnswer[] = [];
705
- for (const answer of answers) {
706
- if (answer === null || typeof answer !== "object" || Array.isArray(answer)) return undefined;
707
- const a = answer as Record<string, unknown>;
708
- if (typeof a["id"] !== "string" || typeof a["question"] !== "string") return undefined;
709
- const selected = a["selectedOptions"];
710
- if (!Array.isArray(selected) || selected.some((label) => typeof label !== "string")) {
711
- return undefined;
712
- }
713
- const customInput = a["customInput"];
714
- if (customInput !== undefined && typeof customInput !== "string") return undefined;
715
- parsed.push({
716
- id: a["id"],
717
- question: a["question"],
718
- selectedOptions: selected as string[],
719
- ...(customInput === undefined ? {} : { customInput }),
720
- });
721
- }
722
- return { outcome: { status: "answered", answers: parsed } };
723
- }
724
- if (status === "cancelled" || status === "expired" || status === "aborted") {
725
- return { outcome: { status } as AskAnswerEnvelope["outcome"] };
726
- }
727
- return undefined;
728
- }
729
-
730
- /**
731
- * The decision-row write one envelope calls for, or none. An answer becomes a
732
- * row resolution carrying the option *label* (or the free-text reply to a
733
- * free-text ask) — the identity the operator actually chose; cancel becomes a
734
- * withdrawal, so a deliberate close is never an answer; expiry/abort mean
735
- * "nobody answered yet" and leave the row open for the bounded wait to decide.
736
- */
737
- export function askAnswerRowWrite(
738
- envelope: AskAnswerEnvelope,
739
- ): { state: "answered" | "withdrawn"; resolution: string } | undefined {
740
- if (envelope.outcome.status === "cancelled") {
741
- return { state: "withdrawn", resolution: "Question cancelled by the operator" };
742
- }
743
- if (envelope.outcome.status !== "answered") return undefined;
744
- const first = envelope.outcome.answers[0];
745
- if (first === undefined) return undefined;
746
- const resolution =
747
- first.customInput !== undefined && first.customInput.trim() !== ""
748
- ? first.customInput.trim()
749
- : first.selectedOptions.join(", ");
750
- if (resolution.trim() === "") return undefined;
751
- return { state: "answered", resolution };
752
- }
753
-
754
- /** One read of the decision row; `undefined` means the id is unknown. */
755
- export type DecisionReader = (id: string) => DecisionRecord | undefined;
756
-
757
- export interface BoundedAskDeps {
758
- decisionId: string;
759
- ceilingMs: number;
760
- read: DecisionReader;
761
- /** Advance one poll; injected so tests stay deterministic. */
762
- wait: (ms: number) => Promise<void>;
763
- now: () => number;
764
- /**
765
- * Called once per poll, before the row read: the seam through which an
766
- * interactive delivery surface (a Telegram button tap, #722) translates an
767
- * inbound answer into a decision-row write the read then sees. Optional and
768
- * synchronous on purpose — the wait must never depend on anything slower
769
- * than the row itself.
770
- */
771
- settle?: () => void;
772
- }
773
-
774
- export type BoundedAskOutcome =
775
- | { kind: "answered"; answer: string }
776
- | { kind: "withdrawn" }
777
- | { kind: "timed-out" };
778
-
779
- /**
780
- * The wait itself: read the row, leave when someone closed it, time out at the
781
- * deadline. Pure by construction — every clock and sleep is injected — so the
782
- * whole "does the ask come back bounded" contract is a deterministic test, not
783
- * a timer gamble.
784
- */
785
- export async function runBoundedAsk(deps: BoundedAskDeps): Promise<BoundedAskOutcome> {
786
- const deadline = deps.now() + deps.ceilingMs;
787
- for (;;) {
788
- deps.settle?.();
789
- const row = deps.read(deps.decisionId);
790
- if (row !== undefined && row.state !== "open") {
791
- return row.state === "answered"
792
- ? { kind: "answered", answer: row.resolution ?? "" }
793
- : { kind: "withdrawn" };
794
- }
795
- if (deps.now() >= deadline) return { kind: "timed-out" };
796
- await deps.wait(ASK_POLL_MS);
797
- }
798
- }
799
-
800
429
  /** What delivering the question to the operator returned, for the result text. */
801
430
  export interface AskDeliveryResult {
802
431
  kind: "sent" | "held";
@@ -806,7 +435,6 @@ export interface AskDeliveryResult {
806
435
 
807
436
  /** What one {@link ASK_TOOL} call produced, and the exact text the model reads. */
808
437
  export interface AskResult {
809
- outcome: BoundedAskOutcome;
810
438
  decisionId: string;
811
439
  text: string;
812
440
  }
@@ -814,173 +442,105 @@ export interface AskResult {
814
442
  export interface AskDeps {
815
443
  store: Store;
816
444
  project: string;
817
- /** The tick config's `askTimeoutSeconds`, already validated. */
818
- configuredCeilingSeconds?: number;
819
- /** The tick config's `budgetSeconds`, already validated. */
820
- turnBudgetSeconds?: number;
821
445
  /** Delivers the question through the sanctioned durable path. */
822
446
  deliver(questionText: string, category: InterruptCategory): Promise<AskDeliveryResult>;
823
- /**
824
- * The selectable delivery surface (#722): the same Bot API buttons
825
- * `telegram_ask` uses, whose taps resolve the decision row with the chosen
826
- * option label. Absent or unavailable → the ask degrades to the durable
827
- * text path, and the row records the degraded delivery.
828
- */
829
- interactive?: AskInteractiveDelivery;
830
- wait?: (ms: number) => Promise<void>;
831
447
  now?: () => number;
832
448
  }
833
449
 
834
- /** Real-time default for the injected wait; tests hand their own. */
835
- const sleep = (ms: number): Promise<void> => {
836
- const { promise, resolve } = Promise.withResolvers<void>();
837
- setTimeout(resolve, ms);
838
- return promise;
839
- };
450
+ /**
451
+ * One sentence naming what became of the delivery attempt. A throw is its own
452
+ * case rather than an escape: the row is already filed, so the honest report is
453
+ * "recorded, not sent" — and swallowing the throw is what lets the caller learn
454
+ * the row id at all, which is exactly what it needs in order to park the work.
455
+ */
456
+ function deliveryLine(delivery: AskDeliveryResult | undefined, failure: string | undefined): string {
457
+ if (delivery === undefined) {
458
+ return (
459
+ `Delivery FAILED (${failure ?? "unknown error"}): nothing was sent, and the question is held only as ` +
460
+ `the row itself. Say so in your report, and deliver it by hand with \`omp-conductor message\` if it ` +
461
+ `cannot wait for the next digest.`
462
+ );
463
+ }
464
+ return delivery.kind === "sent"
465
+ ? `It went to the operator as a ${delivery.category} message.`
466
+ : `It is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the daemon releases it ` +
467
+ `with the next digest or working-hours catch-up.`;
468
+ }
469
+
470
+ /** Deliver once, never throwing: the row outlives any transport failure. */
471
+ async function deliverOnce(
472
+ deps: AskDeps,
473
+ text: string,
474
+ category: InterruptCategory,
475
+ ): Promise<{ delivery?: AskDeliveryResult; failure?: string }> {
476
+ try {
477
+ return { delivery: await deps.deliver(text, category) };
478
+ } catch (err) {
479
+ return { failure: err instanceof Error ? err.message : String(err) };
480
+ }
481
+ }
840
482
 
841
483
  /**
842
- * One bounded ask, end to end: durable row first, then delivery, then the wait,
843
- * then the declared timeout outcome. The row exists before the delivery
844
- * attempt, so a crash at any point leaves a recorded question — never one that
845
- * lived only in the model's context.
484
+ * One ask, end to end: durable row, one delivery, park.
485
+ *
486
+ * The row exists before the delivery attempt, so a crash at any point leaves a
487
+ * recorded question — never one that lived only in the model's context. Then
488
+ * the call returns. Nothing polls, nothing waits, and this function never
489
+ * resolves the row: the console session does that when the operator answers.
846
490
  */
847
491
  export async function performAsk(request: AskRequest, deps: AskDeps): Promise<AskResult> {
848
492
  const now = deps.now ?? Date.now;
849
- const wait = deps.wait ?? sleep;
850
-
851
- // The delivery mode is decided before the row exists: the degraded marker
852
- // must ride the row's `blocks` from birth, and the row must exist before any
853
- // delivery attempt (a crash at any point leaves a recorded question). The
854
- // interactive preview is pure — no files, no network — so it can run here
855
- // without violating that ordering.
856
- const degradedReason =
857
- deps.interactive === undefined
858
- ? "no interactive delivery surface is mounted for this session"
859
- : deps.interactive.unavailableReason(request);
860
- const degraded = degradedReason !== undefined;
861
493
 
862
494
  const row = deps.store.createDecision({
863
495
  project: deps.project,
864
496
  question: request.question,
865
- ...(request.blocks === undefined && !degraded
866
- ? {}
867
- : { blocks: degraded ? degradedAskBlocks(request.blocks) : request.blocks }),
497
+ ...(request.blocks === undefined ? {} : { blocks: request.blocks }),
868
498
  at: now(),
869
499
  });
870
500
 
871
- // Interactive first: posting selectable options is the whole fix (#722).
872
- // A preview that passed but a post that failed falls back to the durable
873
- // text path below, and the result names the failure — the row keeps its
874
- // clean blocks in that rare race, because the note is decided at birth and
875
- // the row has no update surface by design.
876
- let interactivePosted = false;
877
- let postFailure: string | undefined;
878
- if (!degraded) {
879
- const posted = await deps.interactive!.post(request, row.id);
880
- interactivePosted = posted.ok;
881
- if (!posted.ok) postFailure = posted.reason;
882
- }
883
-
884
- let delivery: AskDeliveryResult | undefined;
885
- if (!interactivePosted) {
886
- delivery = await deps.deliver(askMessageFor(request), request.category ?? "decision-needed");
887
- }
888
-
889
- const ceilingSeconds = resolveAskCeilingSeconds(
890
- request.timeoutSeconds,
891
- deps.configuredCeilingSeconds,
892
- deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
501
+ const { delivery, failure } = await deliverOnce(
502
+ deps,
503
+ askMessageFor(request),
504
+ request.category ?? "decision-needed",
893
505
  );
894
- const outcome = await runBoundedAsk({
895
- decisionId: row.id,
896
- ceilingMs: ceilingSeconds * 1_000,
897
- read: (id) => deps.store.decision(id),
898
- wait,
899
- now,
900
- settle: interactivePosted ? () => deps.interactive!.collect(row.id) : undefined,
901
- });
902
- if (interactivePosted) deps.interactive!.close(row.id);
903
506
 
904
- let text: string;
905
- if (outcome.kind === "answered") {
906
- text =
907
- `conductor_ask ${row.id}: the operator answered ${outcome.answer}. ` +
908
- `The decision row is resolved with that answer. Proceed to apply it.`;
909
- } else if (outcome.kind === "withdrawn") {
910
- text =
911
- `conductor_ask ${row.id}: the decision was withdrawn by whoever holds the row before the ` +
912
- `ceiling elapsed. No answer was given and nothing was auto-applied. Treat it as "the question is ` +
913
- `closed", not as an answer.`;
914
- } else if (request.onTimeout === "auto-proceed") {
915
- const resolution = autoApplyResolution(request.recommended!);
916
- deps.store.resolveDecision(row.id, "answered", resolution, now());
917
- text =
918
- `conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — auto-proceeding as declared. ` +
919
- `The recommended option "${request.recommended}" is applied, and the decision row was resolved ` +
920
- `with the answer "${resolution}" so the record shows nobody human chose it. Proceed to apply ` +
921
- `that recommendation to the blocked work now.`;
922
- } else {
923
- text =
924
- `conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — parking as declared. ` +
925
- `The decision row stays open and pending, re-surfaced in every tick prompt until answered or the ` +
926
- `seven-day expiry. Park the blocked work now: take it out of the claimable queue and record its ` +
927
- `state (e.g. unlabel it or move it to a parked state), then note it in your report.`;
928
- }
929
-
930
- const deliveryLine =
931
- delivery === undefined
932
- ? ""
933
- : delivery.kind === "sent"
934
- ? ` The question went to the operator as a ${delivery.category} message.`
935
- : ` The question is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
936
- "daemon releases it with the next digest or working-hours catch-up.";
937
- const delivered = interactivePosted
938
- ? "The question went to the operator as selectable options (button taps); a tap resolves " +
939
- "the decision row with the chosen option, and a free-text ask is answered by replying."
940
- : degraded
941
- ? `The interactive surface is unavailable for this call (${degradedReason}), so the decision row ` +
942
- `records the degraded delivery: the operator's prose reply is NOT an answer to this row and ` +
943
- `does not resolve it — if they answer in prose, resolve or withdraw the row by hand ` +
944
- `(omp-conductor decision resolve|withdraw ${row.id}).${deliveryLine}`
945
- : `The interactive question could not be posted (${postFailure}) and the ask fell back to the ` +
946
- `durable text path.${deliveryLine}`;
947
-
948
- return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
507
+ return {
508
+ decisionId: row.id,
509
+ text:
510
+ `conductor_ask ${row.id}: the question is filed as open decision row ${row.id}. ` +
511
+ `${deliveryLine(delivery, failure)}\n` +
512
+ `Nothing waits for the answer here: this tick is headless, and the operator answers in the console ` +
513
+ `session, which resolves the row with \`${decisionResolveCommand(row.id)}\`. Until then the row stays ` +
514
+ `open and pending re-surfaced in every tick prompt until it is answered, withdrawn, or swept by ` +
515
+ `the seven-day decision expiry.\n` +
516
+ `Park the blocked work NOW, in this turn: take it out of the claimable queue and record its state ` +
517
+ `(e.g. unlabel it or move it to a parked state), then note the parked work and row ${row.id} in your ` +
518
+ `report. Do not wait, do not re-ask, and do not act as though the recommendation were approved.`,
519
+ };
949
520
  }
950
521
 
951
- /** What one {@link QUESTIONNAIRE_TOOL} call produced, per item and as text. */
522
+ /** What one {@link QUESTIONNAIRE_TOOL} call produced, and its text. */
952
523
  export interface QuestionnaireResult {
953
- /** The minted group id — the handle for `omp-conductor decision` on any item. */
524
+ /** The minted group id — the handle for `omp-conductor decision` on the set. */
954
525
  groupId: string;
955
- items: { id: string; question: string; state: DecisionState; resolution?: string }[];
526
+ items: { id: string; question: string }[];
956
527
  text: string;
957
528
  }
958
529
 
959
530
  /**
960
- * Ask one bounded spec-out questionnaire (#947).
961
- *
962
- * The shape mirrors {@link performAsk} deliberately — rows before delivery, one
963
- * bounded wait, per-item timeout policy applied at the ceiling — with two
964
- * differences that are the whole feature:
965
- *
966
- * 1. **One delivery.** The items go out as a single numbered message, so a
967
- * questionnaire costs one interrupt rather than N.
968
- * 2. **N independent resolutions.** The wait ends when every item is closed or
969
- * the ceiling elapses, and the ceiling then applies each item's own policy.
970
- * An item the operator answered keeps that answer.
531
+ * Ask one spec-out questionnaire (#947).
971
532
  *
972
- * It uses the durable text path rather than posting N interactive keyboards,
973
- * because N posts is exactly the thing it exists not to do. That means a prose
974
- * reply is not itself a row resolution the same contract a degraded single ask
975
- * already has so the result text names the ids and the command that resolves
976
- * them.
533
+ * The shape mirrors {@link performAsk} deliberately rows before delivery,
534
+ * then park with the one difference that is the whole feature: the items go
535
+ * out as a **single** numbered message, so a questionnaire costs one interrupt
536
+ * rather than N. Each item is still its own row, resolved independently and in
537
+ * any order by the console.
977
538
  */
978
539
  export async function performQuestionnaire(
979
540
  request: QuestionnaireRequest,
980
541
  deps: AskDeps,
981
542
  ): Promise<QuestionnaireResult> {
982
543
  const now = deps.now ?? Date.now;
983
- const wait = deps.wait ?? sleep;
984
544
 
985
545
  // Every row durable, in one transaction, BEFORE the delivery: an operator must
986
546
  // never be shown a question that no row is waiting on.
@@ -995,71 +555,26 @@ export async function performQuestionnaire(
995
555
  );
996
556
  const groupId = rows[0]!.groupId!;
997
557
 
998
- const category = request.items[0]!.category ?? "decision-needed";
999
- const delivery = await deps.deliver(questionnaireMessageFor(request), category);
1000
-
1001
- const ceilingSeconds = resolveAskCeilingSeconds(
1002
- // One ceiling for the group, taken from the shortest item that named one:
1003
- // the whole point is that a questionnaire cannot wait longer than an ask.
1004
- request.items.reduce<number | undefined>(
1005
- (shortest, item) =>
1006
- item.timeoutSeconds === undefined ? shortest : Math.min(shortest ?? item.timeoutSeconds, item.timeoutSeconds),
1007
- undefined,
1008
- ),
1009
- deps.configuredCeilingSeconds,
1010
- deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
558
+ const { delivery, failure } = await deliverOnce(
559
+ deps,
560
+ questionnaireMessageFor(request),
561
+ request.items[0]!.category ?? "decision-needed",
1011
562
  );
1012
- const deadline = now() + ceilingSeconds * 1_000;
1013
- for (;;) {
1014
- const live = deps.store.decisionGroup(groupId);
1015
- if (live.every((row) => row.state !== "open")) break;
1016
- if (now() >= deadline) break;
1017
- await wait(ASK_POLL_MS);
1018
- }
1019
-
1020
- // The ceiling, per item. `resolveDecision` refuses a second resolution, so an
1021
- // answer that landed in the same instant as the deadline is never overwritten.
1022
- const atCeiling = deps.store.decisionGroup(groupId);
1023
- for (const outcome of resolveQuestionnaireTimeouts(
1024
- atCeiling.map((row, index) => ({
1025
- id: row.id,
1026
- request: request.items[index]!,
1027
- answered: row.state !== "open",
1028
- })),
1029
- )) {
1030
- if (outcome.kind === "auto-applied") {
1031
- deps.store.resolveDecision(outcome.id, "answered", outcome.resolution, now());
1032
- }
1033
- }
1034
563
 
1035
- const final = deps.store.decisionGroup(groupId);
1036
- const items = final.map((row) => ({
1037
- id: row.id,
1038
- question: row.question,
1039
- state: row.state,
1040
- ...(row.resolution === undefined ? {} : { resolution: row.resolution }),
1041
- }));
1042
- const open = items.filter((item) => item.state === "open");
1043
- const deliveryLine =
1044
- delivery.kind === "sent"
1045
- ? `The questionnaire went to the operator as one ${delivery.category} message.`
1046
- : `The questionnaire is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
1047
- "daemon releases it with the next digest or working-hours catch-up.";
564
+ const items = rows.map((row) => ({ id: row.id, question: row.question }));
1048
565
  const perItem = items
1049
- .map(
1050
- (item, index) =>
1051
- `${index + 1}. ${item.state}${item.resolution === undefined ? "" : ` — ${item.resolution}`} (${item.id})`,
1052
- )
566
+ .map((item, index) => `${index + 1}. ${item.id} — ${decisionResolveCommand(item.id)}`)
1053
567
  .join("\n");
1054
- const closing =
1055
- open.length === 0
1056
- ? "Every item is resolved. Apply the answers to the spec now."
1057
- : `${open.length} item(s) stayed open as declared: park the work each one blocks, and note them in your ` +
1058
- "report. A prose reply does NOT resolve these rows — map it yourself with " +
1059
- "`omp-conductor decision resolve <id> --answer \"…\"`.";
1060
568
  return {
1061
569
  groupId,
1062
570
  items,
1063
- text: `${deliveryLine} Ceiling ${ceilingSeconds}s; #${request.specIssue}.\n${perItem}\n${closing}`,
571
+ text:
572
+ `conductor_questionnaire ${groupId}: ${items.length} questions about #${request.specIssue}, each filed ` +
573
+ `as its own open decision row, delivered as one message. ${deliveryLine(delivery, failure)}\n` +
574
+ `${perItem}\n` +
575
+ `Nothing waits for the answers here: the console session resolves each row as the operator answers ` +
576
+ `it, in any order, and an unanswered row is swept by the seven-day decision expiry. Park the work ` +
577
+ `every item blocks NOW, in this turn — out of the claimable queue, state recorded — and note group ` +
578
+ `${groupId} in your report.`,
1064
579
  };
1065
580
  }