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
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -51,6 +51,38 @@ marketplace plugin. Three components, one boundary:
|
|||
wrapper (marketplace convention: hooks are `.mjs`) that calls
|
||||
`coord-inbox.sh` and emits the `hookSpecificOutput.additionalContext`
|
||||
envelope. No mailbox logic lives here. Always exits 0.
|
||||
- **Hook (`hooks/scripts/pre-state-line-guard.mjs`):** a `PreToolUse` hook on
|
||||
`Write|Edit` that enforces the STATE.md convention's `maks ~60 linjer`
|
||||
(global CLAUDE.md) mechanically. It exists because the prose limit alone
|
||||
failed: a real STATE.md drifted to 155-156 lines before an /insights sweep
|
||||
of 160 sessions noticed, and one trim pass on it *increased* the line count
|
||||
instead of shrinking it. org-ops dispatched the work order
|
||||
(20260814T144553Z) asking for a `PostToolUse` hook — that was the wrong
|
||||
event, and the fix is not cosmetic: `PostToolUse` fires only after the tool
|
||||
has already written the file (confirmed against the official hooks docs,
|
||||
2026-08-14 — "Can block? No", stderr is shown to the model but the write
|
||||
already landed), so it cannot stop an oversized STATE.md from landing, only
|
||||
nag about it afterward. `PreToolUse` is the only event that can deny the
|
||||
call before the file is touched, which is what "enforces" has to mean here.
|
||||
Denial is stderr + `exit 2`, matching `llm-security`'s
|
||||
`pre-write-pathguard.mjs` — the only other `PreToolUse` `Write|Edit` guard
|
||||
in this marketplace — rather than the `hookSpecificOutput.permissionDecision`
|
||||
JSON form; both block, and matching the sibling convention keeps one idiom
|
||||
for "block a write" instead of two. For `Write` the projected content is the
|
||||
call's own `content`; for `Edit` it is the CURRENT on-disk file (read fresh,
|
||||
since `PreToolUse` fires before the edit is applied) with `old_string`
|
||||
replaced by `new_string` — every occurrence when `replace_all` is set,
|
||||
otherwise only the first, mirroring what the real Edit tool does. Getting
|
||||
`replace_all` wrong in either direction is not a hypothetical: a hook that
|
||||
only ever replaced the first occurrence would silently pass a bulk edit that
|
||||
balloons the file, so `state-line-guard-selftest.sh` (16 checks) pins a
|
||||
fixture where only counting every `replace_all` occurrence produces the
|
||||
correct denial. Anything the hook cannot project with confidence — a
|
||||
missing file, an `old_string` that is not present, fields of the wrong
|
||||
type — is left to the real tool, which reports a clearer error than a guess
|
||||
here would; the guard only ever touches files named exactly `STATE.md`, at
|
||||
any depth, matching the same basename rule the global session-start hook's
|
||||
nearest-STATE-wins search already uses.
|
||||
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
|
||||
next-step blocks + board lines, `git status`, and mailbox pending counts, and
|
||||
prints one line per repo. Read-only by construction: it writes to no repo, no
|
||||
|
|
@ -361,17 +393,21 @@ obligations in another repo.
|
|||
builtins only in hook and tests.
|
||||
- TDD: no behavior change without a failing selftest check first.
|
||||
`bash scripts/coord-selftest.sh` must exit 0 (191/191),
|
||||
`bash scripts/board-selftest.sh` must exit 0 (152/152) and
|
||||
`bash scripts/route-selftest.sh` must exit 0 (69/69).
|
||||
`bash scripts/board-selftest.sh` must exit 0 (152/152),
|
||||
`bash scripts/route-selftest.sh` must exit 0 (69/69) and
|
||||
`bash scripts/state-line-guard-selftest.sh` must exit 0 (16/16).
|
||||
- English for all code, docs, and commit messages (public repo). Norwegian
|
||||
trigger aliases in the skill description are deliberate.
|
||||
- Conventional Commits: `type(scope): description`.
|
||||
|
||||
## Commands
|
||||
|
||||
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh` and
|
||||
`bash scripts/route-selftest.sh` (or `npm test`, the Node wrapper around all three)
|
||||
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh`,
|
||||
`bash scripts/route-selftest.sh` and `bash scripts/state-line-guard-selftest.sh`
|
||||
(or `npm test`, the Node wrapper around all four)
|
||||
- Hook smoke test: `node hooks/scripts/session-start.mjs` (expects JSON on stdout)
|
||||
- State-line-guard smoke test: `echo '{"tool_name":"Write","tool_input":{"file_path":"/tmp/STATE.md","content":"x\n"}}' | node hooks/scripts/pre-state-line-guard.mjs; echo $?`
|
||||
(expects exit 0, no output — a one-line STATE.md is under the limit)
|
||||
- Board smoke test: `bash scripts/board.sh` (read-only, ~3s over the real tree)
|
||||
- Briefing smoke test: `bash scripts/board.sh --brief` (read-only, writes
|
||||
nothing). `brief-nightly.sh` DOES write — it overwrites `$CLAUDE_BRIEF_FILE`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue