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>
90 lines
3.7 KiB
JavaScript
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}`,
|
|
},
|
|
});
|