claude-harness/hooks/harness_target.py

746 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 # [(путь нового worktree, ветка)] из 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 <x> без «--»: ветка или путь — неизвестно; защищённые имена и «-» — точно смена ветки,
# переменная — ветка не определена (последующие 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.wts)
elif sub == "push":
_push(rest, path, add)
def _worktree(rest, repo, wts):
"""git worktree add <путь> -b|-B <ветка>: (путь нового worktree, ветка) для целей той же команды."""
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 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
wts.append((_join(repo, pos[0][0]), branch))
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 = [] # [(реальный путь нового worktree, ветка)] из git worktree add -b|-B в этой же команде
if tool == "Bash":
targets = parse(ti.get("command") or "", cwd, _wts=wts)
wts = [(os.path.realpath(p), b) for p, b 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 t in 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
wt = next((b for p, b in reversed(wts) if real == p or real.startswith(p + os.sep)), None)
if wt is not None: # цель — в worktree, который эта же команда создаёт на новой ветке (#63)
branch = wt
elif root in over:
branch = over[root]
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))