import { join } from 'path'; import { getEntityDir } from '../paths.js'; import { getNextId } from '../counter.js'; import { readEntity, writeEntity } from '../files.js'; import { AskSchema, type Ask } from '../schema.js'; import { Index } from '../index.js'; import { loadConfig } from '../config.js'; /** * Ask primitive — autonomous decision-routing (TSK-0118). * * ## Authority policy * An Ask is the single blocking channel for a decision an agent can't make * alone. It flows strictly: * * implementer → ARCHITECT → (answer | escalate → CEO) * * - Implementer questions ALWAYS route to the architect (never the CEO). The * implementer awaits exactly ONE Ask — never a second, parallel blocking * channel. * - The architect answers most Asks himself, within the approve/push gate. * - The architect MUST escalate (not answer) for: release / publish / push, * OSS decisions, and architecture pivots. Escalation flips the Ask to * `escalated` (escalatedTo = 'ceo') and closes the loop through the SAME Ask — * the implementer keeps waiting on that one Ask, it never opens a CEO channel. */ function indexEntryFor(record: Ask, filePath: string) { return { id: record.id, type: 'ask', title: `${record.from} → ${record.to}`, content: record.question, filePath, createdAt: record.createdAt, updatedAt: record.updatedAt, status: record.status, fromAgent: record.from, toAgent: record.to, taskId: record.taskId, }; } /** * Who an Ask routes to by default: the architect's preferred agent. NEVER the * CEO — a raw question is the architect's to field; only an explicit escalation * reaches the CEO. */ function defaultRoutedTo(cwd: string): string { try { return loadConfig(cwd).roles?.architect?.preferredAgent || 'architect'; } catch { return 'architect'; } } /** Ask a routed question. Defaults to the architect; a `to` of 'ceo' is refused * (rerouted to the architect) — the CEO is reachable only via escalateAsk. */ export function createAsk(cwd: string, options: Partial = {}): Ask { if (!options.from) throw new Error('Ask requires a "from" agent'); if (!options.question) throw new Error('Ask requires a question'); const now = new Date().toISOString(); const to = options.to && options.to.toLowerCase() !== 'ceo' ? options.to : defaultRoutedTo(cwd); const record: Ask = AskSchema.parse({ id: getNextId(cwd, 'ask'), from: options.from, to, question: options.question, taskId: options.taskId, status: 'pending', createdAt: now, updatedAt: now, }); const filePath = join(getEntityDir(cwd, 'asks'), `${record.id}.md`); writeEntity(filePath, record, `# ${record.from} → ${record.to}\n\n${record.question}`); const index = new Index(cwd); index.upsert(indexEntryFor(record, filePath), { fts: false }); // keep Asks out of FTS5 index.close(); return record; } export function getAsk(cwd: string, id: string): { ask: Ask; body: string; filePath: string } { if (!id) throw new Error('Ask ID is required'); const filePath = join(getEntityDir(cwd, 'asks'), `${id}.md`); const { frontmatter, body } = readEntity(filePath); return { ask: AskSchema.parse(frontmatter), body, filePath }; } /** Asks, newest first, optionally filtered by recipient and/or status. */ export function listAsks(cwd: string, opts: { to?: string; status?: string } = {}): Ask[] { const index = new Index(cwd); const entries = index.list('ask', opts.status ? { status: opts.status } : undefined); index.close(); const ids = entries .filter((e) => !opts.to || (e.toAgent != null && String(e.toAgent).toLowerCase() === opts.to.toLowerCase())) .map((e) => e.id); const out: Ask[] = []; for (const id of ids) { try { out.push(getAsk(cwd, id).ask); } catch { /* torn file — skip */ } } return out; } /** Answer an Ask (architect resolves it in the approve/push gate). */ export function answerAsk(cwd: string, id: string, text: string, by?: string): Ask { if (!text || !text.trim()) throw new Error('Ask answer requires text'); const { ask, body, filePath } = getAsk(cwd, id); const updated: Ask = { ...ask, status: 'answered', answer: text, answeredBy: by, updatedAt: new Date().toISOString() }; writeEntity(filePath, updated, body); const index = new Index(cwd); index.upsert(indexEntryFor(updated, filePath), { fts: false }); index.close(); return updated; } /** * Escalate an Ask to the CEO (required for release/publish/push, OSS, and * architecture pivots). This does NOT open a second blocking channel — it flips * the SAME Ask to `escalated`; the waiting implementer resolves on this one Ask. */ export function escalateAsk(cwd: string, id: string, note?: string): Ask { const { ask, body, filePath } = getAsk(cwd, id); const updated: Ask = { ...ask, status: 'escalated', escalatedTo: 'ceo', updatedAt: new Date().toISOString() }; const newBody = note ? `${body}\n\n_escalated to ceo: ${note}_` : body; writeEntity(filePath, updated, newBody); const index = new Index(cwd); index.upsert(indexEntryFor(updated, filePath), { fts: false }); index.close(); return updated; }