voyage/hooks/lib/post-edit.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

76 lines
2.6 KiB
JavaScript

// hooks/lib/post-edit.mjs
// Shared by the post-edit guard (P7, hooks/scripts/post-edit-check.mjs) and
// the step check (K9, lib/verification/step-check.mjs): when the guard is on,
// and where its log lives.
//
// On (operator decision 06.10): while Voyage executes a plan in the repo -- a
// `.claude/projects/*/progress.json` (or legacy `.trekexecute-progress-*.json`)
// with status "in_progress" -- or when the repo has switched it on with
// `.claude/voyage-guards.json` = { "post_edit_check": true }. Off everywhere
// else, because the guard is wired for every session in every repo.
import { existsSync, readFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
export const OPT_IN_FILE = join('.claude', 'voyage-guards.json');
function readJson(p) {
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
}
function inProgress(p) {
return readJson(p)?.status === 'in_progress';
}
export function executingPlan(root) {
const projects = join(root, '.claude', 'projects');
try {
for (const d of readdirSync(projects, { withFileTypes: true })) {
if (d.isDirectory() && inProgress(join(projects, d.name, 'progress.json'))) return true;
}
} catch { /* no projects dir */ }
try {
for (const f of readdirSync(root)) {
if (/^\.trekexecute-progress-.+\.json$/.test(f) && inProgress(join(root, f))) return true;
}
} catch { /* unreadable root */ }
return false;
}
export function optedIn(root) {
const p = join(root, OPT_IN_FILE);
return existsSync(p) && readJson(p)?.post_edit_check === true;
}
/** @returns {'plan'|'opt-in'|null} why the guard is on, or null when it is off */
export function postEditActive(root) {
if (optedIn(root)) return 'opt-in';
if (executingPlan(root)) return 'plan';
return null;
}
export function postEditLogPath(gitDir) {
return join(gitDir, 'voyage', 'post-edit.jsonl');
}
export function appendPostEditLog(gitDir, record) {
const p = postEditLogPath(gitDir);
mkdirSync(join(gitDir, 'voyage'), { recursive: true });
appendFileSync(p, JSON.stringify(record) + '\n');
return p;
}
// The latest record per file (repo-relative path).
export function latestPostEditRecords(gitDir) {
const p = postEditLogPath(gitDir);
const out = new Map();
if (!existsSync(p)) return out;
for (const line of readFileSync(p, 'utf8').split('\n')) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line);
if (r && typeof r.file === 'string') out.set(r.file, r);
} catch { /* a torn line is skipped, never read as clean */ }
}
return out;
}