spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/agent/completion.ts4 * Description: Verification gate and CompletionEvidence — evidence-backed completion, never confident sentences (ADR-12, CLAUDE.md §17).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import * as path from "node:path";11import type { CheckResult, CompletionEvidence, DurableEvent } from "../session/index.js";12import type { Workspace } from "../workspace/index.js";1314// ───────────────────────── change attribution seam ─────────────────────────1516/** Files attributable to this session vs pre-existing user work (CLAUDE.md §16). */17export interface ChangeAttribution {18 khaelor: string[];19 preExisting: string[];20}2122/** Structurally satisfied by `GitService.attributeChanges` (src/repository) — wired by the composition root. */23export type AttributionResult =24 | { kind: "ok"; value: ChangeAttribution }25 | { kind: "not-a-repo" }26 | { kind: "error"; code: string; message: string };2728export interface ChangeAttributor {29 attributeChanges(): Promise<AttributionResult>;30}3132// ───────────────────────── turn facts (pure fold) ─────────────────────────3334/** Commands that count as verification evidence when run through `bash`. */35export const CHECK_COMMAND_PATTERN =36 /\b(tests?|vitest|jest|pytest|tsc|typecheck|lint|eslint|ruff|build|check)\b/i;3738const DOCUMENTATION_EXTENSIONS = [".md", ".markdown", ".rst", ".adoc", ".txt"];3940/** Documentation-only changes are filtered from the gate (ADR-12). */41export function isDocumentationPath(filePath: string): boolean {42 const lower = filePath.toLowerCase();43 return DOCUMENTATION_EXTENSIONS.some((ext) => lower.endsWith(ext));44}4546interface TurnCheck extends CheckResult {47 seq: number;48}4950/** Everything the gate and evidence builder need, derived from the recorded log in one pass. */51export interface TurnFacts {52 /** Seq of the turn's user message; 0 when the log has none. */53 turnStartSeq: number;54 /** The user's request text — the evidence `objective`. */55 objective: string;56 /** All files modified this turn (write/edit events). */57 changedFiles: string[];58 /** Seq of the last NON-documentation file change this turn; 0 when none. */59 lastCodeChangeSeq: number;60 /** Check-shaped bash commands completed this turn, with real exit codes only. */61 checks: TurnCheck[];62 /** Verification nudges already issued this turn (budget ≤ 2). */63 verificationAttempts: number;64 /** Seq of the last settled assistant text block — the withheld candidate (ADR-12). */65 candidateSeq: number;66}6768/** Fold the durable stream into the current turn's completion-relevant facts. */69export function collectTurnFacts(events: readonly DurableEvent[]): TurnFacts {70 let turnStartSeq = 0;71 let objective = "";72 for (const event of events) {73 if (event.type === "user.message-created") {74 turnStartSeq = event.seq;75 objective = event.payload.text;76 }77 }7879 const changedFiles = new Set<string>();80 let lastCodeChangeSeq = 0;81 const bashCommands = new Map<string, string>(); // toolUseId → command82 const checks: TurnCheck[] = [];83 let verificationAttempts = 0;84 let candidateSeq = 0;8586 for (const event of events) {87 if (event.seq <= turnStartSeq) continue;88 switch (event.type) {89 case "file.modified": {90 changedFiles.add(event.payload.path);91 if (!isDocumentationPath(event.payload.path)) {92 lastCodeChangeSeq = Math.max(lastCodeChangeSeq, event.seq);93 }94 break;95 }96 case "tool.requested": {97 if (event.payload.toolName === "bash") {98 const input = event.payload.input;99 const command =100 input !== null && typeof input === "object"101 ? (input as Record<string, unknown>)["command"]102 : undefined;103 if (typeof command === "string") bashCommands.set(event.payload.toolUseId, command);104 }105 break;106 }107 case "tool.completed": {108 const command = bashCommands.get(event.payload.toolUseId);109 const exitCode = event.payload.ui.exitCode;110 if (111 command !== undefined &&112 CHECK_COMMAND_PATTERN.test(command) &&113 typeof exitCode === "number"114 ) {115 checks.push({116 command,117 exitCode,118 summary: event.payload.ui.summary,119 durationMs: event.payload.durationMs,120 seq: event.seq,121 });122 }123 break;124 }125 case "task.verification-requested": {126 verificationAttempts += 1;127 break;128 }129 case "model.text-block-completed": {130 candidateSeq = event.seq;131 break;132 }133 default:134 break;135 }136 }137138 return {139 turnStartSeq,140 objective,141 changedFiles: [...changedFiles].sort(),142 lastCodeChangeSeq,143 checks,144 verificationAttempts,145 candidateSeq,146 };147}148149// ───────────────────────── the gate ─────────────────────────150151export interface VerificationGateResult {152 /** Code changed this turn with no check run since the last change. */153 required: boolean;154 /** Nudges already issued this turn. */155 attempts: number;156 /** Seq of the withheld candidate answer (last settled text block). */157 candidateSeq: number;158}159160export interface VerificationGateOptions {161 workspace: Workspace;162 /** GitService (or compatible) — merged into evidence.changedFiles when available. */163 attributor?: ChangeAttributor;164}165166const CHECK_SCRIPT_NAMES = ["test", "typecheck", "lint", "build", "check"] as const;167168/**169 * The verification gate (ARCHITECTURE.md §9). When the model stops with code170 * changed this turn and no fresh verification evidence, the kernel records171 * `VerificationRequested` (max 2 attempts) and re-prompts with a nudge before172 * accepting completion. All evidence is real: recorded events and actual173 * repository state — nothing is fabricated (Absolute Rule #4).174 */175export class VerificationGate {176 readonly #workspace: Workspace;177 readonly #attributor: ChangeAttributor | undefined;178179 constructor(options: VerificationGateOptions) {180 this.#workspace = options.workspace;181 this.#attributor = options.attributor;182 }183184 /** Pure decision over recorded state — consumed by the kernel's deriveNext. */185 needsVerification(events: readonly DurableEvent[]): VerificationGateResult {186 const facts = collectTurnFacts(events);187 const freshEvidence = facts.checks.some((check) => check.seq > facts.lastCodeChangeSeq);188 return {189 required: facts.lastCodeChangeSeq > 0 && !freshEvidence,190 attempts: facts.verificationAttempts,191 candidateSeq: facts.candidateSeq,192 };193 }194195 /**196 * Detect relevant check commands from the repository — package scripts197 * only in V1, never blind full suites (ADR-12). Empty when undetectable.198 */199 async detectChecks(): Promise<string[]> {200 try {201 const raw = await this.#workspace.readFile(202 path.join(this.#workspace.cwd(), "package.json"),203 );204 const parsed = JSON.parse(raw) as { scripts?: Record<string, unknown> };205 const scripts = parsed.scripts ?? {};206 const detected: string[] = [];207 for (const name of CHECK_SCRIPT_NAMES) {208 if (typeof scripts[name] === "string") {209 detected.push(name === "test" ? "npm test" : `npm run ${name}`);210 }211 }212 return detected;213 } catch {214 return [];215 }216 }217218 /** The synthetic evidence-bearing nudge injected as volatile per-turn context. */219 buildNudge(detectedChecks: readonly string[]): string {220 const suggestion =221 detectedChecks.length > 0222 ? ` Detected project checks: ${detectedChecks.join(" · ")}.`223 : "";224 return (225 "Verification required before completion: files were modified this turn but no check " +226 "has been run since the last change. Run the relevant project checks now with the bash " +227 `tool and report their real results.${suggestion} If a check fails, fix the code and ` +228 "re-run it. Only then give your final answer."229 );230 }231232 /**233 * Build the CompletionEvidence record (CLAUDE.md §17) from recorded events234 * plus live change attribution. Every check result carries a real exit code.235 */236 async collectEvidence(events: readonly DurableEvent[]): Promise<CompletionEvidence> {237 const facts = collectTurnFacts(events);238 const changed = new Set(facts.changedFiles);239240 if (this.#attributor !== undefined) {241 try {242 const attribution = await this.#attributor.attributeChanges();243 if (attribution.kind === "ok") {244 for (const file of attribution.value.khaelor) changed.add(file);245 }246 } catch {247 // Attribution is best-effort evidence enrichment — never fabricated, never fatal.248 }249 }250251 const unresolvedIssues: string[] = [];252 for (const check of facts.checks) {253 if (check.exitCode !== 0) {254 unresolvedIssues.push(`"${check.command}" exited with code ${check.exitCode}.`);255 }256 }257 const gate = this.needsVerification(events);258 if (gate.required) {259 unresolvedIssues.push(260 gate.attempts >= 2261 ? "Code files changed without fresh verification evidence (verification nudge budget exhausted)."262 : "Code files changed without fresh verification evidence.",263 );264 }265266 return {267 objective: facts.objective,268 changedFiles: [...changed].sort(),269 checks: facts.checks.map(({ command, exitCode, summary, durationMs }) => ({270 command,271 exitCode,272 summary,273 durationMs,274 })),275 unresolvedIssues,276 };277 }278}279