#!/usr/bin/env node // Hook: post-edit-check.mjs // Event: PostToolUse (Edit, Write) // Purpose: P7 -- after each edit, run the target repo's OWN formatter and // linter on the edited file (the fast tier of three: per file here / step // end and full in lib/verification/step-check.mjs, contract K9). Findings go // back to Claude next to the tool result (`decision: "block"`, hooks // reference section PostToolUse decision control); the edit itself already // happened, so the stop is at step end, where K9 exits 1 on them. // A file no configured, installed tool covers is "not measured" -- never // clean. Every result is logged in /voyage/post-edit.jsonl for K9. // // On only while Voyage executes a plan in the repo, or when the repo has // switched it on (hooks/lib/post-edit.mjs; operator decision 06.10). In every // other session it exits at once and says nothing. // // Credit: running the formatter after Edit|Write is Anthropic's own documented // hook pattern (hooks-guide); formatting per edit and the rest at step end is // Cherny's; the cost tiers and "not measured" are agent-skills' (MIT); the // per-file / step-end split is Kiro's. Ideas only. import { readFileSync } from 'node:fs'; import { relative } from 'node:path'; import { resolveRepo, detectTools, toolsForFile, runTool, canonicalPath } from '../../lib/verification/repo-tools.mjs'; import { postEditActive, appendPostEditLog } from '../lib/post-edit.mjs'; let input; try { input = JSON.parse(readFileSync(0, 'utf-8')); } catch { process.exit(0); } const file = input?.tool_input?.file_path; if (!file || typeof file !== 'string') process.exit(0); const repo = resolveRepo(file); if (!repo) process.exit(0); const why = postEditActive(repo.root); if (!why) process.exit(0); const rel = relative(repo.root, canonicalPath(file)); const all = detectTools(repo.root); const tools = toolsForFile(all, file); const record = { ts: new Date().toISOString(), file: rel, on: why, session: input?.session_id ?? null }; function out(obj) { process.stdout.write(JSON.stringify(obj)); process.exit(0); } if (tools.length === 0) { const missing = all.filter((t) => !t.available).map((t) => t.reason); appendPostEditLog(repo.gitDir, { ...record, status: 'not_measured', results: [], reason: missing.join('; ') || 'no tool for this file type' }); out({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: `[voyage] post-edit check: not measured for ${rel} -- no formatter or linter for this file type is ` + `both configured and installed in this repository${missing.length ? ` (${missing.join('; ')})` : ''}.`, }, }); } const results = tools.map((t) => runTool(t, [rel], { cwd: repo.root, timeout: 30_000 })); const findings = results.filter((r) => r.status === 'finding'); const errors = results.filter((r) => r.status === 'error'); const status = findings.length ? 'finding' : errors.length ? 'not_measured' : 'clean'; appendPostEditLog(repo.gitDir, { ...record, status, results: results.map(({ output, ...r }) => r) }); const errText = errors.length ? ` Not measured: ${errors.map((e) => `${e.name} could not run (exit ${e.exit})`).join('; ')}.` : ''; if (findings.length) { out({ decision: 'block', reason: `[voyage] post-edit check found problems in ${rel}: ` + findings.map((f) => `${f.name} (${f.kind}): ${f.output.split('\n').slice(0, 15).join('\n')}`).join('\n') + `\nFix them before the step ends; the step check fails on them.${errText}`, }); } out({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: status === 'clean' ? `[voyage] post-edit check clean for ${rel}: ${results.map((r) => r.name).join(', ')}.` : `[voyage] post-edit check for ${rel}:${errText}`, }, });