feat(hooks): enforce STATE.md's ~60-line convention with a PreToolUse guard

org-ops dispatched a work order (20260814T144553Z) from an /insights sweep
of 160 sessions: a real STATE.md drifted to 155-156 lines before anyone
noticed, and one trim pass on it increased the line count instead of
shrinking it. Prose alone doesn't enforce.

org-ops proposed a PostToolUse hook. Checked against the official hooks
docs first: PostToolUse fires after the tool has already written the file
and cannot block it (confirmed "Can block? No"), only nag afterward. Built
it as PreToolUse instead, the only event that can deny the call before the
file lands.

pre-state-line-guard.mjs denies (stderr + exit 2, matching llm-security's
pre-write-pathguard.mjs) a Write or Edit on any STATE.md whose projected
result exceeds 60 lines. Write projects from the call's own content; Edit
projects from the current on-disk file with old_string replaced by
new_string, honoring replace_all (every occurrence) vs the default (first
occurrence only) the same way the real Edit tool does. Anything the hook
can't project confidently (missing file, old_string not found) is left to
the real tool.

state-line-guard-selftest.sh: 16 checks, including a replace_all fixture
that a first-occurrence-only projection would wrongly allow. Wired into
hooks/hooks.json as PreToolUse on Write|Edit. Version 0.22.0 -> 0.23.0.
Suite total: 191 + 152 + 69 + 16 = 428.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186kZGKddxfA9N84HqMLbb2
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 17:01:20 +02:00
commit f39c0df929
12 changed files with 412 additions and 17 deletions

View file

@ -34,6 +34,14 @@ test('route bash selftest passes', () => {
execFileSync('bash', [join(root, 'scripts', 'route-selftest.sh')], { encoding: 'utf8' });
});
// pre-state-line-guard.mjs is a PreToolUse hook, so like session-start.mjs it
// must be proven from the plugin root: the hook config resolves it through
// CLAUDE_PLUGIN_ROOT, and a guard proven only elsewhere is unproven on the
// path production actually runs.
test('state-line-guard bash selftest passes', () => {
execFileSync('bash', [join(root, 'scripts', 'state-line-guard-selftest.sh')], { encoding: 'utf8' });
});
// The engine refuses to invent an identity from the cwd, but the hook is the
// FOURTH place repo identity is derived, and a rule enforced in three of four
// places is not a rule: as long as the hook resolved the name itself and passed