From 1620d1e8060cb00c88f4baaae954f1ea26d39e79 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Fri, 16 Oct 2026 01:55:05 +0400 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20require-plan=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BC=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- hooks/require-plan.sh | 39 ++++++--------------------------- tests/test-hook-require-plan.sh | 34 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 32 deletions(-) create mode 100644 tests/test-hook-require-plan.sh diff --git a/hooks/require-plan.sh b/hooks/require-plan.sh index 3d099f9..7c70ad2 100755 --- a/hooks/require-plan.sh +++ b/hooks/require-plan.sh @@ -1,33 +1,8 @@ #!/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. -set -euo pipefail -input=$(cat) -read -r cwd fpath < <(printf '%s' "$input" | python3 -c ' -import sys,json -d=json.load(sys.stdin); ti=d.get("tool_input",{}) or {} -print(d.get("cwd",""), ti.get("file_path") or ti.get("path") or "")' 2>/dev/null || echo " ") -[ -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 +# PreToolUse hook (Edit/Write/MultiEdit): правки в каталогах кода (любой src/ или .claude/code-paths) — только при +# плане для ветки или файле .bounded. Репозиторий — тот, где лежит файл; логика в harness_target.py рядом. +py="${0%/*}/harness_target.py" +command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0 +python3 "$py" require-plan +[ $? -eq 2 ] && exit 2 +exit 0 diff --git a/tests/test-hook-require-plan.sh b/tests/test-hook-require-plan.sh new file mode 100644 index 0000000..4760365 --- /dev/null +++ b/tests/test-hook-require-plan.sh @@ -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"