ORDRE 42 (operator, 2026-08-16). Two sessions had their push refused by the UFW rate limit on port 22, reported that 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 since `done` drops a repo from the board plan, `morning --say <repo>` could not reach either of them. The deny sits on the WRITE, not on session end. Measured against the official hooks docs rather than assumed: Stop fires "once per turn" with no signal marking the last one, and its exit 2 "prevents Claude from stopping", so a repo that genuinely cannot push would get a session that will not end; SessionEnd is the once-per-session event and cannot block at all. Fails open on every git uncertainty (no upstream, detached HEAD, missing remote-tracking ref, not a repo) -- 8 of 44 repos on the real tree have no upstream, one already status=done. Compares against the branch's own upstream, never a hardcoded origin/main (three repos sit on master). Selects the board line with board.sh's own anchor, so prose saying status=done never triggers it. status=blocked and status=in-progress stay writable in the same single edit, so the deny can never wedge a session. state-line-guard-selftest.sh section 10, 17 checks (23 -> 40), including the mandatory known-positive: status=done with everything pushed still allows. Both outcomes also verified against real repos -- app-creator (1 unpushed) denied, repo-mailbox (clean) allowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P4LMWBQGmufmBU6UdvJZ2E
272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
#!/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 <repo>` 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 '^<!-- board:'),
|
|
// so the guard judges the exact line the board renders - or neither of them
|
|
// finds one. Prose is therefore never a trigger, which matters because a
|
|
// STATE.md documenting this very guard writes the literal string status=done.
|
|
function boardLineOf(text) {
|
|
const m = text.match(/^<!-- board:[^\n]*/m);
|
|
return m ? m[0] : null;
|
|
}
|
|
|
|
// board.sh's `sed -n 's/.*status=\([a-z-]*\).*/\1/p'` is greedy, so it reads the
|
|
// LAST status= on the line; mirror that rather than the first. The value is
|
|
// then compared to the exact vocabulary token: board.sh's prefix defect (F3+F4,
|
|
// queued separately) reads done2 as done, and copying that here would pin the
|
|
// defect instead of the vocabulary.
|
|
function boardStatusOf(line) {
|
|
const all = line.match(/status=[^;>\s]*/g);
|
|
return all ? all[all.length - 1].slice('status='.length) : null;
|
|
}
|
|
|
|
function git(dir, args) {
|
|
return execFileSync('git', ['-C', dir, ...args], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
timeout: 5000,
|
|
}).trim();
|
|
}
|
|
|
|
// Returns { branch, upstream, count, subjects } when the repo demonstrably has
|
|
// commits the upstream does not, or null in every other case INCLUDING every
|
|
// case it could not measure.
|
|
function unpushedOf(dir) {
|
|
try {
|
|
const upstream = git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
const count = parseInt(git(dir, ['rev-list', '--count', '@{u}..HEAD']), 10);
|
|
if (!Number.isFinite(count) || count < 1) return null;
|
|
return {
|
|
branch: git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
upstream,
|
|
count,
|
|
subjects: git(dir, ['log', '--format=%h %s', '-n', '5', '@{u}..HEAD']),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
const boardLine = boardLineOf(projected);
|
|
if (boardLine && boardStatusOf(boardLine) === 'done') {
|
|
const unpushed = unpushedOf(dirname(filePath));
|
|
if (unpushed) {
|
|
const one = unpushed.count === 1;
|
|
const noun = one ? 'commit' : 'commits';
|
|
const verb = one ? 'is' : 'are';
|
|
const indented = unpushed.subjects.split('\n').map((l) => ` ${l}`).join('\n');
|
|
const more = unpushed.count > 5 ? ` ... and ${unpushed.count - 5} more\n` : '';
|
|
process.stderr.write(
|
|
`\n[repo-mailbox] STATE DONE GUARD: ${toolName} blocked\n` +
|
|
` File: ${filePath}\n` +
|
|
` Board line: ${boardLine}\n` +
|
|
` Branch: ${unpushed.branch} -> ${unpushed.upstream}\n` +
|
|
` Unpushed: ${unpushed.count} ${noun}, present only in this checkout\n` +
|
|
`${indented}\n${more}\n` +
|
|
`status=done claims the WORK LANDED, not that the session finished. It has\n` +
|
|
`not landed: the ${noun} above ${verb} not on ${unpushed.upstream}, so anything\n` +
|
|
`reading the board -- or the published remote -- sees green over nothing.\n\n` +
|
|
`Do one of these, then write STATE.md again:\n` +
|
|
` git push origin ${unpushed.branch}\n` +
|
|
` -- if it goes through, status=done is true\n` +
|
|
` status=blocked\n` +
|
|
` -- if the push is refused (SSH rate limit: UFW allows 6 connections\n` +
|
|
` per 30s on port 22, and the chain ends in REJECT)\n` +
|
|
` status=in-progress\n` +
|
|
` -- if the work simply is not finished\n\n` +
|
|
`Only the board line's status token is judged here; nothing else in this\n` +
|
|
`write is being questioned.\n`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
process.exit(0);
|