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
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "repo-mailbox",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.23.0] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **`pre-state-line-guard.mjs`: a PreToolUse hook that enforces the documented
|
||||
~60-line STATE.md convention mechanically.** Dispatched by org-ops
|
||||
(20260814T144553Z) from an /insights analysis 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 asked
|
||||
sessions to keep it short, and nothing enforced it. org-ops' work order
|
||||
proposed a PostToolUse hook; confirmed against the official hooks docs that
|
||||
PostToolUse fires after the tool has already written the file and cannot
|
||||
block it — only PreToolUse can. The hook denies (stderr + exit 2, matching
|
||||
llm-security's `pre-write-pathguard.mjs` convention) a `Write` or `Edit`
|
||||
whose projected result exceeds 60 lines: for `Write` the projection is the
|
||||
call's own `content`; for `Edit` it is the current on-disk file with
|
||||
`old_string` replaced by `new_string` (every occurrence when `replace_all`
|
||||
is set, mirroring the real Edit tool), so the replace_all case is counted
|
||||
correctly rather than only the first occurrence. Wired into
|
||||
`hooks/hooks.json` as `PreToolUse` on `Write|Edit`. Pinned by
|
||||
`state-line-guard-selftest.sh` (16 checks). Suite total: coord 191 + board
|
||||
152 + route 69 + state-line-guard 16 = 428.
|
||||
|
||||
## [0.22.0] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
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`
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -10,11 +10,11 @@ Session A in repo X leaves a message for repo Y; the next session in repo Y gets
|
|||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ It lives here because it is the **writer** for the cost field the board already
|
|||
|
||||
Scoring is judgement and belongs to the skill; turning scores into a row is a lookup and costs no model calls. One deliberate side effect is worth more than the tokens saved: a next step that cannot be scored `known` or `partial`, with no design phase planned, is an **underspecified task description** — the answer is to rewrite the step, not to upgrade the model.
|
||||
|
||||
**CLI.** The engine is eight user-facing bash scripts in the plugin's `scripts/` directory (plus three selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
|
||||
**CLI.** The engine is eight user-facing bash scripts in the plugin's `scripts/` directory (plus four selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
|
||||
|
||||
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
|
||||
coord-send.sh --to <repo> --subject "<subject>" --fyi # a notice: no reply expected
|
||||
|
|
@ -128,6 +128,8 @@ The two consumers want the same information shaped differently, so each block ca
|
|||
|
||||
Driving a terminal from this plan deliberately lives **outside this repo**. That work is a version-pinned, undocumented composition on top of a preview API whose documented path is already broken upstream, and its blast radius reaches into other repos' running sessions. The dependency runs one way — the driver consumes the plan, the plan never knows a terminal exists — so if the terminal API breaks, the plan still prints and the operator still pastes.
|
||||
|
||||
**The STATE.md line guard (`pre-state-line-guard.mjs`) enforces the ~60-line convention that used to be prose only.** 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 — because nothing checked the file, only a convention description asked sessions to keep it short. The guard is a **PreToolUse** hook on `Write|Edit`, not PostToolUse: PostToolUse fires after the tool has already written the file and cannot undo it, so PreToolUse is the only event that can actually stop an oversized STATE.md before it lands. For `Write` the projected content is the tool call's own `content`; for `Edit` it is the current on-disk file with `old_string` replaced by `new_string` (every occurrence when `replace_all` is set, matching what the real Edit tool does) — a write projected past 60 lines is denied with the projected count in the message, everything else is left alone. It only ever looks at files named exactly `STATE.md`, at any depth.
|
||||
|
||||
## Security Model
|
||||
|
||||
Cross-repo message content is untrusted input by design:
|
||||
|
|
@ -156,15 +158,16 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
|
|||
## Requirements
|
||||
|
||||
- macOS or Linux with bash 3.2+ (the scripts are deliberately bash-3.2-safe and ASCII-only).
|
||||
- Node.js >= 18 for the SessionStart hook (zero npm dependencies).
|
||||
- Node.js >= 18 for the SessionStart and PreToolUse hooks (zero npm dependencies).
|
||||
- `git` is required to derive repo identity automatically. Without it, pass `--from`/`--repo` explicitly; the engine refuses to guess an identity from the working directory.
|
||||
|
||||
## Development
|
||||
|
||||
bash scripts/coord-selftest.sh # 191 checks against a throwaway mailbox
|
||||
bash scripts/board-selftest.sh # 152 checks against a throwaway repo tree
|
||||
bash scripts/route-selftest.sh # 69 checks, incl. the route->board round trip
|
||||
npm test # all three selftests via node --test
|
||||
bash scripts/coord-selftest.sh # 191 checks against a throwaway mailbox
|
||||
bash scripts/board-selftest.sh # 152 checks against a throwaway repo tree
|
||||
bash scripts/route-selftest.sh # 69 checks, incl. the route->board round trip
|
||||
bash scripts/state-line-guard-selftest.sh # 16 checks, incl. the Edit replace_all projection
|
||||
npm test # all four selftests via node --test
|
||||
|
||||
TDD is the house rule: every behavior change lands with a failing selftest check first.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-state-line-guard.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "repo-mailbox",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
214
scripts/state-line-guard-selftest.sh
Executable file
214
scripts/state-line-guard-selftest.sh
Executable file
|
|
@ -0,0 +1,214 @@
|
|||
#!/bin/bash
|
||||
# state-line-guard-selftest.sh - proves hooks/scripts/pre-state-line-guard.mjs
|
||||
# actually PREVENTS a Write/Edit that would push a STATE.md past the
|
||||
# documented ~60-line convention (global CLAUDE.md), and leaves everything
|
||||
# else alone. ASCII only, bash 3.2 safe.
|
||||
#
|
||||
# PreToolUse, not PostToolUse: the org-ops work order (20260814T144553Z) asked
|
||||
# for PostToolUse, but PostToolUse fires AFTER the tool already ran and cannot
|
||||
# undo the write (confirmed against the official hooks docs, 2026-08-14).
|
||||
# PreToolUse is the only event that can deny before the file lands. Blocking
|
||||
# convention (stderr + exit 2) matches llm-security's pre-write-pathguard.mjs,
|
||||
# the only other PreToolUse Write/Edit guard in this marketplace.
|
||||
set -u
|
||||
export LC_ALL=C
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
HOOK="$DIR/../hooks/scripts/pre-state-line-guard.mjs"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; }
|
||||
|
||||
# run_hook <json-file> -- sets HOOK_EXIT, HOOK_STDERR
|
||||
run_hook() {
|
||||
HOOK_STDERR="$(node "$HOOK" <"$1" 2>&1 1>/dev/null)"
|
||||
HOOK_EXIT=$?
|
||||
}
|
||||
|
||||
# payload <node-script-writing-JSON-to-stdout> -- returns path to a tmp file
|
||||
payload() {
|
||||
f="$TMPDIR/payload_$$_$RANDOM.json"
|
||||
node -e "$1" >"$f"
|
||||
printf '%s' "$f"
|
||||
}
|
||||
|
||||
echo "state-line-guard-selftest"
|
||||
|
||||
# --- 1. Write: line-count boundary ------------------------------------------
|
||||
|
||||
P="$(payload '
|
||||
const content = "x\n".repeat(60);
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/tmp/wherever/STATE.md", content }
|
||||
}));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Write: exactly 60 lines allows" $?
|
||||
|
||||
P="$(payload '
|
||||
const content = "x\n".repeat(61);
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/tmp/wherever/STATE.md", content }
|
||||
}));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 2 ]; check "Write: 61 lines denies (exit 2)" $?
|
||||
printf '%s' "$HOOK_STDERR" | grep -q "61"; check "Write: denial message names the projected count" $?
|
||||
printf '%s' "$HOOK_STDERR" | grep -q "60"; check "Write: denial message names the max" $?
|
||||
|
||||
# --- 2. Write: only STATE.md is guarded -------------------------------------
|
||||
|
||||
P="$(payload '
|
||||
const content = "x\n".repeat(500);
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/tmp/wherever/NOTES.md", content }
|
||||
}));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Write: non-STATE.md file allows regardless of size" $?
|
||||
|
||||
P="$(payload '
|
||||
const content = "x\n".repeat(500);
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/some/deep/plugin/subdir/STATE.md", content }
|
||||
}));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 2 ]; check "Write: STATE.md matched by basename at any depth" $?
|
||||
|
||||
# --- 3. Only Write/Edit are guarded ------------------------------------------
|
||||
|
||||
P="$(payload '
|
||||
const content = "x\n".repeat(500);
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: "Read",
|
||||
tool_input: { file_path: "/tmp/wherever/STATE.md", content }
|
||||
}));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Read: never guarded, regardless of content field" $?
|
||||
|
||||
# --- 4. Malformed / partial input never crashes the hook --------------------
|
||||
|
||||
P="$TMPDIR/malformed.json"
|
||||
printf 'not json at all {' >"$P"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "malformed JSON on stdin fails open" $?
|
||||
|
||||
P="$(payload '
|
||||
process.stdout.write(JSON.stringify({ tool_name: "Write", tool_input: {} }));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Write with no file_path fails open" $?
|
||||
|
||||
P="$(payload '
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path: "/tmp/wherever/STATE.md" }
|
||||
}));
|
||||
')"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Write with no content field fails open" $?
|
||||
|
||||
# --- 5. Edit: projects the post-edit file, not the diff ---------------------
|
||||
|
||||
FIXTURE="$TMPDIR/a"
|
||||
mkdir -p "$FIXTURE"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
fs.writeFileSync(process.argv[1], "x\n".repeat(55));
|
||||
' "$FIXTURE/STATE.md"
|
||||
|
||||
# 55 lines, replace one "x\n" occurrence with 6 "y\n" lines: net +5 -> 60, allow
|
||||
P="$(payload "
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '$FIXTURE/STATE.md',
|
||||
old_string: 'x\\n',
|
||||
new_string: 'y\\n'.repeat(6)
|
||||
}
|
||||
}));
|
||||
")"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: projected 60 lines allows" $?
|
||||
|
||||
# same fixture, net +6 -> 61, deny
|
||||
P="$(payload "
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '$FIXTURE/STATE.md',
|
||||
old_string: 'x\\n',
|
||||
new_string: 'y\\n'.repeat(7)
|
||||
}
|
||||
}));
|
||||
")"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 2 ]; check "Edit: projected 61 lines denies" $?
|
||||
printf '%s' "$HOOK_STDERR" | grep -q "61"; check "Edit: denial message names the projected count" $?
|
||||
|
||||
# --- 6. Edit: replace_all is honored, not just the first occurrence --------
|
||||
|
||||
FIXTURE2="$TMPDIR/b"
|
||||
mkdir -p "$FIXTURE2"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
fs.writeFileSync(process.argv[1], "a\n".repeat(50) + "b\n".repeat(5));
|
||||
' "$FIXTURE2/STATE.md"
|
||||
|
||||
# 55 lines total. replace_all doubles each of the 50 "a\n" occurrences
|
||||
# (a\n -> a\na\n): net +50 -> 105 lines. A hook that only replaced the FIRST
|
||||
# occurrence would project 56 lines and wrongly allow this.
|
||||
P="$(payload "
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '$FIXTURE2/STATE.md',
|
||||
old_string: 'a\\n',
|
||||
new_string: 'a\\na\\n',
|
||||
replace_all: true
|
||||
}
|
||||
}));
|
||||
")"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 2 ]; check "Edit: replace_all counts every occurrence, not just the first" $?
|
||||
|
||||
# --- 7. Edit: cases the hook must leave to the real tool --------------------
|
||||
|
||||
P="$(payload "
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '$FIXTURE/STATE.md',
|
||||
old_string: 'this string is not in the fixture',
|
||||
new_string: 'y\\n'.repeat(500)
|
||||
}
|
||||
}));
|
||||
")"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: old_string not found in file fails open" $?
|
||||
|
||||
P="$(payload "
|
||||
process.stdout.write(JSON.stringify({
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '$TMPDIR/does-not-exist/STATE.md',
|
||||
old_string: 'x',
|
||||
new_string: 'y\\n'.repeat(500)
|
||||
}
|
||||
}));
|
||||
")"
|
||||
run_hook "$P"
|
||||
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: nonexistent file fails open" $?
|
||||
|
||||
echo ""
|
||||
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
|
|
@ -20,7 +20,7 @@ description: >-
|
|||
user names no repo and no tool — choosing *between* repos is this skill. Not for
|
||||
"where were we" inside the current repo: that is this repo's own STATE.md,
|
||||
already injected at session start.
|
||||
version: "0.22.0"
|
||||
version: "0.23.0"
|
||||
---
|
||||
|
||||
# board — which repo deserves the next session
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ description: >-
|
|||
covers retiring a broadcast that has become wrong or obsolete: "retract that
|
||||
broadcast", "that announcement is outdated, pull it", "trekk tilbake kringkastingen",
|
||||
"den broadcasten er utdatert".
|
||||
version: "0.22.0"
|
||||
version: "0.23.0"
|
||||
---
|
||||
|
||||
# coord-send — natural-language front door for inter-repo messages
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ description: >-
|
|||
the operator names no model and no tool — choosing the model for the next
|
||||
session IS this skill. Not for choosing which REPO gets the next session:
|
||||
that is the `board` skill.
|
||||
version: "0.22.0"
|
||||
version: "0.23.0"
|
||||
---
|
||||
|
||||
# route — what the next session should run with
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ test('route bash selftest passes', () => {
|
|||
execFileSync('bash', [join(root, 'scripts', 'route-selftest.sh')], { encoding: 'utf8' });
|
||||
});
|
||||
|
||||
// pre-state-line-guard.mjs is a PreToolUse hook, so like session-start.mjs it
|
||||
// must be proven from the plugin root: the hook config resolves it through
|
||||
// CLAUDE_PLUGIN_ROOT, and a guard proven only elsewhere is unproven on the
|
||||
// path production actually runs.
|
||||
test('state-line-guard bash selftest passes', () => {
|
||||
execFileSync('bash', [join(root, 'scripts', 'state-line-guard-selftest.sh')], { encoding: 'utf8' });
|
||||
});
|
||||
|
||||
// The engine refuses to invent an identity from the cwd, but the hook is the
|
||||
// FOURTH place repo identity is derived, and a rule enforced in three of four
|
||||
// places is not a rule: as long as the hook resolved the name itself and passed
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue