agenthub/src/core/services/askService.ts
chahinebrini f0c7be25f4 feat(agenthub): TSK-0118 — Ask primitive for autonomous decision-routing
- 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>
2026-07-12 01:19:42 +02:00

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;
}