SPB Git

Repository Public

criteria.ts 6.1 KB 113 lines typescript

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
Files main
Menu

Theme