#!/usr/bin/env python3 """claude-harness: общее ядро хуков guard-main и require-plan — решение по цели записи, а не по cwd. Обёртки hooks/guard-main.sh и hooks/require-plan.sh передают сюда JSON вызова из stdin: python3 harness_target.py guard-main|require-plan < hook.json → exit 0 (пропустить) | 2 (блок, причина в stderr) Отладка разбора без файловой системы: python3 harness_target.py targets '<команда>' [cwd] → по строке на цель: «kind path op» Любой сбой разбора или вызова git — пропуск (exit 0). Только stdlib, Python 3.8+. Спека: openspec/specs/hook-guards/spec.md. """ import glob import json import os import re import subprocess import sys from collections import namedtuple # kind: file | git | switch; path: абсолютный путь или None (не разрешился); op: что делает команда. # Для switch path — каталог репозитория, op — новая ветка (None — не определена). Target = namedtuple("Target", "kind path op") PROTECTED = ("main", "master", "") # "" — detached HEAD SEPS = (";", "&&", "||", "|", "|&", "&", ";;", "\n", "(", ")") OPS = sorted(["&&", "||", ";;", ";", "|&", "|", "&>>", "&>", "&", "(", ")", ">>", ">|", ">&", ">", "<<<", "<<-", "<<", "<&", "<>", "<"], key=len, reverse=True) OP_CHARS = set(";&|()<>") WRITE_REDIRS = (">", ">>", ">|", "&>", "&>>") KEYWORDS = ("if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}") SKIP_HEADS = ("for", "case", "select", "function") PREFIXES = ("sudo", "env", "nohup", "command", "exec", "nice", "time", "builtin", "xargs", "timeout", "ionice", "stdbuf") # Опции префиксов, которые забирают следующее слово как значение (иначе значение примут за голову команды). PREFIX_VALUED = { "sudo": ("-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "--user", "--group", "--chdir"), "nice": ("-n", "--adjustment"), "env": ("-u", "--unset", "-S", "--split-string"), "timeout": ("-s", "--signal", "-k", "--kill-after"), "ionice": ("-c", "--class", "-n", "--classdata", "-p", "--pid", "-P", "--pgid", "-u", "--uid"), "stdbuf": ("-i", "--input", "-o", "--output", "-e", "--error"), "xargs": ("-I", "-n", "-P", "-L", "-d", "-a", "-E", "-s", "--max-args", "--max-procs", "--max-lines", "--delimiter", "--arg-file", "--replace"), } NOFOLLOW = ("rm", "rmdir", "unlink", "ln", "mv") # меняют саму ссылку, а не её цель CUR_BRANCH = re.compile(r"(\$\(|`)\s*git\s+(branch\s+--show-current|rev-parse\s+--abbrev-ref\s+HEAD|" r"symbolic-ref\s+(-q\s+)?--short\s+HEAD)\s*(\)|`)") ALL_PATHS = ("tee", "touch", "mkdir", "rm", "rmdir", "truncate", "unlink", "mv", "shred") DEST = ("cp", "install", "ln", "rsync") GIT_MUT = ("commit", "merge", "rebase", "cherry-pick", "revert", "reset", "am", "add", "rm", "mv", "restore") ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=") FD = re.compile(r"\d+(?=[<>])") VAR = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#*@-]") def _home(): return os.environ.get("HOME", "") # ---------- лексер ---------- class Lexer: """Токены команды: ("w", текст, динамическое?) и ("op", оператор). Тело heredoc пропускается. Динамическое слово содержит $VAR, $(…) или `…` — в тексте остаётся сырой вид подстановки. ~, $HOME и ${HOME} раскрываются по переменной окружения HOME. """ def __init__(self, s, i=0): self.s, self.i, self.heredocs = s, i, [] def tokens(self, until_paren=False): s, out, depth = self.s, [], 0 while self.i < len(s): c = s[self.i] if c in " \t": self.i += 1 elif s.startswith("\\\n", self.i): self.i += 2 elif c == "\n": self.i += 1 out.append(("op", "\n")) self._skip_heredocs() elif c == "#": j = s.find("\n", self.i) self.i = len(s) if j < 0 else j elif FD.match(s, self.i): m = FD.match(s, self.i) self.i = m.end() op = self._op() out.append(("op", m.group() + op)) elif c in OP_CHARS: op = self._op() if until_paren and op == "(": depth += 1 elif until_paren and op == ")": if depth == 0: return out depth -= 1 out.append(("op", op)) if op in ("<<", "<<-"): w = self._word() if w is not None: self.heredocs.append((w[0], op == "<<-")) out.append(("w",) + w) else: out.append(("w",) + self._word()) if until_paren: raise ValueError("unclosed $(") return out def _op(self): op = next(o for o in OPS if self.s.startswith(o, self.i)) self.i += len(op) return op def _skip_heredocs(self): s = self.s for delim, strip_tabs in self.heredocs: while self.i < len(s): j = s.find("\n", self.i) line = s[self.i:len(s) if j < 0 else j] self.i = len(s) if j < 0 else j + 1 if (line.lstrip("\t") if strip_tabs else line) == delim: break self.heredocs = [] def _word(self): s, n = self.s, len(self.s) while self.i < n and s[self.i] in " \t": self.i += 1 start, buf, dyn = self.i, [], False while self.i < n: c = s[self.i] if c in " \t\n" or c in OP_CHARS: break if c == "\\": if self.i + 1 < n and s[self.i + 1] != "\n": buf.append(s[self.i + 1]) self.i += 2 elif c == "'": j = s.find("'", self.i + 1) if j < 0: raise ValueError("unclosed '") buf.append(s[self.i + 1:j]) self.i = j + 1 elif c == '"': self.i += 1 while True: if self.i >= n: raise ValueError('unclosed "') c = s[self.i] if c == '"': self.i += 1 break if c == "\\" and self.i + 1 < n and s[self.i + 1] in '"\\$`\n': if s[self.i + 1] != "\n": buf.append(s[self.i + 1]) self.i += 2 elif c in "$`": t, d = self._dollar() buf.append(t) dyn = dyn or d else: buf.append(c) self.i += 1 elif c in "$`": t, d = self._dollar() buf.append(t) dyn = dyn or d elif c == "~" and self.i == start and (self.i + 1 >= n or s[self.i + 1] in "/ \t\n;&|()<>"): buf.append(_home()) self.i += 1 else: buf.append(c) self.i += 1 if self.i == start: return None return "".join(buf), dyn def _dollar(self): """$…/`…` с позиции i → (текст, динамическое?).""" s, i = self.s, self.i if s[i] == "`": j = i + 1 while j < len(s) and s[j] != "`": j += 2 if s[j] == "\\" else 1 if j >= len(s): raise ValueError("unclosed `") self.i = j + 1 return s[i:j + 1], True if s.startswith("$(", i): # подстановка и арифметика $((…)): пропуск до парной скобки sub = Lexer(s, i + 2) sub.tokens(until_paren=True) self.i = sub.i return s[i:self.i], True if s.startswith("${", i): j = s.find("}", i) if j < 0: raise ValueError("unclosed ${") self.i = j + 1 return (_home(), False) if s[i + 2:j] == "HOME" else (s[i:j + 1], True) m = VAR.match(s, i + 1) if not m: self.i = i + 1 return "$", False self.i = m.end() return (_home(), False) if m.group() == "HOME" else (s[i:m.end()], True) # ---------- разбор ---------- def _join(cur, p): """Путь из команды Bash: у glob — каталог до первого метасимвола, дальше как _abs.""" if p is None: return None m = re.search(r"[*?\[]", p) if m: p = os.path.dirname(p[:m.start()]) or "." return _abs(cur, p) def _abs(cur, p): """Абсолютный нормализованный путь; None, если относительный и cwd неизвестен. Литерал, без glob — для file_path инструментов (src/[slug].ts — имя файла, а не шаблон).""" if p is None: return None if os.path.isabs(p): return os.path.normpath(p) if not cur: return None return os.path.normpath(os.path.join(cur, p)) def _path(w, cur): text, dyn = w return None if dyn else _join(cur, text) def _operands(args, valued=()): """Аргументы-операнды без опций; опции из valued забирают следующее слово как значение.""" out, k, opts_done = [], 0, False while k < len(args): t = args[k][0] if not opts_done and t == "--": opts_done = True elif not opts_done and t.startswith("-") and t != "-": if t in valued: k += 1 else: out.append(args[k]) k += 1 return out def _branch(w): """Имя ветки из слова. Для динамического — '<статический префикс>*', если префикс не может стать защищённой веткой, иначе None (не определена).""" text, dyn = w if not dyn: return text cut = min([i for i in (text.find("$"), text.find("`")) if i >= 0] or [len(text)]) prefix = text[:cut] if prefix and not any(p.startswith(prefix) for p in ("main", "master", "HEAD", "refs/heads/main", "refs/heads/master")): return prefix + "*" return None class _State: def __init__(self, cwd, out, depth, wts): self.cur, self.out, self.depth, self.dirs = cwd, out, depth, [] self.wts = wts # [(путь, ветка, позиция в out)] из git worktree add -b|-B в этой же команде def parse(cmd, cwd, _out=None, _depth=0, _wts=None): """Цели записи команды Bash в порядке выполнения. Чистая функция: файловую систему не читает. cwd — каталог, от которого считаются относительные пути (None — неизвестен). Новые worktree из git worktree add -b попадают в _wts (путь → ветка + позиция): decide_guard оценивает по ним только цели, идущие в команде после создания worktree.""" out = [] if _out is None else _out if _depth > 5: return out st = _State(cwd or None, out, _depth, [] if _wts is None else _wts) toks = Lexer(cmd).tokens() stack, words, redirs, prev = [], [], [], None i = 0 while i <= len(toks): t = toks[i] if i < len(toks) else ("op", "\n") if t[0] == "w": words.append((t[1], t[2])) i += 1 continue op = t[1] if op in SEPS: if words or redirs: new = _simple(words, redirs, st) if new is not _SAME and op not in ("|", "|&") and prev not in ("|", "|&"): st.cur = new words, redirs = [], [] if op == "(": stack.append(st.cur) elif op == ")" and stack: st.cur = stack.pop() prev = op i += 1 continue nxt = toks[i + 1] if i + 1 < len(toks) and toks[i + 1][0] == "w" else None redirs.append((op, (nxt[1], nxt[2]) if nxt else None)) i += 2 if nxt else 1 return out _SAME = object() def _simple(words, redirs, st): """Одна простая команда: цели в st.out; возвращает новый каталог для cd/pushd/popd или _SAME.""" cur, out = st.cur, st.out for op, w in redirs: o = op.lstrip("0123456789") if w is None: continue if o in WRITE_REDIRS or (o == ">&" and not re.fullmatch(r"\d+-?|-", w[0])): if not w[1] and w[0].startswith("/dev/"): continue out.append(Target("file", _path(w, cur), op)) ws = list(words) while ws and ws[0][0] in KEYWORDS and not ws[0][1]: ws.pop(0) if not ws or ws[0][0] in SKIP_HEADS: return _SAME xargs = False while ws: h = ws[0][0] if ASSIGN.match(h): ws.pop(0) elif h in PREFIXES and not ws[0][1]: xargs = xargs or h == "xargs" ws.pop(0) valued = PREFIX_VALUED.get(h, ()) while ws: t = ws[0][0] if h == "env" and ASSIGN.match(t): ws.pop(0) elif h == "env" and (t in ("-C", "--chdir") or t.startswith("--chdir=")): ws.pop(0) if "=" in t: v = (t.split("=", 1)[1], False) else: v = ws.pop(0) if ws else ("", True) cur = None if v[1] else _abs(cur, v[0]) # env -C DIR: каталог только этой команды elif t.startswith("-") and t != "-": ws.pop(0) if t in valued and ws: ws.pop(0) else: break if h == "timeout" and ws: ws.pop(0) # длительность else: break if not ws or ws[0][1]: return _SAME # пусто или голова — переменная: неизвестная команда head, args = os.path.basename(ws[0][0]), ws[1:] def file_(w, op): out.append(Target("file", None if xargs else _path(w, cur), op)) if head in ("cd", "pushd"): a = _operands(args) if not a: new = _home() or None else: new = None if a[0][1] or a[0][0] == "-" else _join(cur, a[0][0]) if head == "pushd": st.dirs.append(cur) return new if head == "popd": return st.dirs.pop() if st.dirs else None if head in ("sh", "bash", "zsh", "dash"): for k, (t, d) in enumerate(args): if t.startswith("-") and not t.startswith("--") and "c" in t[1:]: if k + 1 < len(args) and not args[k + 1][1]: parse(args[k + 1][0], cur, out, st.depth + 1, st.wts) break return _SAME if head == "eval": parse(" ".join(t for t, d in args), cur, out, st.depth + 1, st.wts) return _SAME if xargs and head in ALL_PATHS + DEST + ("chmod", "chown", "chgrp", "sed", "perl"): out.append(Target("file", None, head)) # пути придут из stdin return _SAME if head in ALL_PATHS: for w in _operands(args, ("-s", "--size") if head == "truncate" else ()): file_(w, head) elif head in ("chmod", "chown", "chgrp"): for w in _operands(args)[1:]: file_(w, head) elif head in DEST: tdir = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-t"), None) tdir = tdir or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--target-directory=")), None) ops = _operands(args, ("-t", "-S", "--suffix")) if tdir: dst = tdir elif len(ops) >= 2: dst = ops[-1] elif head == "ln" and ops: dst = (".", False) # ln -s цель — ссылка в текущем каталоге else: dst = None if dst is not None and not (head == "rsync" and re.match(r"[^/]*:", dst[0])): file_(dst, head) elif head == "sed": inplace = any(t == "-i" or t.startswith("-i") or t.startswith("--in-place") or re.fullmatch(r"-[A-Za-z]*i[A-Za-z]*", t) for t, d in args) if inplace: rest = [w for k, w in enumerate(args) if not (w[0] == "" and k > 0 and args[k - 1][0] == "-i")] # BSD: sed -i '' … has_script = any(t in ("-e", "-f", "--expression", "--file") or t.startswith("--expression=") for t, d in rest) ops = _operands(rest, ("-e", "-f", "-l", "--expression", "--file")) for w in (ops if has_script else ops[1:]): file_(w, "sed -i") elif head == "perl": if any(re.fullmatch(r"-[A-Za-z]*i\S*", t) for t, d in args): has_script = any(re.fullmatch(r"-[A-Za-z]*[eE]", t) for t, d in args) ops = _operands(args, ("-e", "-E", "-pe", "-ne", "-pie", "-npe")) for w in (ops if has_script else ops[1:]): file_(w, "perl -i") elif head == "dd": for t, d in args: if t.startswith("of="): file_((t[3:], d), "dd") elif head == "tar": first = args[0][0] if args else "" if any(t in ("--extract", "--get") or (re.fullmatch(r"-[A-Za-z]+", t) and "x" in t) for t, d in args) \ or (re.fullmatch(r"[A-Za-z]+", first) and "x" in first): dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-C"), None) dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None) file_(dst or (".", False), "tar -x") elif head == "unzip": if not any(t in ("-l", "-t", "-v", "-Z", "-p") for t, d in args): dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None) file_(dst or (".", False), "unzip") elif head == "patch": if "--dry-run" not in [t for t, d in args]: dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None) dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None) file_(dst or (".", False), "patch") elif head in ("npm", "yarn", "pnpm"): if not any(t in ("-g", "--global") for t, d in args): ops = _operands(args) sub = ops[0][0] if ops else ("install" if head == "yarn" else "") if sub in ("install", "i", "ci", "add", "init"): out.append(Target("file", cur, head + " " + sub)) elif head == "openspec": ops = _operands(args) if ops and ops[0][0] in ("archive", "new", "init", "update"): out.append(Target("file", cur, "openspec " + ops[0][0])) elif head == "git": _git(args, st, cur) return _SAME def _git(args, st, cur): repo, k, unknown = cur, 0, False while k < len(args): t, d = args[k] if t == "-C" and k + 1 < len(args): v = args[k + 1] if v[1]: unknown = True else: repo = _join(repo, v[0]) k += 2 elif t in ("-c", "--namespace", "--exec-path", "--config-env") and k + 1 < len(args): k += 2 elif t.startswith("--git-dir") or t.startswith("--work-tree"): unknown = True k += 1 if "=" in t else 2 elif t.startswith("-"): k += 1 else: break if k >= len(args): return sub, rest = args[k][0], args[k + 1:] path = None if unknown or repo is None else repo words = [t for t, d in rest] add = st.out.append if sub in GIT_MUT: add(Target("git", path, sub)) elif sub == "apply": if not any(w in ("--check", "--stat", "--numstat", "--summary") for w in words): add(Target("git", path, "apply")) elif sub == "stash": s2 = next((w for w in words if not w.startswith("-")), "push") if s2 not in ("list", "show"): add(Target("git", path, "stash")) elif sub == "checkout": flag = next((j for j, w in enumerate(words) if w in ("-b", "-B", "--orphan")), None) if flag is not None and flag + 1 < len(rest): add(Target("switch", path, _branch(rest[flag + 1]))) elif "--" in words and words.index("--") < len(words) - 1: add(Target("git", path, "checkout --")) else: # checkout без «--»: ветка или путь — неизвестно; защищённые имена и «-» — точно смена ветки, # переменная — ветка не определена (последующие git-мутации блокируются) ops = _operands(rest) if ops and (ops[0][1] or ops[0][0] in ("main", "master", "-")): add(Target("switch", path, None if ops[0][1] or ops[0][0] == "-" else ops[0][0])) elif sub == "switch": name, found = None, False for j, w in enumerate(words): if w in ("-c", "-C", "--create", "--force-create", "--orphan") and j + 1 < len(rest): name, found = _branch(rest[j + 1]), True break if w in ("-d", "--detach"): name, found = "", True break if not found: ops = _operands(rest) if ops: name, found = (None if ops[0] == ("-", False) else _branch(ops[0])), True # «-» — предыдущая if found: add(Target("switch", path, name)) elif sub == "worktree": _worktree(rest, path, st) elif sub == "push": _push(rest, path, add) def _worktree(rest, repo, st): """git worktree add <путь> -b|-B <ветка>: (путь, ветка, позиция в целях) для последующих целей команды.""" if not rest or rest[0][0] != "add": return branch, pos, k = None, [], 1 while k < len(rest): t = rest[k][0] if t in ("-b", "-B") and k + 1 < len(rest): branch = _branch(rest[k + 1]) k += 2 continue if t in ("--reason",) and k + 1 < len(rest): k += 2 continue if not t.startswith("-"): pos.append(rest[k]) k += 1 if branch is None or not pos or pos[0][1] or repo is None: return st.wts.append((_join(repo, pos[0][0]), branch, len(st.out))) def _push(rest, path, add): delete, everything, tags, pos, k = False, False, False, [], 0 while k < len(rest): t = rest[k][0] if t in ("--delete", "-d"): delete = True elif t == "--tags": tags = True elif t in ("--all", "--mirror", "--branches"): everything = True elif t in ("--repo", "-o", "--push-option", "--receive-pack", "--exec"): k += 1 elif not t.startswith("-"): pos.append(rest[k]) k += 1 if everything: add(Target("git", path, "push --all")) return refs = pos[1:] if not refs: if not delete and not tags: # --tags без refspec пушит только теги add(Target("git", path, "push")) return for text, dyn in refs: src, colon, dst = text.lstrip("+").partition(":") if delete or not colon: dst = src if dst.startswith("refs/heads/"): dst = dst[len("refs/heads/"):] if dyn and CUR_BRANCH.fullmatch(dst): add(Target("git", path, "push")) # цель — $(git branch --show-current) и т. п., то есть текущая ветка continue if dst == "HEAD" and not delete and not colon: add(Target("git", path, "push")) continue name = _branch((dst, dyn)) if name is None: add(Target("git", path, "push?")) elif name in ("main", "master"): add(Target("git", path, "push>" + name)) # ---------- файловая система и вердикт ---------- _cache = {} def _git_out(d, *args): env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} r = subprocess.run(["git", "-C", d] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, universal_newlines=True, timeout=5, env=env) return r.stdout.strip() if r.returncode == 0 else None def resolve(path, follow=True): """(корень, ветка, реальный путь) git-репозитория, в котором лежит path, или None — вне репозитория. Ветка '' — detached HEAD. Симлинки раскрываются до поиска (в том числе висячие: запись создаст файл у цели ссылки), затем берётся ближайший существующий предок. follow=False — для команд, меняющих саму ссылку (rm, ln, mv): раскрывается только каталог, последний элемент пути остаётся как есть.""" if follow: real = p = os.path.realpath(path) else: p = os.path.realpath(os.path.dirname(path)) # поиск от каталога: exists/isdir разыменовали бы ссылку real = os.path.join(p, os.path.basename(path)) while not os.path.exists(p): parent = os.path.dirname(p) if parent == p: return None p = parent d = p if os.path.isdir(p) else os.path.dirname(p) if d not in _cache: root = _git_out(d, "rev-parse", "--show-toplevel") branch = _git_out(root, "branch", "--show-current") if root else None _cache[d] = (root, branch or "") if root else None hit = _cache[d] if not hit: return None return hit[0], hit[1], real def _label(branch): return "detached HEAD" if branch == "" else branch WT_HINT = "Работай в worktree на ветке (superpowers:using-git-worktrees)." def decide_guard(tool, ti, cwd): """Сообщение блокировки guard-main или None.""" cwd = cwd or None wts = [] # [(реальный путь, ветка, позиция в targets)] из git worktree add -b|-B в этой же команде if tool == "Bash": targets = parse(ti.get("command") or "", cwd, _wts=wts) wts = [(os.path.realpath(p), b, pos) for p, b, pos in wts] elif tool in ("Edit", "Write", "MultiEdit"): fp = ti.get("file_path") or "" targets = [Target("file", _abs(cwd, fp), tool)] if fp else [] else: return None over = {} # корень → ветка после git switch/checkout -b в этой же команде (None — не определена) for idx, t in enumerate(targets): if t.kind == "switch": r = resolve(t.path) if t.path else None if r: over[r[0]] = t.op continue if t.path is None: if t.kind == "git": if t.op == "push?": return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно." return ("BLOCKED [guard-main]: git %s — репозиторий не определён (переменная в -C, --git-dir или " "--work-tree). Укажи git -C <абсолютный путь>." % t.op) continue r = resolve(t.path, follow=not (t.kind == "file" and t.op in NOFOLLOW)) if not r: continue root, branch, real = r if root in over: # явный switch/checkout в этой же команде выигрывает у пары worktree (R-1) branch = over[root] else: wt = next((b for p, b, pos in reversed(wts) if idx >= pos and not (p == root or root.startswith(p + os.sep)) and (real == p or real.startswith(p + os.sep))), None) if wt is not None: # цель идёт после worktree add и лежит в нём — ветка из add (#63) branch = wt if t.kind == "git": if t.op.startswith("push>") or t.op == "push --all": return "BLOCKED [guard-main]: git %s — репозиторий %s. В main только через PR." % ( t.op.replace(">", " в "), root) if t.op == "push?": return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно." if branch is None or branch in PROTECTED: return "BLOCKED [guard-main]: git %s — репозиторий %s на %s. В main только через PR. %s" % ( t.op, root, "неизвестной ветке" if branch is None else _label(branch), WT_HINT) elif branch in PROTECTED: return "BLOCKED [guard-main]: запись в %s — репозиторий %s на %s. %s" % ( os.path.relpath(real, root), root, _label(branch), WT_HINT) return None def decide_plan(fp, cwd): """Сообщение блокировки require-plan или None.""" path = _abs(cwd or None, fp) if fp else None r = resolve(path) if path else None if not r: return None root, branch, real = r rel = os.path.relpath(real, root) cp = os.path.join(root, ".claude", "code-paths") if os.path.isfile(cp): prefixes = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")] protected = any(p and rel.startswith(p + "/") for p in prefixes) else: protected = "/src/" in "/" + rel if not protected or os.path.exists(os.path.join(root, ".bounded")): return None plans = sorted(glob.glob(os.path.join(root, "docs", "superpowers", "plans", "*.md"))) if plans: if branch == "" or len(plans) == 1: return None for p in plans: if branch in open(p, encoding="utf-8", errors="replace").read(): return None return ("BLOCKED [require-plan]: правка %s без плана для ветки %s. Запусти superpowers:brainstorming; " "для bounded-задачи создай %s/.bounded." % (rel, _label(branch), root)) def main(argv): mode = argv[1] if len(argv) > 1 else "" if mode == "targets": try: ts = parse(argv[2] if len(argv) > 2 else "", argv[3] if len(argv) > 3 else os.getcwd()) except Exception as e: print("error %s" % e) return 1 for t in ts: print(t.kind, "-" if t.path is None else t.path, "-" if t.op is None else t.op) return 0 try: d = json.loads(sys.stdin.read()) ti = d.get("tool_input") or {} if mode == "guard-main": msg = decide_guard(d.get("tool_name") or "", ti, d.get("cwd") or "") elif mode == "require-plan": msg = decide_plan(ti.get("file_path") or ti.get("path") or "", d.get("cwd") or "") else: msg = None except Exception: return 0 if msg: sys.stderr.write(msg + "\n") return 2 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))