Repository Public
1/**2 * Done criteria (CONTRACT §5.1 / §5.4): the propose → confirm protocol, `update_criteria`, and the3 * `finish` gate. Pure functions over the criteria list; the engine persists the list in4 * `missions.done_criteria` inside journaled steps and folds the step RESULTS back into its state.5 */6import type { Kernel } from '@aos/kernel';7import type { DoneCriterion, ResultEnvelope } from '@aos/shared';8import { dbQuery } from './db.js';9import { finishToEnvelope, type FinishPayload, type UpdateCriteriaPayload } from '@aos/tools';1011export const ACCEPT_CRITERIA_OPTION = 'Yes — use these criteria';1213/** Criteria as stored by `createMission` (`c1…cn`, passes=false). */14export function criteriaFromTexts(texts: string[]): DoneCriterion[] {15 return texts.map((text, n) => ({ id: `c${n + 1}`, text, passes: false }));16}1718export function proposalQuestion(criteria: string[], rationale: string | null): Record<string, unknown> {19 const list = criteria.map((c, i) => `${i + 1}. ${c}`).join('\n');20 return {21 text: 'The agent proposes these done criteria. Do they describe the outcome you want?',22 detail: rationale ? `${list}\n\nWhy: ${rationale}` : list,23 options: [ACCEPT_CRITERIA_OPTION],24 source: 'propose_criteria',25 proposed: criteria,26 };27}2829/**30 * Reads the owner's reply to a proposal. An answer that is the offered option or starts with a plain31 * "yes / ok / oui / accept / approve / lgtm" accepts the list as proposed (anything after that word is32 * passed to the agent as a note); any other reply is a request for changes.33 */34export function readProposalAnswer(answer: string): { accepted: boolean; note: string | null } {35 const a = answer.trim();36 if (a === ACCEPT_CRITERIA_OPTION) return { accepted: true, note: null };37 const m = /^(yes|y|ok|okay|oui|accept(ed)?|approved?|lgtm)\b[\s.,;:!—–-]*/i.exec(a);38 if (!m) return { accepted: false, note: a.length > 0 ? a : null };39 const rest = a.slice(m[0].length).trim();40 return { accepted: true, note: rest.length > 0 ? a : null };41}4243export interface CriteriaUpdate {44 ok: boolean;45 /** Tool result text. */46 content: string;47 criteria: DoneCriterion[];48 changed: boolean;49}5051const tally = (criteria: DoneCriterion[]): string => `${criteria.filter(isSatisfied).length}/${criteria.length} criteria passing`;52export const isSatisfied = (c: DoneCriterion): boolean => c.passes === true && typeof c.evidence === 'string' && c.evidence.trim().length > 0;5354/** Applies an `update_criteria` payload. All-or-nothing: an unknown id rejects the whole call. */55export function applyCriteriaUpdate(criteria: DoneCriterion[], p: UpdateCriteriaPayload): CriteriaUpdate {56 if (criteria.length === 0) {57 return { ok: false, content: 'There are no confirmed done criteria yet. Call propose_criteria first and wait for the owner to confirm them.', criteria, changed: false };58 }59 const known = new Set(criteria.map((c) => c.id));60 const unknown = p.updates.filter((u) => !known.has(u.id)).map((u) => u.id);61 if (unknown.length > 0) {62 return { ok: false, content: `Unknown criterion id: ${unknown.join(', ')}. Valid ids: ${criteria.map((c) => c.id).join(', ')}. Nothing was changed.`, criteria, changed: false };63 }64 const missing = p.updates.filter((u) => u.passes && u.evidence.trim().length === 0).map((u) => u.id);65 if (missing.length > 0) return { ok: false, content: `Evidence is required to mark ${missing.join(', ')} as passing. Nothing was changed.`, criteria, changed: false };66 const byId = new Map(p.updates.map((u) => [u.id, u]));67 let changed = false;68 const next = criteria.map((c): DoneCriterion => {69 const u = byId.get(c.id);70 if (!u) return c;71 const evidence = u.evidence.trim();72 const n: DoneCriterion = evidence.length > 0 ? { id: c.id, text: c.text, passes: u.passes, evidence } : { id: c.id, text: c.text, passes: u.passes };73 if (n.passes !== c.passes || (n.evidence ?? '') !== (c.evidence ?? '')) changed = true;74 return n;75 });76 const lines = p.updates.map((u) => `${u.id}: ${u.passes ? 'passing' : 'not passing'}`);77 return { ok: true, content: `Recorded. ${lines.join('; ')}. ${tally(next)}.`, criteria: next, changed };78}7980/**81 * The `finish` gate. "done" needs confirmed criteria that all pass with evidence; the other statuses are82 * always accepted. Returns the envelope, or the error text handed back to the model.83 */84export function gateFinish(p: FinishPayload, criteria: DoneCriterion[], confirmed: boolean): { ok: true; envelope: ResultEnvelope } | { ok: false; error: string } {85 if (p.status === 'done') {86 if (!confirmed || criteria.length === 0) {87 return { ok: false, error: 'Cannot finish as "done": this mission has no confirmed done criteria. Call propose_criteria, get them confirmed, verify them, then finish — or finish with "partial"/"blocked" and a reason.' };88 }89 const open = criteria.filter((c) => !isSatisfied(c));90 if (open.length > 0) {91 const list = open.map((c) => `- ${c.id}: ${c.text}${c.passes ? ' (marked passing but without evidence)' : ''}`).join('\n');92 return {93 ok: false,94 error: `Cannot finish as "done": ${open.length} of ${criteria.length} done criteria are not passing with evidence:\n${list}\n` +95 'Verify each one and record it with update_criteria, or finish with status "partial", "blocked" or "failed" and a reason.',96 };97 }98 }99 return { ok: true, envelope: finishToEnvelope(p) };100}101102/** One-paragraph state used in engine-written summaries (budget, context, stall endings). */103export function criteriaStatusLine(criteria: DoneCriterion[], confirmed: boolean): string {104 if (!confirmed || criteria.length === 0) return 'Done criteria: none confirmed.';105 const open = criteria.filter((c) => !isSatisfied(c)).map((c) => c.id);106 return `Done criteria: ${tally(criteria)}${open.length > 0 ? ` (open: ${open.join(', ')})` : ''}.`;107}108109/** Absolute, idempotent write of the mission's criteria (called inside a journaled step). */110export async function storeCriteria(k: Kernel, missionId: string, criteria: DoneCriterion[]): Promise<void> {111 await dbQuery(k, 'UPDATE missions SET done_criteria = $2::jsonb WHERE id = $1', [missionId, JSON.stringify(criteria)]);112}113