#!/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