import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; const ROAM_COMPILE_TIMEOUT_MS = 6000; const ROAM_VERIFY_TIMEOUT_MS = 90000; const MIN_PROMPT_CHARS = 8; type RoamViolation = { file?: string; line?: number; message?: string; fix?: string; }; type RoamCategoryResult = { violations?: RoamViolation[]; }; async function runRoamCompile( pi: ExtensionAPI, prompt: string ): Promise { try { const result = await pi.exec("roam", ["--json", "compile", prompt], { timeout: ROAM_COMPILE_TIMEOUT_MS, }); if (result.exitCode !== 0) return null; const output = result.stdout?.trim(); if (!output) return null; const d = JSON.parse(output); const summary = d.summary ?? {}; const advice = String(summary.injection_advice ?? ""); if (advice.startsWith("skip")) return null; const plan = d.artifact?.plan ?? {}; const facts: Record = {}; for (const [k, v] of Object.entries(plan.prefetched_facts ?? {})) { if (!k.startsWith("_")) facts[k] = v; } const block: Record = {}; if (summary.procedure) block.procedure = summary.procedure; if (summary.classifier_confidence !== undefined) { block.confidence = summary.classifier_confidence; } const namedPaths = (plan.named_paths ?? []).slice(0, 6); if (namedPaths.length > 0) block.named_paths = namedPaths; if (plan.recommended_first_command) { block.recommended_first_command = plan.recommended_first_command; } if (plan.answer_contract) block.answer_contract = plan.answer_contract; if (Object.keys(facts).length > 0) block.prefetched_facts = facts; if (Object.keys(block).length === 0) return null; return ( "PRE-COMPUTED PLAN (roam compile -- answer from these " + "embedded facts; do not re-gather what is already answered):\n" + JSON.stringify(block, null, 2) ); } catch { // fail open: roam must never block a turn return null; } } async function runRoamVerify( pi: ExtensionAPI ): Promise<{ decision: "block"; reason: string } | null> { try { const result = await pi.exec( "roam", ["--json", "verify", "--auto", "--diff-only"], { timeout: ROAM_VERIFY_TIMEOUT_MS, } ); const output = result.stdout?.trim(); if (!output) return null; const d = JSON.parse(output); const summary = d.summary ?? {}; const verdict = String(summary.verdict ?? ""); if (!verdict || verdict.toUpperCase().startsWith("PASS")) return null; const lines: string[] = [ `roam verify (post-edit, changed lines vs HEAD): ${verdict}`, ]; const findings: Array = []; for (const [cat, res] of Object.entries( d.categories as Record ?? {} )) { for (const v of res.violations ?? []) { findings.push({ category: cat, ...v }); } } for (const v of findings.slice(0, 8)) { const loc = v.line ? `${v.file}:${v.line}` : String(v.file); lines.push(` - [${v.category}] ${loc} -- ${v.message}`); if (v.fix) lines.push(` fix: ${v.fix}`); } if (findings.length > 8) { lines.push(` ... and ${findings.length - 8} more`); } lines.push( "AUTO-FIX: resolve these now. EDIT the file(s) to fix each " + "finding on a line your change touched; a genuine false " + "positive goes in .roam-suppressions.yml (rule/file/symbol or " + "line + reason); only clearly pre-existing, unrelated findings " + "may be left. Verify re-runs automatically after your fix." ); return { decision: "block", reason: lines.join("\n") }; } catch { // fail open: roam must never block completion return null; } } export default function (pi: ExtensionAPI) { // ── 0. session_start: warn if roam CLI is missing ──────────────── pi.on("session_start", async (_event, ctx) => { try { await pi.exec("roam", ["--version"], { timeout: 2000 }); } catch { ctx.ui.notify( "roam CLI not found. Install: curl -sSL https://roam.dev/install | sh", "warning" ); } }); // ── 1. before_agent_start: inject roam compile context ──────────── pi.on("before_agent_start", async (event) => { const prompt = (event.prompt || "").trim(); if (prompt.length < MIN_PROMPT_CHARS) return; const output = await runRoamCompile(pi, prompt); if (!output) return; return { message: { customType: "roam-compile", content: output, display: false, // invisible in TUI — fed to model only }, }; }); // ── 2. agent_end: run roam verify after agent finishes ──────────── pi.on("agent_end", async (_event, ctx) => { // Skip if already inside a stop-hook continuation const entries = ctx.sessionManager.getEntries(); const lastUserMessage = [...entries] .reverse() .find((e) => e.type === "message" && e.message.role === "user"); if (lastUserMessage?.message?.role === "user") { const text = lastUserMessage.message.content ?.map((c) => (c.type === "text" ? c.text : "")) .join("") ?? ""; if (text.includes("[roam-verify-auto-fix]")) return; } const parsed = await runRoamVerify(pi); if (!parsed || parsed.decision !== "block" || !parsed.reason) return; // Queue a follow-up message to fix the issues. // Using sendUserMessage with followUp delivers only after the // agent is fully idle, preventing loops naturally. pi.sendUserMessage(`[roam-verify-auto-fix]\n${parsed.reason}`, { deliverAs: "followUp", }); }); }