voyage/hooks/scripts/post-edit-check.mjs
Kjell Tore Guttormsen 2ee8137e7c
feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard).
All command hooks; none runs a model.

- P12: pre-bash-executor answers permissionDecision "ask" before merge
  into main, push, tag/publish and destructive git (holds in auto mode).
  post-approval-log writes exactly what the person was asked to the K2
  approval log: merge/push to main as `merge`, tag/publish as `release`,
  with a one-hour expiry; nothing when no person was asked.
- P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan
  mode and sends a refused plan back; "not measured" asks, never passes.
- P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to
  locked test files; stop-test-lock refuses "done" for the locking
  session (stop_hook_active guarded).
- P7: post-edit-check runs the target repo's own formatter/linter per
  edit, only during a Voyage plan run or after opt-in
  (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails
  the step on findings; no tool reads "not measured", never green.
- STORM cap matcher also names `Agent` (the tool's name since 2.1.63).
- Mod precondition stated in README, SECURITY.md and every PreToolUse stop
  guard: Voyage's guards are deterministic only when no installed mod
  handles `tool.check`.

K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged.
Two independent review rounds; every finding has a regression test.
Suite 1600 (1598 pass / 0 fail / 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:09:32 +02:00

90 lines
3.7 KiB
JavaScript

#!/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 <git dir>/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}`,
},
});