feat(hooks): require-plan по репозиторию правимого файла (#6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andrey.epifantsev 2026-10-16 01:55:05 +04:00
parent 87f2c956aa
commit 1620d1e806
2 changed files with 41 additions and 32 deletions

View File

@ -1,33 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# PreToolUse hook: edits in code dirs (any src/ by default, or .claude/code-paths) require a plan for this branch or a .bounded marker. # PreToolUse hook (Edit/Write/MultiEdit): правки в каталогах кода (любой src/ или .claude/code-paths) — только при
set -euo pipefail # плане для ветки или файле .bounded. Репозиторий — тот, где лежит файл; логика в harness_target.py рядом.
input=$(cat) py="${0%/*}/harness_target.py"
read -r cwd fpath < <(printf '%s' "$input" | python3 -c ' command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
import sys,json python3 "$py" require-plan
d=json.load(sys.stdin); ti=d.get("tool_input",{}) or {} [ $? -eq 2 ] && exit 2
print(d.get("cwd",""), ti.get("file_path") or ti.get("path") or "")' 2>/dev/null || echo " ") exit 0
[ -n "$cwd" ] && [ -n "$fpath" ] || exit 0
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$root" ] || exit 0
# Защищаемые пути. По умолчанию — любой каталог src/ на любой глубине (src/, api/src/, packages/*/src/).
# Проект может переопределить списком префиксов (относительно корня, по одному на строку) в .claude/code-paths.
rel=${fpath#"$root"/}
protected=0
if [ -f "$root/.claude/code-paths" ]; then
while IFS= read -r p; do
p=${p%%#*}; p=${p## }; p=${p%% }; [ -n "$p" ] || continue
case "$rel" in "${p%/}"/*) protected=1; break ;; esac
done < "$root/.claude/code-paths"
else
case "/$rel" in */src/*) protected=1 ;; esac
fi
[ "$protected" = 1 ] || exit 0
[ -f "$root/.bounded" ] && exit 0
branch=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
if ls "$root"/docs/superpowers/plans/*.md >/dev/null 2>&1; then
if [ -z "$branch" ] || grep -lq -- "$branch" "$root"/docs/superpowers/plans/*.md 2>/dev/null; then exit 0; fi
# any plan exists but none names this branch: still allow if only one plan (single-feature repo)
[ "$(ls "$root"/docs/superpowers/plans/*.md | wc -l)" -eq 1 ] && exit 0
fi
echo "BLOCKED: editing $fpath without a plan. Run superpowers:brainstorming; for a bounded change create '$root/.bounded' first." >&2
exit 2

View File

@ -0,0 +1,34 @@
# require-plan.sh: корень, ветка, code-paths, .bounded и планы — из репозитория правимого файла (спека hook-guards).
H="$ROOT/hooks/require-plan.sh"
S="$TMP/rp"; mkdir -p "$S/out"
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
mk "$S/a"; touch "$S/a/.bounded" # A: есть .bounded
mk "$S/b" # B: ни плана, ни .bounded
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x; touch "$S/wt/.bounded"
mk "$S/c"; mkdir -p "$S/c/.claude"; printf 'hooks/ # код\n\nskills/\n' > "$S/c/.claude/code-paths"
mk "$S/p2" feat/6-x; mkdir -p "$S/p2/docs/superpowers/plans"
echo "Ветка: feat/6-x" > "$S/p2/docs/superpowers/plans/2026-10-16-a.md"; echo "другое" > "$S/p2/docs/superpowers/plans/2026-10-11-b.md"
mk "$S/p0" feat/7-y; mkdir -p "$S/p0/docs/superpowers/plans"
echo "Ветка: feat/6-x" > "$S/p0/docs/superpowers/plans/a.md"; echo "другое" > "$S/p0/docs/superpowers/plans/b.md"
mk "$S/p1" feat/7-y; mkdir -p "$S/p1/docs/superpowers/plans"; echo "x" > "$S/p1/docs/superpowers/plans/a.md"
mk "$S/pd"; mkdir -p "$S/pd/docs/superpowers/plans"; echo a > "$S/pd/docs/superpowers/plans/a.md"; echo b > "$S/pd/docs/superpowers/plans/b.md"
git -C "$S/pd" checkout -q --detach
j(){ python3 -c 'import json,sys; print(json.dumps({"cwd":sys.argv[1],"tool_name":"Edit","tool_input":{"file_path":sys.argv[2]}}))' "$@"; }
rc(){ j "$@" | bash "$H" 2>/dev/null; echo $?; }
assert_eq "файл в B без плана при cwd в A с .bounded" "$(rc "$S/a" "$S/b/src/a.py")" 2
assert_eq "файл в worktree с .bounded при cwd на main" "$(rc "$S/f" "$S/wt/src/a.py")" 0
assert_eq "src на глубине без плана" "$(rc "$S/b" "$S/b/api/src/a.py")" 2
assert_eq "не код — пропуск" "$(rc "$S/b" "$S/b/docs/a.md")" 0
assert_eq "вне репозитория — пропуск" "$(rc "$S/b" "$S/out/src/a.py")" 0
assert_eq "code-paths: hooks/ без плана" "$(rc "$S/a" "$S/c/hooks/x.sh")" 2
assert_eq "code-paths: src/ не защищён" "$(rc "$S/c" "$S/c/src/a.py")" 0
assert_eq "два плана, один с веткой" "$(rc "$S/p2" "$S/p2/src/a.py")" 0
assert_eq "два плана без ветки" "$(rc "$S/p0" "$S/p0/src/a.py")" 2
assert_eq "один план — пропуск" "$(rc "$S/p1" "$S/p1/src/a.py")" 0
assert_eq "detached HEAD при планах — пропуск" "$(rc "$S/pd" "$S/pd/src/a.py")" 0
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$H" 2>/dev/null; echo $?)" 0
assert_eq "без file_path — пропуск" "$(printf '{"cwd":"%s","tool_input":{}}' "$S/b" | bash "$H" 2>/dev/null; echo $?)" 0
m=$(j "$S/a" "$S/b/src/a.py" | bash "$H" 2>&1 >/dev/null)
assert_grep "сообщение: префикс и путь" '^BLOCKED \[require-plan\]: правка src/a.py' <(printf '%s' "$m")
assert_grep "сообщение: .bounded репозитория файла" "$(basename "$S")/b/\.bounded" <(printf '%s' "$m")
assert_grep "settings.hooks.json регистрирует require-plan" 'require-plan\.sh' "$ROOT/hooks/settings.hooks.json"