SPB Git

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%
9.7 KB · 279 lines typescript
Raw Blame History
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