/** * The durable, parking ask surface for an orchestrator tick (#438). * * On 2026-08-16 the orchestrator called `telegram_ask` for a genuine tier-2 * contract decision (PR #432 / issue #368) and the operator was asleep. The * tool does not time out: it blocked the turn for just over six hours, and a * tick prompt timestamped 01:14Z did not execute until after the answer at * 07:04Z. Every duty behind the ask — draining, merging, grooming — stopped * with it. The first fix bounded the wait to minutes. This surface removes the * wait entirely, because the session split made waiting pointless as well as * dangerous: * * - the tick brain is **headless** — it never receives operator turns, so an * answer could not land in the asking session even if the operator were * awake, and a duty cycle must never block on a human; * - operator conversation lives in a separate 24/7 **console** session, which * answers by resolving the decision row (`omp-conductor decision resolve * --answer "…"`) — the same store write the dashboard and the CLI use, * and 409-safe by `resolveDecision`'s open-only guard. * * So the ask does exactly three things: it records the question durably (a * decision row, verbatim), delivers it once through the sanctioned path, and * returns immediately with the row id, the resolve command, and the * instruction to park the blocked work in the same turn. It never resolves a * row itself: there is no timeout to auto-apply, so nothing here can ever be * read back as a human choice that never happened. * * A parked row is bounded without any new machinery: `expireDueDecisions` * (daemon.ts) sweeps unanswered decisions after seven days, exactly as it does * for every other open question. */ import type { InterruptCategory, Store } from "./types.ts"; import { INTERRUPT_CATEGORIES } from "./types.ts"; /** The durable ask tool this package registers on an orchestrator session. */ export const ASK_TOOL = "conductor_ask"; /** * The shape ceilings for a decision row's question (#593). A question is the * bare ask an operator reads on a phone — one sentence, the recommendation, the * options — not an essay. On 2026-08-17 three rows opened at 41/22/26 lines and * 2,043/1,168/1,392 characters each, embedding their measurements, timeline and * rationale inline; every one was accepted and every one rendered in full. The * walls are set so a genuine four-option ask fits and an essay does not: the * measured detail belongs in the issue or a separate backdrop, never inline. */ export const MAX_QUESTION_LINES = 10; export const MAX_QUESTION_CHARACTERS = 700; /** * The result of {@link validateQuestionShape}: fine, or refused with a problem * that names the measured counts and where the overflow belongs. */ export type QuestionShape = | { ok: true } | { ok: false; problem: string }; /** One option the operator may pick, as rendered in the delivered question. */ export interface AskOption { label: string; description?: string; } /** The validated argument surface of {@link ASK_TOOL}. */ export interface AskRequest { /** The question verbatim, as it is archived on the decision row. */ question: string; /** What is waiting on the answer, for the decision row and the digest. */ blocks?: string; /** * The option you recommend, rendered in the delivered question. When * {@link AskRequest.options} are supplied it must name one of their labels — * the label as delivered, never an index. */ recommended?: string; /** The choices shown to the operator, with {@link AskRequest.recommended} named. */ options?: AskOption[]; /** Escalation category for the delivered question; defaults to `decision-needed`. */ category?: InterruptCategory; } type AskParse = | { ok: true; request: AskRequest } | { ok: false; problem: string }; function isInterruptCategory(value: unknown): value is InterruptCategory { return typeof value === "string" && (INTERRUPT_CATEGORIES as readonly string[]).includes(value); } /** * The command that answers one row. One spelling for every result text and * prompt line, for the reason `types.ts` gives for closed vocabularies * everywhere else: a command an operator or a console session copies verbatim * must not exist in two shapes, one of which the CLI refuses. */ export function decisionResolveCommand(id: string): string { return `omp-conductor decision resolve ${id} --answer ""`; } /** * Refuse a question that outgrew the phone-sized decision row. Counts the * trimmed question — the exact text archived on the row — against * {@link MAX_QUESTION_LINES} / {@link MAX_QUESTION_CHARACTERS}, and refuses * rather than truncating: a truncated question reads as complete and has lost * its options, which is strictly worse than a rejection the caller must fix. * * The refusal is terminal for the call (#741): nothing is recorded or * delivered, and the caller must not resubmit the trimmed question in the same * turn — the rationale belongs in the issue or the event ledger, and a * decision that still needs a human answer is raised as a fresh ask on a later * tick. A re-issued ask therefore always starts from the accepted arguments of * a new call, never from a rewrite of a refused one. */ export function validateQuestionShape(question: string): QuestionShape { const lines = question.split("\n").length; const chars = question.length; if (lines <= MAX_QUESTION_LINES && chars <= MAX_QUESTION_CHARACTERS) { return { ok: true }; } return { ok: false, problem: `the question is ${lines} lines / ${chars} characters — over the ${MAX_QUESTION_LINES}-line / ` + `${MAX_QUESTION_CHARACTERS}-character ceiling. Trim it to the bare question the operator must answer; ` + `the measurements and rationale belong in the issue, or as a separate rationale recorded with ` + "`omp-conductor event record`, never inline in the question", }; } /** * Validate one raw tool call. Strict like the verb arguments: an unknown shape * is refused with the reason, never coerced into a default that could hide what * the asker actually sent. */ export function parseAskRequest(raw: unknown): AskParse { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, problem: "conductor_ask arguments must be an object" }; } const input = raw as Record; const question = input["question"]; if (typeof question !== "string" || question.trim().length === 0) { return { ok: false, problem: `conductor_ask needs "question": the question you put to the operator` }; } const shape = validateQuestionShape(question.trim()); if (!shape.ok) { return { ok: false, problem: `conductor_ask ${shape.problem} Do not re-issue the trimmed question in this turn: if the ` + "decision still needs a human answer, say so in the report and raise it as a fresh ask in a " + "later tick.", }; } const recommendedRaw = input["recommended"]; const recommended = typeof recommendedRaw === "string" && recommendedRaw.trim().length > 0 ? recommendedRaw.trim() : undefined; const blocksRaw = input["blocks"]; const blocks = typeof blocksRaw === "string" && blocksRaw.trim().length > 0 ? blocksRaw.trim() : undefined; const optionsRaw = input["options"]; let options: AskOption[] | undefined; if (optionsRaw !== undefined) { if (!Array.isArray(optionsRaw)) { return { ok: false, problem: "conductor_ask options must be an array of { label, description? }" }; } const parsedOptions: AskOption[] = []; for (const entry of optionsRaw) { if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { return { ok: false, problem: "conductor_ask options entries must be objects with a label" }; } const option = entry as Record; const label = option["label"]; if (typeof label !== "string" || label.trim().length === 0) { return { ok: false, problem: "conductor_ask options entries need a non-empty label" }; } const descriptionRaw = option["description"]; parsedOptions.push({ label: label.trim(), ...(typeof descriptionRaw === "string" && descriptionRaw.trim().length > 0 ? { description: descriptionRaw.trim() } : {}), }); } options = parsedOptions; } // A recommendation must name a supplied option. `recommended` is the option // label exactly as the operator reads it — never an index (that is // telegram_ask's convention, not this tool's) and never free text when a // menu exists. Refused at parse time so the incoherent ask leaves no trace: // the recommendation is delivered as one of the choices, and a value naming // none of them is a question nobody can answer by picking (#740). if (options !== undefined && recommended !== undefined) { const labels = options.map((option) => option.label); if (!labels.includes(recommended)) { return { ok: false, problem: `conductor_ask recommended "${recommended}" names no option — it must be one of: ${labels.join(", ")}. ` + "It is the option label exactly as delivered to the operator, not an index", }; } } const categoryRaw = input["category"]; if (categoryRaw !== undefined && !isInterruptCategory(categoryRaw)) { return { ok: false, problem: `conductor_ask category must be one of ${INTERRUPT_CATEGORIES.join(", ")} when present`, }; } return { ok: true, request: { question: question.trim(), ...(blocks === undefined ? {} : { blocks }), ...(recommended === undefined ? {} : { recommended }), ...(options === undefined ? {} : { options }), ...(categoryRaw === undefined ? {} : { category: categoryRaw as InterruptCategory }), }, }; } /** * The JSON Schema the harness advertises for {@link ASK_TOOL}, shaped like the * verb parameter schemas: plain JSON Schema, `additionalProperties: false`, * required lists spelled out. * * No timeout property of any kind, and deliberately no conditional * requirement: the ask has exactly one behaviour — file, deliver, park — so * the model has nothing to declare about what happens when nobody answers. */ export function askParameterSchema(): Record { return { type: "object", properties: { question: { type: "string", description: "The question, written for a phone: one plain sentence, your recommendation, and the options with their consequences.", }, recommended: { type: "string", description: "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.", }, blocks: { type: "string", description: "What is waiting on the answer (an issue, a PR, a release) for the decision row.", }, options: { type: "array", items: { type: "object", properties: { label: { type: "string", description: "Short button label." }, description: { type: "string", description: "Optional tradeoff detail." }, }, required: ["label"], additionalProperties: false, }, description: "The choices shown to the operator; name the recommended one in `recommended`.", }, category: { type: "string", enum: [...INTERRUPT_CATEGORIES], description: "Escalation category for the delivered question. Optional; defaults to decision-needed.", }, }, required: ["question"], additionalProperties: false, }; } /** * The questionnaire tool's arguments (#947): the spec it belongs to, plus items * that are each exactly one {@link askParameterSchema}. Composed rather than * restated, so an item can never accept something a single ask refuses. */ export function questionnaireParameterSchema(): Record { return { type: "object", properties: { "spec-issue": { type: "number", description: "The issue these questions spec out. The answers become that issue's provenance, so a later reader sees why a slice is shaped the way it is instead of re-litigating it.", }, items: { type: "array", minItems: 1, maxItems: MAX_QUESTIONNAIRE_ITEMS, description: "The judgement calls only — anything a repo read can answer is your own work, not an operator question. Delivered as one message, answerable in any order, each item resolving independently.", items: askParameterSchema(), }, }, required: ["spec-issue", "items"], additionalProperties: false, }; } /** The question exactly as it is delivered, durable prefix and all. */ export function askMessageFor(request: AskRequest): string { const lines = [`QUESTION: ${request.question}`]; if (request.options !== undefined && request.options.length > 0) { lines.push( `Options: ${request.options .map((option) => option.description === undefined ? option.label : `${option.label} — ${option.description}`, ) .join("; ")}`, ); } if (request.recommended !== undefined) lines.push(`Recommended: ${request.recommended}`); return lines.join("\n"); } /** The tool name a spec-out questionnaire arrives on (#947). */ export const QUESTIONNAIRE_TOOL = "conductor_questionnaire"; /** How many items one delivery may carry. A questionnaire longer than this is * not a questionnaire, it is an interview the operator will abandon halfway. */ export const MAX_QUESTIONNAIRE_ITEMS = 6; /** * One spec-out questionnaire (#947): several questions about ONE issue, * delivered together and answerable in any order. * * It is deliberately N {@link AskRequest}s plus a spec binding rather than a * new question shape. Every per-item semantic a spec-out needs — options, a * recommendation, the shape ceiling, the durable row — already exists and is * already tested here; what did not exist is asking six of them once. * Re-deriving those semantics for a "questionnaire item" would be the same * contract in two places, and the second copy is the one that drifts. */ export interface QuestionnaireRequest { /** The issue these questions spec out; their answers are its provenance. */ specIssue: number; /** The items, in delivery order. Each is an ordinary durable ask. */ items: AskRequest[]; } type QuestionnaireParse = | { ok: true; request: QuestionnaireRequest } | { ok: false; problem: string }; /** * Validate one questionnaire call. Strict exactly like {@link parseAskRequest}, * and every item is validated through it — including the shape ceiling, which * refuses the **whole group** rather than truncating or dropping one item. A * partially accepted questionnaire would deliver a question set that does not * match the rows behind it, which is the one failure this whole feature exists * to prevent. */ export function parseQuestionnaireRequest(raw: unknown): QuestionnaireParse { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, problem: `${QUESTIONNAIRE_TOOL} arguments must be an object` }; } const record = raw as Record; const known = new Set(["spec-issue", "items"]); for (const key of Object.keys(record)) { if (!known.has(key)) { return { ok: false, problem: `unknown argument "${key}" — expected spec-issue and items` }; } } const specIssue = record["spec-issue"]; if (typeof specIssue !== "number" || !Number.isInteger(specIssue) || specIssue <= 0) { return { ok: false, problem: 'spec-issue must be the issue number these questions spec out — a questionnaire whose answers belong to nothing is chat history, not provenance', }; } const items = record["items"]; if (!Array.isArray(items) || items.length === 0) { return { ok: false, problem: "items must be a non-empty array of questions" }; } if (items.length > MAX_QUESTIONNAIRE_ITEMS) { return { ok: false, problem: `${items.length} items — over the ${MAX_QUESTIONNAIRE_ITEMS}-item ceiling. Ask the judgement calls only; anything a repo read answers is not an operator question`, }; } const parsed: AskRequest[] = []; for (const [index, item] of items.entries()) { const one = parseAskRequest(item); if (!one.ok) return { ok: false, problem: `item ${index + 1}: ${one.problem}` }; const shape = validateQuestionShape(one.request.question); if (!shape.ok) return { ok: false, problem: `item ${index + 1}: ${shape.problem}` }; parsed.push(one.request); } return { ok: true, request: { specIssue, items: parsed } }; } /** * The questionnaire exactly as it is delivered: **one** message, its items * numbered so an operator can answer them in any order by naming the number. * * One delivery is the point. N deliveries would be N interrupts for one * decision, which is what the serial workaround already did and why nothing was * ever answered as a set. */ export function questionnaireMessageFor(request: QuestionnaireRequest): string { const lines = [`QUESTION: ${request.items.length} calls to spec out #${request.specIssue} — answer by number, in any order.`]; for (const [index, item] of request.items.entries()) { const parts = [`${index + 1}. ${item.question}`]; if (item.options !== undefined && item.options.length > 0) { parts.push( ` options: ${item.options .map((option) => (option.description === undefined ? option.label : `${option.label} — ${option.description}`)) .join("; ")}`, ); } if (item.recommended !== undefined) parts.push(` rec: ${item.recommended}`); lines.push(parts.join("\n")); } return lines.join("\n"); } /** What delivering the question to the operator returned, for the result text. */ export interface AskDeliveryResult { kind: "sent" | "held"; category: InterruptCategory; noticeId?: string; } /** What one {@link ASK_TOOL} call produced, and the exact text the model reads. */ export interface AskResult { decisionId: string; text: string; } export interface AskDeps { store: Store; project: string; /** Delivers the question through the sanctioned durable path. */ deliver(questionText: string, category: InterruptCategory): Promise; now?: () => number; } /** * One sentence naming what became of the delivery attempt. A throw is its own * case rather than an escape: the row is already filed, so the honest report is * "recorded, not sent" — and swallowing the throw is what lets the caller learn * the row id at all, which is exactly what it needs in order to park the work. */ function deliveryLine(delivery: AskDeliveryResult | undefined, failure: string | undefined): string { if (delivery === undefined) { return ( `Delivery FAILED (${failure ?? "unknown error"}): nothing was sent, and the question is held only as ` + `the row itself. Say so in your report, and deliver it by hand with \`omp-conductor message\` if it ` + `cannot wait for the next digest.` ); } return delivery.kind === "sent" ? `It went to the operator as a ${delivery.category} message.` : `It is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the daemon releases it ` + `with the next digest or working-hours catch-up.`; } /** Deliver once, never throwing: the row outlives any transport failure. */ async function deliverOnce( deps: AskDeps, text: string, category: InterruptCategory, ): Promise<{ delivery?: AskDeliveryResult; failure?: string }> { try { return { delivery: await deps.deliver(text, category) }; } catch (err) { return { failure: err instanceof Error ? err.message : String(err) }; } } /** * One ask, end to end: durable row, one delivery, park. * * The row exists before the delivery attempt, so a crash at any point leaves a * recorded question — never one that lived only in the model's context. Then * the call returns. Nothing polls, nothing waits, and this function never * resolves the row: the console session does that when the operator answers. */ export async function performAsk(request: AskRequest, deps: AskDeps): Promise { const now = deps.now ?? Date.now; const row = deps.store.createDecision({ project: deps.project, question: request.question, ...(request.blocks === undefined ? {} : { blocks: request.blocks }), at: now(), }); const { delivery, failure } = await deliverOnce( deps, askMessageFor(request), request.category ?? "decision-needed", ); return { decisionId: row.id, text: `conductor_ask ${row.id}: the question is filed as open decision row ${row.id}. ` + `${deliveryLine(delivery, failure)}\n` + `Nothing waits for the answer here: this tick is headless, and the operator answers in the console ` + `session, which resolves the row with \`${decisionResolveCommand(row.id)}\`. Until then the row stays ` + `open and pending — re-surfaced in every tick prompt — until it is answered, withdrawn, or swept by ` + `the seven-day decision expiry.\n` + `Park the blocked work NOW, in this turn: take it out of the claimable queue and record its state ` + `(e.g. unlabel it or move it to a parked state), then note the parked work and row ${row.id} in your ` + `report. Do not wait, do not re-ask, and do not act as though the recommendation were approved.`, }; } /** What one {@link QUESTIONNAIRE_TOOL} call produced, and its text. */ export interface QuestionnaireResult { /** The minted group id — the handle for `omp-conductor decision` on the set. */ groupId: string; items: { id: string; question: string }[]; text: string; } /** * Ask one spec-out questionnaire (#947). * * The shape mirrors {@link performAsk} deliberately — rows before delivery, * then park — with the one difference that is the whole feature: the items go * out as a **single** numbered message, so a questionnaire costs one interrupt * rather than N. Each item is still its own row, resolved independently and in * any order by the console. */ export async function performQuestionnaire( request: QuestionnaireRequest, deps: AskDeps, ): Promise { const now = deps.now ?? Date.now; // Every row durable, in one transaction, BEFORE the delivery: an operator must // never be shown a question that no row is waiting on. const rows = deps.store.createDecisionGroup( request.items.map((item) => ({ project: deps.project, question: item.question, ...(item.blocks === undefined ? {} : { blocks: item.blocks }), at: now(), })), request.specIssue, ); const groupId = rows[0]!.groupId!; const { delivery, failure } = await deliverOnce( deps, questionnaireMessageFor(request), request.items[0]!.category ?? "decision-needed", ); const items = rows.map((row) => ({ id: row.id, question: row.question })); const perItem = items .map((item, index) => `${index + 1}. ${item.id} — ${decisionResolveCommand(item.id)}`) .join("\n"); return { groupId, items, text: `conductor_questionnaire ${groupId}: ${items.length} questions about #${request.specIssue}, each filed ` + `as its own open decision row, delivered as one message. ${deliveryLine(delivery, failure)}\n` + `${perItem}\n` + `Nothing waits for the answers here: the console session resolves each row as the operator answers ` + `it, in any order, and an unanswered row is swept by the seven-day decision expiry. Park the work ` + `every item blocks NOW, in this turn — out of the claimable queue, state recorded — and note group ` + `${groupId} in your report.`, }; }