- New Ask entity (ASK-####): schema + counter + paths(EntityType 'asks') +
events(AgentHubEventType 'ask') + fsWatch(WATCHED + toEvent). Generic entities
table, no migration.
- askService: createAsk routes to config.roles.architect.preferredAgent, NEVER
the CEO (a to='ceo' is rerouted); answerAsk / escalateAsk (escalatedTo='ceo',
single channel) / getAsk / listAsks. Authority-policy JSDoc.
- Asks kept OUT of FTS5: Index.upsert gains a { fts?: boolean } option; askService
upserts with fts:false, so 'memory search' never returns asks.
- routes: POST/GET /asks, GET /asks/:id, POST /asks/:id/{answer,escalate}, each
emitChange type:'ask'.
- CLI 'ask <q> --from [--task][--wait][--timeout]' (SSE reconnect wait until
status!=pending) + ask list/answer/escalate; remoteClient ask methods.
- MCP agenthub_ask (wait via waitForTask, now woken by 'ask' events) +
agenthub_ask_list/answer/escalate; agenthub_work architect branch surfaces
pending asks ({reviews,asks,messages}).
- Unattended mode (invocation flag): work.ts ctx + CLI 'work --unattended' +
agenthub_work schema + LOOP reminder ('call agenthub_ask instead of pausing').
- tests: +askService.test.ts (routing/answer/escalate/list/FTS-exclusion),
+ask-wait.test.ts (routes roundtrip + SSE wait: answer resolves, no-answer
times out cleanly)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
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> = {}): 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;
|
|
}
|