#!/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. // - RATCHET: denies only when the projected line count is BOTH over // MAX_LINES and larger than the file's CURRENT line count (0 for a file // that doesn't exist yet). A file already over the limit is the normal // starting point for a trim, not an edge case - measured on the real // tree 2026-08-14, 23 of the machine's STATE.md files were already over // 60 lines, one at 1405. Comparing only against MAX_LINES (no ratchet) // would deny every incremental trim of those files that doesn't land at // <=60 in one shot - the opposite of what a guard meant to make trimming // possible should do. The ratchet still blocks what the guard exists to // block: a compliant file growing past the limit, or a brand-new file // being created oversized. // - 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; } function currentLineCountOf(path) { try { return countLines(readFileSync(path, 'utf-8')); } catch { return 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; let currentLines; if (toolName === 'Write') { if (typeof toolInput.content !== 'string') allow(); projected = toolInput.content; currentLines = currentLineCountOf(filePath); } 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); currentLines = countLines(current); } const lines = countLines(projected); if (lines > MAX_LINES && lines > currentLines) { process.stderr.write( `\n[repo-mailbox] STATE LINE GUARD: ${toolName} blocked\n` + ` File: ${filePath}\n` + ` Projected: ${lines} lines (current: ${currentLines}, max ${MAX_LINES} per the STATE.md convention)\n\n` + `This would grow STATE.md further past the limit. Trim it instead -- ` + `any write that reduces the line count is allowed, even if still over ${MAX_LINES}.\n` ); process.exit(2); } process.exit(0);