Files
gplan-2/.zcode/hooks/worktree-guard.sh
T

196 lines
10 KiB
Bash
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 bash
# agent 侧守卫(PreToolUse,本机件不入 git;2026-10-31 车道恢复随动——worktree 铁律回归)
# 拦四路:①Edit/Write 族写主工作区(主工作区只作合并落点;豁免 .zcode/ 与 .qoder/)
# ②Bash 在主工作区一切 git commit 一律拦(直提走 worktree;冲突/红态续合=git merge --abort → 回 worktree → 重新 git merge --no-ff,合并收尾提交不再例外)
# ③Bash 在主工作区 git commit 带 --no-verify/-n(agent 禁用,旗标仅属用户)
# (合回 merge 零机检,全量验证归合回后本地 CI:tools/hooks/post-merge)
# ④规则闸:Edit/Write 写 design/、game/,或 Bash 以文件触碰动词+路径命中两域时,
# 本会话读取记录须含对应规约 read: 标记(PostToolUse 规约投递闸读规约本体时记入;
# 记录文件不存在=投递闸未运行→本闸不启用;子代理不经守卫层,2026-10-26 实测)
# 附快速通道:Edit/Write 族目标在主工作区外(worktree)→ 免 python+git 直接过
# pre-commit 直改闸已随主区直改车道退役(commit 点零执法,全量验证归合回后本地 CI);
# 本守卫只约束 agent,用户手工改动不受限
# 退出码 2 = 拦截(stderr 回传模型);0 = 放行;任何解析异常一律放行(守卫是兜底,不能挡死会话收口)
proj="${ZCODE_PROJECT_DIR:-$PWD}"
_payload=""
IFS= read -r -d '' _payload || true # 内建全量读 stdin;临时文件只在走 python 路时才落
# ── 规则闸+快速通道(2026-10-26 立,规约投递闸配套;纯 bash 内建,零额外进程)────────────
# 规则闸:Edit/Write 写 design/、game/,或 Bash 命令以文件触碰动词+路径命中两域时,
# 本会话读取记录须含对应规约 read: 标记(PostToolUse 规约投递闸读规约本体时记入);
# 记录文件不存在=投递闸未运行(含子代理——不经守卫层,2026-10-26 实测)→ 本闸不启用。
# 快速通道:Edit/Write 族目标在主工作区外(worktree)→ 车道判定本就放行,免 python+git 直接过。
# 两段都遵守总纲:解析异常一律放行。
_rg_norm() { # 路径归一:反斜杠→斜杠、小写、/d/ 形转 d:/ 形、折叠重复斜杠(内建零进程)
local p="${1//\\//}"
p="${p,,}"
[[ $p =~ ^/([a-z])/(.*)$ ]] && p="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
while [[ $p == *"//"* ]]; do p="${p//\/\//\/}"; done
printf '%s' "$p"
}
_rg_tool=""; _rg_fp=""
[[ $_payload =~ \"tool_name\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]] && _rg_tool="${BASH_REMATCH[1]}"
[[ $_payload =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]] && _rg_fp="${BASH_REMATCH[1]}"
[ -z "$_rg_fp" ] && [[ $_payload =~ \"notebook_path\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]] && _rg_fp="${BASH_REMATCH[1]}"
_rg_low="$(_rg_norm "$_rg_fp")"
_rl_sid="${CLAUDE_SESSION_ID:-}"
_rl_ledger="${TMPDIR:-/tmp}/zcode-rule-ledger/${_rl_sid}.ledger"
if [ -n "$_rl_sid" ] && [ -f "$_rl_ledger" ]; then
_rl_marks="$(<"$_rl_ledger")"
_rg_need() { # 域规约标记齐备→0(内建子串匹配,零进程)
if [ "$1" = doc ]; then
[[ $_rl_marks == *read:doc-conventions.md* && $_rl_marks == *read:concept-conventions.md* ]]
return
fi
[[ $_rl_marks == *read:code-conventions.md* && $_rl_marks == *read:test-conventions.md* ]]
}
_rg_block() { printf '%s\n' "$1" >&2; exit 2; }
case "$_rg_tool" in
Edit|Write|MultiEdit|NotebookEdit)
case "$_rg_low" in
*/design/*|design/*)
_rg_need doc || _rg_block "[规则闸] 写入 design/ 前本会话未读过 doc-conventions.md 与 concept-conventions.md——先用 Read 完整读这两份规约再继续(读完自动记读取记录)。" ;;
*/game/*|game/*)
_rg_need code || _rg_block "[规则闸] 写入 game/ 前本会话未读过 code-conventions.md 与 test-conventions.md——先用 Read 完整读这两份(或把读文件操作改用 Read 工具),读完自动记读取记录。" ;;
esac
;;
Bash)
# 命令文本(含描述字段)里「文件触碰动词+域路径」同现 → 按域核对读取记录
_lp="${_payload,,}"
if [[ $_lp =~ (^|[^a-z])(cat|head|tail|sed|less|more|grep|rg|awk|find|cp|mv|tee|wc|sort|diff)([^a-z]|$) || $_lp == *">"* ]]; then
[[ $_lp =~ (^|[^a-z])design/ ]] && { _rg_need doc || _rg_block "[规则闸] 用 Bash 触碰 design/ 前本会话未读过 doc-conventions.md 与 concept-conventions.md——先用 Read 完整读这两份(或把读文件操作改用 Read 工具),读完自动记读取记录。"; }
[[ $_lp =~ (^|[^a-z])game/ ]] && { _rg_need code || _rg_block "[规则闸] 用 Bash 触碰 game/ 前本会话未读过 code-conventions.md 与 test-conventions.md——先用 Read 完整读这两份(或把读文件操作改用 Read 工具),读完自动记读取记录。"; }
fi
;;
esac
fi
# 快速通道:Edit/Write 族且目标路径在主工作区外(worktree)——车道判定本就放行,免 python+git
case "$_rg_tool" in
Edit|Write|MultiEdit|NotebookEdit)
_rg_projn="$(_rg_norm "$proj")"
if [ -n "$_rg_fp" ] && [ "${_rg_low#"$_rg_projn"/}" = "$_rg_low" ]; then
exit 0
fi
;;
esac
tmp="$(mktemp)"
printf '%s' "$_payload" > "$tmp"
projw="$(cygpath -w "$proj" 2>/dev/null || echo "$proj")"
python - "$projw" "$(cygpath -w "$tmp" 2>/dev/null || echo "$tmp")" <<'PY'
import json, os, re, subprocess, sys
try:
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
def winpath(p):
# MSYS 风格路径(/d/...)原生 Windows Python 不识别,统一转 Windows 形式
if isinstance(p, str) and re.match(r"^/[A-Za-z](/|$)", p):
try:
r = subprocess.run(["cygpath", "-w", p], capture_output=True, text=True, timeout=5,
encoding="utf-8", errors="replace")
return r.stdout.strip() or p
except Exception:
return p
return p
proj = os.path.abspath(winpath(sys.argv[1]))
try:
with open(sys.argv[2], encoding='utf-8') as f:
d = json.load(f)
except Exception:
sys.exit(0)
tool = d.get("tool_name") or d.get("tool") or ""
ti = d.get("tool_input") or {}
cwd = os.path.abspath(winpath(d.get("cwd") or proj))
def in_dir(p, root):
p = os.path.normcase(os.path.abspath(p))
root = os.path.normcase(os.path.abspath(root))
return p == root or p.startswith(root.rstrip('\\/') + os.sep)
def trunk_branch(repo):
try:
r = subprocess.run(["git", "-C", repo, "branch", "--show-current"],
capture_output=True, text=True, timeout=5,
encoding="utf-8", errors="replace")
return r.stdout.strip()
except Exception:
return ""
TRUNK = {"master", "main"}
# 车道铁律(2026-10-31 恢复):主工作区只作合并落点——agent 的 Edit/Write 族写主工作区即拦;
# 豁免 .zcode/ 与 .qoder/(本机件不入 git,worktree 带不进去);worktree 目标已在快速通道放行
if tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
fp = ti.get("file_path") or ti.get("notebook_path") or ""
if fp:
fpn = os.path.abspath(winpath(fp))
if in_dir(fpn, proj):
rel = os.path.relpath(fpn, proj).replace("\\", "/").lower()
if not rel.startswith(".zcode/") and not rel.startswith(".qoder/"):
sys.stderr.write(
"[车道铁律] 主工作区只作合并落点,改动走独立 worktree——"
"git worktree add ../g-plan2-<任务名> -b <任务分支>,在区里改与提交;"
"豁免面仅 .zcode/ 与 .qoder/。\n")
sys.exit(2)
sys.exit(0)
cmd = ti.get("command", "") or ""
toks = cmd.split()
has_commit = False
for i, t in enumerate(toks):
if t.rstrip(';') != 'git':
continue
j = i + 1
while j < len(toks) and j - i <= 5:
u = toks[j].rstrip(';')
if u == "commit":
has_commit = True
break
if u.startswith("-"):
if u in ("-C", "--git-dir", "-c") and j + 1 < len(toks):
j += 2
continue
j += 1
continue
break
# 主工作区判定:cwd 在内,或命令文本里显式指到主工作区路径(不含 worktree 兄弟目录)
norm = cmd.replace("\\", "/").lower()
ref_main = re.search(r"/d/code/g-plan2(?![\w-])", norm) or re.search(r"d:/code/g-plan2(?![\w-])", norm)
ref_wt = re.search(r"/g-plan2-[\w-]+", norm)
sibling = bool(ref_wt) and not ref_main
on_trunk = trunk_branch(proj) in TRUNK
in_main = (in_dir(cwd, proj) or bool(ref_main)) and on_trunk
if not in_main or sibling:
sys.exit(0)
# 车道铁律(2026-10-31 恢复):主工作区禁止一切 git commit——令回 worktree;合并类命令不在本分支,
# 冲突/前置检查红态(MERGE_HEAD 在)的 git commit 续合同拦——一律 abort → 回 worktree → 重新 git merge --no-ff
if has_commit:
# --no-verify/-n 仅属用户紧急出口,agent 禁用(词法匹配兜底;
# 只扫 -m 之前的参数区,防 commit 消息里提及该旗标名造成误拦)
_flag_zone = toks
for _i, _t in enumerate(toks):
if _t.rstrip(';') == "-m":
_flag_zone = toks[:_i]
break
if any(u.rstrip(';') in ("--no-verify", "-n") for u in _flag_zone):
sys.stderr.write(
"[车道铁律] git commit --no-verify 仅属用户紧急出口,agent 禁用——"
"移除该旗标重新提交;改动走独立 worktree。\n")
sys.exit(2)
# 主区一切 git commit 一律拦(含 MERGE_HEAD 态续合),不再探测 MERGE_HEAD;
# 合并提交只由 git merge --no-ff 一步落地,两合回钩子必触发。
sys.stderr.write(
"[车道铁律] 主工作区禁止一切 git commit(含 MERGE_HEAD 态续合)——改动走独立 worktree;"
"主区唯一合法写动作=git merge --no-ff。"
"若正处合并冲突/前置检查红态:一律 git merge --abort → 回 worktree 处理 → 重新 git merge --no-ff <任务分支>。\n")
sys.exit(2)
sys.exit(0)
PY
rc=$?
rm -f "$tmp"
exit $rc