omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.9",
3
+ "version": "0.15.11",
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.",
@@ -24,10 +24,11 @@
24
24
  "systemd",
25
25
  "schema",
26
26
  "README.md",
27
+ "REFERENCE.md",
27
28
  "LICENSE"
28
29
  ],
29
30
  "scripts": {
30
- "check": "tsc --noEmit",
31
+ "check": "tsc --noEmit && bun run src/check-trailing-newlines.ts",
31
32
  "test": "bun test",
32
33
  "schema": "bun run src/generate-schema.ts"
33
34
  },
@@ -167,9 +167,6 @@
167
167
  "default": "."
168
168
  }
169
169
  },
170
- "required": [
171
- "cwd"
172
- ],
173
170
  "additionalProperties": false
174
171
  }
175
172
  },
@@ -317,10 +314,6 @@
317
314
  ]
318
315
  }
319
316
  },
320
- "required": [
321
- "fallbackToIssueComment",
322
- "orchestrator"
323
- ],
324
317
  "additionalProperties": false
325
318
  },
326
319
  "authority": {
@@ -341,12 +334,16 @@
341
334
  "human",
342
335
  "orchestrator"
343
336
  ]
337
+ },
338
+ "promotion": {
339
+ "default": "human",
340
+ "type": "string",
341
+ "enum": [
342
+ "human",
343
+ "orchestrator"
344
+ ]
344
345
  }
345
346
  },
346
- "required": [
347
- "merge",
348
- "release"
349
- ],
350
347
  "additionalProperties": false
351
348
  },
352
349
  "releasePolicy": {
@@ -413,12 +410,6 @@
413
410
  ]
414
411
  }
415
412
  },
416
- "required": [
417
- "requiredChecks",
418
- "baseFreshness",
419
- "drafts",
420
- "whenBehindBase"
421
- ],
422
413
  "additionalProperties": false
423
414
  },
424
415
  "release": {
@@ -465,12 +456,6 @@
465
456
  }
466
457
  }
467
458
  },
468
- "required": [
469
- "requires",
470
- "requiredChecks",
471
- "artefacts",
472
- "environments"
473
- ],
474
459
  "additionalProperties": false
475
460
  }
