#!/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 // ~120-line convention (global CLAUDE.md's Kontinuitets-system section; // raised from ~60 by operator decision 2026-08-14). // // 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. // // currentLineCountOf() assumes file_path arrives ABSOLUTE - the Write and // Edit tool contracts both require it, so a relative path never reaches this // hook in practice. This matters because a read failure is swallowed as // current=0: a relative path resolving against the wrong cwd would silently // collapse the ratchet back into the flat gate it exists to avoid (Write) or // fail open with no enforcement at all (Edit, via the outer readFileSync // catch). Do not "harden" this away with input.cwd without re-reading why // it was never needed. // // 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 at the 120-line threshold, 13 of the machine's // STATE.md files were already over 120 lines, one at 1496. 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 // // SECOND INVARIANT (ORDRE 42, operator decision 2026-08-16): the same projected // content must not claim `status=done` in its board line while the repo holds // commits that are not on the branch's upstream. Measured that day: two // sessions had their push refused by the UFW rate limit on port 22, said so // honestly in the coord inbox, and wrote status=done anyway - board line green, // one commit unpushed, published surface 404. `done` meant "the session // finished" where every reader takes it to mean "the work landed", and because // `done` removes a repo from the board plan, `morning --say ` could not // reach either of them: one defect hid the other. // // WHY THE WRITE PATH AND NOT SESSION END. The order offered three directions // and named session-end (B) as the recommendation. B does not exist in the form // it assumes, measured against the official hooks docs 2026-08-16: // - Stop fires "once per turn", not once when the session ends, and there is // no signal telling a Stop hook that this turn is the last. Its premise // ("by then commit and push are done") holds only for the final turn; on // every earlier turn it would block live work, and exit 2 there // "prevents Claude from stopping, continues the conversation" - so a repo // that genuinely cannot push (the rate limit that caused the incident) // gets a session that will not end. // - SessionEnd is the once-per-session event, and it cannot block at all: // "Can block? No", exit 2 "shows stderr to user only". It can nag after the // fact, which is what the order explicitly did not want. // C (warn on write, deny at session end) inherits B's half without gaining // anything a single deny does not already give. So: the write path, which is // where the false claim is actually made. // // The false-positive trap the order warned about is real but bounded. STATE.md // is written BEFORE the session's final commit, so a session that batches its // pushes has unpushed commits at exactly this moment. Two things keep that from // biting: the global git rule already requires a push immediately after every // commit (so a compliant session sits at zero unpushed here - measured on the // real tree 2026-08-16, 43 of 44 repos carrying a STATE.md had nothing // unpushed, the one exception being status=blocked and honest), and the deny is // escapable by telling the truth rather than only by pushing: status=blocked // and status=in-progress are always writable, in the same single edit. // // NO RATCHET HERE, deliberately, and the difference from the line-count rule // above is the reason. A file already over the line limit needs many writes to // come back under it, so denying every intermediate step would make trimming // impossible; a false `done` is corrected by changing one token in the write // that is already being made. A "only deny the transition into done" rule was // considered and rejected outright: the common shape is a repo that ended // `done` last session and rewrites `done` this session, which such a rule would // wave through - precisely the case the order exists to stop. // // FAILS OPEN on every git uncertainty (no upstream, detached HEAD, missing // remote-tracking ref, not a repo, git absent or slow). A confident denial // built on a measurement that did not happen is the worse error, and 8 of the // 44 STATE.md repos on the real tree have no upstream at all - one of them // already status=done. The hole this leaves is named in the selftest (10.6). // // The file keeps its name: both invariants are properties of a line in // STATE.md, and one hook process per Write/Edit stays cheaper than two. import { readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { execFileSync } from 'node:child_process'; const MAX_LINES = 120; 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) // A string replacement here would let JS interpret $-sequences inside // newStr ($&, $`, $', $$, $n) as special patterns instead of literal // text - a function replacement is never pattern-substituted. : current.replace(oldStr, () => newStr); currentLines = countLines(current); } // The board line is selected with board.sh's own anchor (grep -m1 '^