/** * KHAELOR * File: src/verify/config.ts * Description: Verification config — .khaelor/verify.json loading and project auto-detection (v2 design §4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { Workspace } from "../workspace/index.js"; export type VerifyPolicy = "after-each-edit-batch" | "before-final-answer" | "off"; export interface VerifyCheck { name: string; cmd: string; timeoutMs: number; /** The check mutates files (e.g. eslint --fix) — run it last. */ autofix: boolean; } export interface VerifyConfig { checks: VerifyCheck[]; policy: VerifyPolicy; /** Bounded repair loop: at most this many failing-verify → model-repair rounds per user turn. */ maxRepairLoops: number; } export const VERIFY_FILE = ".khaelor/verify.json"; export const DEFAULT_MAX_REPAIR_LOOPS = 3; const DEFAULT_TIMEOUT_MS = 120_000; const POLICIES: readonly string[] = ["after-each-edit-batch", "before-final-answer", "off"]; /** Parse a raw verify.json object. Invalid entries are skipped, never fatal. */ export function parseVerifyConfig(raw: unknown): VerifyConfig | null { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; const record = raw as Record; const checks: VerifyCheck[] = []; for (const [name, value] of Object.entries(record)) { if (name === "policy" || name === "maxRepairLoops") continue; if (value === null || typeof value !== "object" || Array.isArray(value)) continue; const entry = value as Record; const cmd = entry["cmd"]; if (typeof cmd !== "string" || cmd.length === 0) continue; const timeout = entry["timeout"]; const timeoutMs = typeof timeout === "number" && timeout > 0 ? Math.round(timeout * 1000) : DEFAULT_TIMEOUT_MS; checks.push({ name, cmd, timeoutMs, autofix: entry["autofix"] === true }); } const policyRaw = record["policy"]; const policy: VerifyPolicy = typeof policyRaw === "string" && POLICIES.includes(policyRaw) ? (policyRaw as VerifyPolicy) : "after-each-edit-batch"; const loopsRaw = record["maxRepairLoops"]; const maxRepairLoops = typeof loopsRaw === "number" && Number.isInteger(loopsRaw) && loopsRaw >= 0 ? loopsRaw : DEFAULT_MAX_REPAIR_LOOPS; return { checks, policy, maxRepairLoops }; } async function readJson(workspace: Workspace, path: string): Promise { try { return JSON.parse(await workspace.readFile(path)) as unknown; } catch { return null; } } async function exists(workspace: Workspace, path: string): Promise { try { await workspace.readFile(path); return true; } catch { return false; } } /** * Auto-detect checks from the project (package.json scripts, tsconfig, * Cargo.toml, pyproject.toml). Best-effort and conservative: only well-known * commands, never destructive ones. */ export async function detectVerifyChecks(workspace: Workspace): Promise { const cwd = workspace.cwd(); const checks: VerifyCheck[] = []; const pkg = await readJson(workspace, `${cwd}/package.json`); if (pkg !== null && typeof pkg === "object" && !Array.isArray(pkg)) { const scripts = (pkg as Record)["scripts"]; const scriptSet = scripts !== null && typeof scripts === "object" && !Array.isArray(scripts) ? (scripts as Record) : {}; if (typeof scriptSet["typecheck"] === "string") { checks.push({ name: "typecheck", cmd: "npm run typecheck", timeoutMs: DEFAULT_TIMEOUT_MS, autofix: false }); } else if (await exists(workspace, `${cwd}/tsconfig.json`)) { checks.push({ name: "typecheck", cmd: "npx tsc --noEmit", timeoutMs: DEFAULT_TIMEOUT_MS, autofix: false }); } if (typeof scriptSet["test"] === "string") { checks.push({ name: "test", cmd: "npm test", timeoutMs: 300_000, autofix: false }); } if (typeof scriptSet["lint"] === "string") { checks.push({ name: "lint", cmd: "npm run lint", timeoutMs: DEFAULT_TIMEOUT_MS, autofix: false }); } return checks; } if (await exists(workspace, `${cwd}/Cargo.toml`)) { checks.push({ name: "check", cmd: "cargo check", timeoutMs: 300_000, autofix: false }); checks.push({ name: "test", cmd: "cargo test", timeoutMs: 600_000, autofix: false }); return checks; } if (await exists(workspace, `${cwd}/pyproject.toml`)) { checks.push({ name: "test", cmd: "python3 -m pytest -x -q", timeoutMs: 300_000, autofix: false }); return checks; } return checks; } /** * Load the effective verify config: .khaelor/verify.json when present, * auto-detection otherwise (v2 §4 — surchargeable). */ export async function loadVerifyConfig(workspace: Workspace): Promise { const raw = await readJson(workspace, `${workspace.cwd()}/${VERIFY_FILE}`); if (raw !== null) { const parsed = parseVerifyConfig(raw); if (parsed !== null) return parsed; } return { checks: await detectVerifyChecks(workspace), policy: "after-each-edit-batch", maxRepairLoops: DEFAULT_MAX_REPAIR_LOOPS, }; }