476
461
  },
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Authenticated pending-challenge state for the arming handshake (conductor
3
+ * #415).
4
+ *
5
+ * `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
6
+ * proves the reply by scanning the orchestrator session transcript. That reply
7
+ * also lands in the orchestrator as an ordinary user turn, where the model once
8
+ * ad-libbed pairing-safety prose because it had no trusted way to recognise it.
9
+ *
10
+ * This module is the bridge between the two roles without an import cycle:
11
+ * `fleet.ts` imports `daemon.ts`, `daemon.ts` imports `orchestrator-tick.ts`,
12
+ * so `orchestrator-tick.ts` can never import `fleet.ts`. Both already import
13
+ * `config.ts`, so the pending-challenge record lives next to the other state
14
+ * under `stateDir()` and both sides reach it through *this* leaf module.
15
+ *
16
+ * Only the sha-256 of the code is ever persisted — never the code, whose
17
+ * plaintext appearance in the protected session transcript is already the
18
+ * backend proof and must not leak into a durable report, an issue comment, or a
19
+ * diagnostic line the way a new artifact could. The orchestrator classifies a
20
+ * reply by hashing its tokens against this record, so nothing on the east side
21
+ * of the boundary trusts a `FLEET-` prefix.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { stateDir } from "./config.ts";
28
+
29
+ const ARM_CHALLENGE_FILE = "arm-challenge.json";
30
+
31
+ interface PendingChallenge {
32
+ /** sha-256 hex of the challenge code — never the code itself. */
33
+ hash: string;
34
+ /** Unix ms after which a matching reply is no longer an active proof. */
35
+ expiresAt: number;
36
+ }
37
+
38
+ /** Keyed by project name; `""` is the pre-multi-project (un-named) spelling. */
39
+ type PendingChallenges = Record<string, PendingChallenge>;
40
+
41
+ function challengesPath(): string {
42
+ return join(stateDir(), ARM_CHALLENGE_FILE);
43
+ }
44
+
45
+ function projectKey(project?: string): string {
46
+ return project ?? "";
47
+ }
48
+
49
+ function readChallenges(): PendingChallenges {
50
+ try {
51
+ const parsed: unknown = JSON.parse(readFileSync(challengesPath(), "utf8"));
52
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
53
+ return parsed as PendingChallenges;
54
+ }
55
+ } catch {
56
+ /* absent or unreadable — no active challenge */
57
+ }
58
+ return {};
59
+ }
60
+
61
+ function writeChallenges(map: PendingChallenges): void {
62
+ const path = challengesPath();
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ // 0600 like the other conductor state an operator never shares; the record is
65
+ // only a hash, but there is no reason to be less careful with it.
66
+ writeFileSync(path, `${JSON.stringify(map)}\n`, { mode: 0o600 });
67
+ }
68
+
69
+ /** sha-256 hex of the challenge code — the persisted token, never the code. */
70
+ function challengeHash(code: string): string {
71
+ return createHash("sha256").update(code).digest("hex");
72
+ }
73
+
74
+ /** Register a new active arming challenge, replacing any prior one for the project. */
75
+ export function recordArmChallenge(project: string | undefined, code: string, expiresAt: number): void {
76
+ const map = readChallenges();
77
+ map[projectKey(project)] = { hash: challengeHash(code), expiresAt };
78
+ writeChallenges(map);
79
+ }
80
+
81
+ /** Drop the active challenge for a project once arming completed or timed out. */
82
+ export function clearArmChallenge(project: string | undefined): void {
83
+ const map = readChallenges();
84
+ const key = projectKey(project);
85
+ if (!(key in map)) return;
86
+ delete map[key];
87
+ writeChallenges(map);
88
+ }
89
+
90
+ /**
91
+ * Authenticated classification for the orchestrator's turn adapter: a reply is
92
+ * an active arming proof iff the project has a non-expired pending challenge
93
+ * and one of the reply's whitespace-separated tokens hashes to it. The code is
94
+ * matched by hash — matching `transcriptHasUserCode`'s leniency (the code
95
+ * appears as a token) without ever trusting a `FLEET-` prefix or exposing the
96
+ * code to the model's other reads. An unsolicited lookalike that matches no
97
+ * active challenge, and a reply in the wrong project, both stay inert.
98
+ *
99
+ * Clears nothing: the host (`armTicks`) owns clearance once its transcript
100
+ * proof lands, so the two sides cannot race for the record.
101
+ */
102
+ export function isActiveArmProof(project: string | undefined, replyText: string, now: number): boolean {
103
+ const pending = readChallenges()[projectKey(project)];
104
+ if (pending === undefined || now >= pending.expiresAt) return false;
105
+ const targetHash = pending.hash;
106
+ // Challenge codes contain no whitespace, so tokenising on whitespace never
107
+ // splits one; empty replies simply yield no token.
108
+ for (const token of replyText.trim().split(/\s+/)) {
109
+ if (token.length > 0 && challengeHash(token) === targetHash) return true;
110
+ }
111
+ return false;
112
+ }
package/src/ask.ts ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * The bounded ask surface for an orchestrator tick (#438).
3
+ *
4
+ * On 2026-08-16 the orchestrator called `telegram_ask` for a genuine tier-2
5
+ * contract decision (PR #432 / issue #368) and the operator was asleep. The
6
+ * tool does not time out: it blocked the turn for just over six hours, and a
7
+ * tick prompt timestamped 01:14Z did not execute until after the answer at
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:
15
+ *
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.
22
+ *
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.
28
+ */
29
+
30
+ import type { DecisionRecord, InterruptCategory, Store } from "./types.ts";
31
+ import { INTERRUPT_CATEGORIES } from "./types.ts";
32
+
33
+ /** The bounded ask tool this package registers on an orchestrator session. */
34
+ export const ASK_TOOL = "conductor_ask";
35
+
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
+ /**
50
+ * The suffix a decision row carries when nobody human chose the answer. Fixed
51
+ * wording on purpose — the same reason `types.ts` gives for closed vocabularies
52
+ * everywhere else: a marker a tick or an operator has to match by reading it
53
+ * must be one no two spellings disagree on.
54
+ */
55
+ export const AUTO_APPLIED_SUFFIX = "(auto-applied on ask timeout)";
56
+
57
+ /** How often the bounded wait re-reads the decision row while waiting. */
58
+ export const ASK_POLL_MS = 1_000;
59
+
60
+ /** One option the operator may pick, as rendered in the delivered question. */
61
+ export interface AskOption {
62
+ label: string;
63
+ description?: string;
64
+ }
65
+
66
+ /** The validated argument surface of {@link ASK_TOOL}. */
67
+ export interface AskRequest {
68
+ /** The question verbatim, as it is archived on the decision row. */
69
+ question: string;
70
+ /** What the ask does when nobody answers within the ceiling. */
71
+ onTimeout: AskTimeoutOutcome;
72
+ /** Optional ceiling in whole seconds; defaults to the configured ceiling. */
73
+ timeoutSeconds?: number;
74
+ /** What is waiting on the answer, for the decision row and the digest. */
75
+ blocks?: string;
76
+ /** The option applied by `auto-proceed`; required exactly for that outcome. */
77
+ recommended?: string;
78
+ /** The choices shown to the operator, with {@link AskRequest.recommended} named. */
79
+ options?: AskOption[];
80
+ /** Escalation category for the delivered question; defaults to `decision-needed`. */
81
+ category?: InterruptCategory;
82
+ }
83
+
84
+ type AskParse =
85
+ | { ok: true; request: AskRequest }
86
+ | { ok: false; problem: string };
87
+
88
+ function isInterruptCategory(value: unknown): value is InterruptCategory {
89
+ return typeof value === "string" && (INTERRUPT_CATEGORIES as readonly string[]).includes(value);
90
+ }
91
+
92
+ /**
93
+ * Validate one raw tool call. Strict like the verb arguments: an unknown shape
94
+ * is refused with the reason, never coerced into a default that could hide what
95
+ * the asker actually sent. The one tolerant field is the omitted `timeoutSeconds`
96
+ * — the issue's whole point is that the ceiling holds even when the model does
97
+ * not name one.
98
+ */
99
+ export function parseAskRequest(raw: unknown): AskParse {
100
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
101
+ return { ok: false, problem: "conductor_ask arguments must be an object" };
102
+ }
103
+ const input = raw as Record<string, unknown>;
104
+
105
+ const question = input["question"];
106
+ if (typeof question !== "string" || question.trim().length === 0) {
107
+ return { ok: false, problem: `conductor_ask needs "question": the question you put to the operator` };
108
+ }
109
+
110
+ const onTimeoutRaw = input["on-timeout"];
111
+ if (
112
+ typeof onTimeoutRaw !== "string" ||
113
+ !(ASK_TIMEOUT_OUTCOMES as readonly string[]).includes(onTimeoutRaw)
114
+ ) {
115
+ return {
116
+ ok: false,
117
+ problem: `conductor_ask needs "on-timeout": one of ${ASK_TIMEOUT_OUTCOMES.join(" or ")} — what happens when nobody answers within the ceiling`,
118
+ };
119
+ }
120
+ const onTimeout = onTimeoutRaw as AskTimeoutOutcome;
121
+
122
+ const recommendedRaw = input["recommended"];
123
+ const recommended =
124
+ typeof recommendedRaw === "string" && recommendedRaw.trim().length > 0
125
+ ? recommendedRaw.trim()
126
+ : undefined;
127
+ if (onTimeout === "auto-proceed" && recommended === undefined) {
128
+ return {
129
+ ok: false,
130
+ problem:
131
+ 'conductor_ask with "on-timeout": "auto-proceed" needs "recommended": the option to apply when nobody answers, ' +
132
+ "because the decision row must record what was auto-applied",
133
+ };
134
+ }
135
+
136
+ const timeoutRaw = input["timeoutSeconds"];
137
+ let timeoutSeconds: number | undefined;
138
+ if (timeoutRaw !== undefined) {
139
+ if (typeof timeoutRaw !== "number" || !Number.isInteger(timeoutRaw)) {
140
+ return { ok: false, problem: "conductor_ask timeoutSeconds must be a whole number of seconds when present" };
141
+ }
142
+ if (timeoutRaw < MIN_ASK_TIMEOUT_SECONDS || timeoutRaw > MAX_ASK_TIMEOUT_SECONDS) {
143
+ return {
144
+ ok: false,
145
+ problem: `conductor_ask timeoutSeconds must be between ${MIN_ASK_TIMEOUT_SECONDS} and ${MAX_ASK_TIMEOUT_SECONDS}`,
146
+ };
147
+ }
148
+ timeoutSeconds = timeoutRaw;
149
+ }
150
+
151
+ const blocksRaw = input["blocks"];
152
+ const blocks = typeof blocksRaw === "string" && blocksRaw.trim().length > 0 ? blocksRaw.trim() : undefined;
153
+
154
+ const optionsRaw = input["options"];
155
+ let options: AskOption[] | undefined;
156
+ if (optionsRaw !== undefined) {
157
+ if (!Array.isArray(optionsRaw)) {
158
+ return { ok: false, problem: "conductor_ask options must be an array of { label, description? }" };
159
+ }
160
+ const parsedOptions: AskOption[] = [];
161
+ for (const entry of optionsRaw) {
162
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
163
+ return { ok: false, problem: "conductor_ask options entries must be objects with a label" };
164
+ }
165
+ const option = entry as Record<string, unknown>;
166
+ const label = option["label"];
167
+ if (typeof label !== "string" || label.trim().length === 0) {
168
+ return { ok: false, problem: "conductor_ask options entries need a non-empty label" };
169
+ }
170
+ const descriptionRaw = option["description"];
171
+ parsedOptions.push({
172
+ label: label.trim(),
173
+ ...(typeof descriptionRaw === "string" && descriptionRaw.trim().length > 0
174
+ ? { description: descriptionRaw.trim() }
175
+ : {}),
176
+ });
177
+ }
178
+ options = parsedOptions;
179
+ }
180
+
181
+ const categoryRaw = input["category"];
182
+ if (categoryRaw !== undefined && !isInterruptCategory(categoryRaw)) {
183
+ return {
184
+ ok: false,
185
+ problem: `conductor_ask category must be one of ${INTERRUPT_CATEGORIES.join(", ")} when present`,
186
+ };
187
+ }
188
+
189
+ return {
190
+ ok: true,
191
+ request: {
192
+ question: question.trim(),
193
+ onTimeout,
194
+ ...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
195
+ ...(blocks === undefined ? {} : { blocks }),
196
+ ...(recommended === undefined ? {} : { recommended }),
197
+ ...(options === undefined ? {} : { options }),
198
+ ...(categoryRaw === undefined ? {} : { category: categoryRaw as InterruptCategory }),
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The JSON Schema the harness advertises for {@link ASK_TOOL}, shaped like the
205
+ * verb parameter schemas: plain JSON Schema, `additionalProperties: false`,
206
+ * required lists spelled out.
207
+ */
208
+ export function askParameterSchema(): Record<string, unknown> {
209
+ return {
210
+ type: "object",
211
+ properties: {
212
+ question: {
213
+ type: "string",
214
+ description:
215
+ "The question, written for a phone: one plain sentence, your recommendation, and the options with their consequences.",
216
+ },
217
+ "on-timeout": {
218
+ type: "string",
219
+ enum: [...ASK_TIMEOUT_OUTCOMES],
220
+ description:
221
+ "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.",
222
+ },
223
+ timeoutSeconds: {
224
+ type: "number",
225
+ 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}.`,
226
+ },
227
+ recommended: {
228
+ type: "string",
229
+ description:
230
+ "The option applied on auto-proceed. Required when on-timeout is auto-proceed, because the row must record what was auto-applied.",
231
+ },
232
+ blocks: {
233
+ type: "string",
234
+ description: "What is waiting on the answer (an issue, a PR, a release) for the decision row.",
235
+ },
236
+ options: {
237
+ type: "array",
238
+ items: {
239
+ type: "object",
240
+ properties: {
241
+ label: { type: "string", description: "Short button label." },
242
+ description: { type: "string", description: "Optional tradeoff detail." },
243
+ },
244
+ required: ["label"],
245
+ additionalProperties: false,
246
+ },
247
+ description: "The choices shown to the operator; name the recommended one in `recommended`.",
248
+ },
249
+ category: {
250
+ type: "string",
251
+ enum: [...INTERRUPT_CATEGORIES],
252
+ description: "Escalation category for the delivered question. Optional; defaults to decision-needed.",
253
+ },
254
+ },
255
+ required: ["question", "on-timeout"],
256
+ additionalProperties: false,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * The ceiling for one ask, in whole seconds. The model may name one, but the
262
+ * enforcement never depends on it: an ask without a `timeoutSeconds` gets the
263
+ * configured ceiling, and both are capped at the turn budget so the ask can
264
+ * never outlive the turn it runs in.
265
+ */
266
+ export function resolveAskCeilingSeconds(
267
+ requestedSeconds: number | undefined,
268
+ configuredSeconds: number | undefined,
269
+ turnBudgetSeconds: number,
270
+ ): number {
271
+ const base = requestedSeconds ?? configuredSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS;
272
+ return Math.min(base, Math.max(MIN_ASK_TIMEOUT_SECONDS, turnBudgetSeconds));
273
+ }
274
+
275
+ /** The row resolution for an auto-applied outcome — exactly the acceptance text. */
276
+ export function autoApplyResolution(recommended: string): string {
277
+ return `${recommended} ${AUTO_APPLIED_SUFFIX}`;
278
+ }
279
+
280
+ /** The question exactly as it is delivered, durable prefix and all. */
281
+ export function askMessageFor(request: AskRequest): string {
282
+ const lines = [`QUESTION: ${request.question}`];
283
+ if (request.options !== undefined && request.options.length > 0) {
284
+ lines.push(
285
+ `Options: ${request.options
286
+ .map((option) =>
287
+ option.description === undefined
288
+ ? option.label
289
+ : `${option.label} — ${option.description}`,
290
+ )
291
+ .join("; ")}`,
292
+ );
293
+ }
294
+ if (request.recommended !== undefined) lines.push(`Recommended: ${request.recommended}`);
295
+ return lines.join("\n");
296
+ }
297
+
298
+ /** One read of the decision row; `undefined` means the id is unknown. */
299
+ export type DecisionReader = (id: string) => DecisionRecord | undefined;
300
+
301
+ export interface BoundedAskDeps {
302
+ decisionId: string;
303
+ ceilingMs: number;
304
+ read: DecisionReader;
305
+ /** Advance one poll; injected so tests stay deterministic. */
306
+ wait: (ms: number) => Promise<void>;
307
+ now: () => number;
308
+ }
309
+
310
+ export type BoundedAskOutcome =
311
+ | { kind: "answered"; answer: string }
312
+ | { kind: "withdrawn" }
313
+ | { kind: "timed-out" };
314
+
315
+ /**
316
+ * The wait itself: read the row, leave when someone closed it, time out at the
317
+ * deadline. Pure by construction — every clock and sleep is injected — so the
318
+ * whole "does the ask come back bounded" contract is a deterministic test, not
319
+ * a timer gamble.
320
+ */
321
+ export async function runBoundedAsk(deps: BoundedAskDeps): Promise<BoundedAskOutcome> {
322
+ const deadline = deps.now() + deps.ceilingMs;
323
+ for (;;) {
324
+ const row = deps.read(deps.decisionId);
325
+ if (row !== undefined && row.state !== "open") {
326
+ return row.state === "answered"
327
+ ? { kind: "answered", answer: row.resolution ?? "" }
328
+ : { kind: "withdrawn" };
329
+ }
330
+ if (deps.now() >= deadline) return { kind: "timed-out" };
331
+ await deps.wait(ASK_POLL_MS);
332
+ }
333
+ }
334
+
335
+ /** What delivering the question to the operator returned, for the result text. */
336
+ export interface AskDeliveryResult {
337
+ kind: "sent" | "held";
338
+ category: InterruptCategory;
339
+ noticeId?: string;
340
+ }
341
+
342
+ /** What one {@link ASK_TOOL} call produced, and the exact text the model reads. */
343
+ export interface AskResult {
344
+ outcome: BoundedAskOutcome;
345
+ decisionId: string;
346
+ text: string;
347
+ }
348
+
349
+ export interface AskDeps {
350
+ store: Store;
351
+ project: string;
352
+ /** The tick config's `askTimeoutSeconds`, already validated. */
353
+ configuredCeilingSeconds?: number;
354
+ /** The tick config's `budgetSeconds`, already validated. */
355
+ turnBudgetSeconds?: number;
356
+ /** Delivers the question through the sanctioned durable path. */
357
+ deliver(questionText: string, category: InterruptCategory): Promise<AskDeliveryResult>;
358
+ wait?: (ms: number) => Promise<void>;
359
+ now?: () => number;
360
+ }
361
+
362
+ /** Real-time default for the injected wait; tests hand their own. */
363
+ const sleep = (ms: number): Promise<void> => {
364
+ const { promise, resolve } = Promise.withResolvers<void>();
365
+ setTimeout(resolve, ms);
366
+ return promise;
367
+ };
368
+
369
+ /**
370
+ * One bounded ask, end to end: durable row first, then delivery, then the wait,
371
+ * then the declared timeout outcome. The row exists before the delivery
372
+ * attempt, so a crash at any point leaves a recorded question — never one that
373
+ * lived only in the model's context.
374
+ */
375
+ export async function performAsk(request: AskRequest, deps: AskDeps): Promise<AskResult> {
376
+ const now = deps.now ?? Date.now;
377
+ const wait = deps.wait ?? sleep;
378
+
379
+ const row = deps.store.createDecision({
380
+ project: deps.project,
381
+ question: request.question,
382
+ ...(request.blocks === undefined ? {} : { blocks: request.blocks }),
383
+ at: now(),
384
+ });
385
+
386
+ const delivery = await deps.deliver(askMessageFor(request), request.category ?? "decision-needed");
387
+
388
+ const ceilingSeconds = resolveAskCeilingSeconds(
389
+ request.timeoutSeconds,
390
+ deps.configuredCeilingSeconds,
391
+ deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
392
+ );
393
+ const outcome = await runBoundedAsk({
394
+ decisionId: row.id,
395
+ ceilingMs: ceilingSeconds * 1_000,
396
+ read: (id) => deps.store.decision(id),
397
+ wait,
398
+ now,
399
+ });
400
+
401
+ let text: string;
402
+ if (outcome.kind === "answered") {
403
+ text =
404
+ `conductor_ask ${row.id}: the operator answered — ${outcome.answer}. ` +
405
+ `The decision row is resolved with that answer. Proceed to apply it.`;
406
+ } else if (outcome.kind === "withdrawn") {
407
+ text =
408
+ `conductor_ask ${row.id}: the decision was withdrawn by whoever holds the row before the ` +
409
+ `ceiling elapsed. No answer was given and nothing was auto-applied. Treat it as "the question is ` +
410
+ `closed", not as an answer.`;
411
+ } else if (request.onTimeout === "auto-proceed") {
412
+ const resolution = autoApplyResolution(request.recommended!);
413
+ deps.store.resolveDecision(row.id, "answered", resolution, now());
414
+ text =
415
+ `conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — auto-proceeding as declared. ` +
416
+ `The recommended option "${request.recommended}" is applied, and the decision row was resolved ` +
417
+ `with the answer "${resolution}" so the record shows nobody human chose it. Proceed to apply ` +
418
+ `that recommendation to the blocked work now.`;
419
+ } else {
420
+ text =
421
+ `conductor_ask ${row.id}: no answer within ${ceilingSeconds}s — parking as declared. ` +
422
+ `The decision row stays open and pending, re-surfaced in every tick prompt until answered or the ` +
423
+ `seven-day expiry. Park the blocked work now: take it out of the claimable queue and record its ` +
424
+ `state (e.g. unlabel it or move it to a parked state), then note it in your report.`;
425
+ }
426
+
427
+ const delivered =
428
+ delivery.kind === "sent"
429
+ ? `The question went to the operator as a ${delivery.category} message.`
430
+ : `The question is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
431
+ "daemon releases it with the next digest or working-hours catch-up.";
432
+
433
+ return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
434
+ }