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:
parent
61aebad748
commit
f39c0df929
12 changed files with 412 additions and 17 deletions
99
hooks/scripts/pre-state-line-guard.mjs
Normal file
99
hooks/scripts/pre-state-line-guard.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-state-line-guard.mjs
|
||||
// Event: PreToolUse (Write|Edit)
|
||||
// Purpose: block a Write/Edit that would push a STATE.md past the documented
|
||||
// ~60-line convention (global CLAUDE.md's Kontinuitets-system section).
|
||||
//
|
||||
// PreToolUse, not PostToolUse: org-ops' work order (20260814T144553Z) asked
|
||||
// for a PostToolUse hook, but PostToolUse fires AFTER the tool already ran
|
||||
// and cannot undo the write (confirmed against the official hooks docs,
|
||||
// 2026-08-14: "Can block? No" for PostToolUse). PreToolUse is the only event
|
||||
// that can deny before the file lands. The prose limit existed already and
|
||||
// still drifted silently to 155-156 lines in a real STATE.md before anyone
|
||||
// noticed via /insights - a hook is the mechanical backstop prose can't be.
|
||||
//
|
||||
// Blocking convention (stderr + exit 2) matches llm-security's
|
||||
// pre-write-pathguard.mjs, the only other PreToolUse Write/Edit guard in
|
||||
// this marketplace.
|
||||
//
|
||||
// Protocol:
|
||||
// - Read JSON from stdin: { tool_name, tool_input }
|
||||
// - Only Write/Edit targeting a file named exactly STATE.md (any
|
||||
// directory) are checked; everything else fails open immediately.
|
||||
// - Write: the projected content is tool_input.content.
|
||||
// - Edit: the projected content is the CURRENT on-disk file with
|
||||
// old_string replaced by new_string (every occurrence if
|
||||
// tool_input.replace_all is true, otherwise the first only) - the same
|
||||
// transform the real Edit tool applies. Anything this hook cannot
|
||||
// project confidently (file missing, old_string not found, fields of
|
||||
// the wrong type) is left to the real tool, which will give a clearer
|
||||
// error than a guess here would.
|
||||
// - Block: stderr + exit 2
|
||||
// - Allow: exit 0, no output
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
const MAX_LINES = 60;
|
||||
|
||||
function allow() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function countLines(text) {
|
||||
const matches = text.match(/\n/g);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
allow();
|
||||
}
|
||||
|
||||
const toolName = input?.tool_name;
|
||||
const toolInput = input?.tool_input ?? {};
|
||||
const filePath = toolInput.file_path;
|
||||
|
||||
if (
|
||||
(toolName !== 'Write' && toolName !== 'Edit') ||
|
||||
typeof filePath !== 'string' ||
|
||||
basename(filePath) !== 'STATE.md'
|
||||
) {
|
||||
allow();
|
||||
}
|
||||
|
||||
let projected;
|
||||
if (toolName === 'Write') {
|
||||
if (typeof toolInput.content !== 'string') allow();
|
||||
projected = toolInput.content;
|
||||
} else {
|
||||
let current;
|
||||
try {
|
||||
current = readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
allow();
|
||||
}
|
||||
const oldStr = toolInput.old_string;
|
||||
const newStr = toolInput.new_string;
|
||||
if (typeof oldStr !== 'string' || typeof newStr !== 'string' || !current.includes(oldStr)) {
|
||||
allow();
|
||||
}
|
||||
projected = toolInput.replace_all
|
||||
? current.split(oldStr).join(newStr)
|
||||
: current.replace(oldStr, newStr);
|
||||
}
|
||||
|
||||
const lines = countLines(projected);
|
||||
if (lines > MAX_LINES) {
|
||||
process.stderr.write(
|
||||
`\n[repo-mailbox] STATE LINE GUARD: ${toolName} blocked\n` +
|
||||
` File: ${filePath}\n` +
|
||||
` Projected: ${lines} lines (max ${MAX_LINES} per the STATE.md convention)\n\n` +
|
||||
`Trim STATE.md before writing -- history belongs in git, not STATE.md.\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue