diff --git a/.zcode/config.json b/.zcode/config.json index 206d64df..23e81144 100644 --- a/.zcode/config.json +++ b/.zcode/config.json @@ -2,6 +2,20 @@ "hooks": { "enabled": true, "events": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "process", + "command": "bash", + "args": ["${ZCODE_PROJECT_DIR}/.zcode/hooks/worktree-guard.sh"], + "timeoutMs": 10000, + "statusMessage": "worktree 铁律守卫(主工作区直改/提交拦截)" + } + ] + } + ], "SessionStart": [ { "hooks": [ diff --git a/.zcode/hooks/worktree-guard.sh b/.zcode/hooks/worktree-guard.sh new file mode 100644 index 00000000..4ab5128a --- /dev/null +++ b/.zcode/hooks/worktree-guard.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# worktree 铁律守卫(PreToolUse,2026-09-19 台账 87 用户立规) +# 拦两路:①Edit/Write 类落在主工作区(白名单外)且主工作区检出 master/main ②Bash 在主工作区 git commit +# 退出码 2 = 拦截(stderr 回传模型);0 = 放行;任何解析异常一律放行(守卫是兜底,不能挡死会话收口) +proj="${ZCODE_PROJECT_DIR:-$PWD}" +tmp="$(mktemp)" +cat > "$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) + 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) + return r.stdout.strip() + except Exception: + return "" + +TRUNK = {"master", "main"} +WHITELIST = [os.path.abspath(winpath(os.path.join(proj, "production", "session-state"))), + os.path.abspath(winpath(os.path.join(proj, ".zcode")))] + +if tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"): + fp = ti.get("file_path") or ti.get("notebook_path") or "" + if not fp: + sys.exit(0) + fp = os.path.abspath(winpath(fp)) + if not in_dir(fp, proj) or any(in_dir(fp, w) for w in WHITELIST): + sys.exit(0) + b = trunk_branch(proj) + if b in TRUNK: + sys.stderr.write( + "[worktree 铁律] 主工作区(%s 分支)禁止直接改动:%s\n" + "先建独立 worktree 再改:git worktree add ../g-plan2-<任务名> -b <任务分支>;" + "收尾在主工作区 git merge --no-ff 合回。豁免面仅 production/session-state/ 与 .zcode/。\n" % (b, fp)) + sys.exit(2) + +elif tool == "Bash": + 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 + if not has_commit: + sys.exit(0) + # 主工作区判定: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) + if (in_dir(cwd, proj) or ref_main) and trunk_branch(proj) in TRUNK: + sys.stderr.write( + "[worktree 铁律] 主工作区(master)禁止直接 git commit——提交在任务 worktree 分支上完成," + "收尾在主工作区 git merge --no-ff 合回 master。\n") + sys.exit(2) + +sys.exit(0) +PY +rc=$? +rm -f "$tmp" +exit $rc diff --git a/CLAUDE.md b/CLAUDE.md index 993b63e7..1b92a407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,19 @@ | 会话进度记录 | `production/session-state/active.md`——顶部「当前状态快照」节 + 批次台账;收口先改快照再记账,每条 ≤3 行,详情外链 | | 工程路线图 | `production/roadmap.md`——里程碑时间线与下一步顺序(批级视图;实时状态看快照节) | +## 分支纪律(worktree 铁律,2026-09-19 用户立规) + +主工作区(本目录,`master` 检出)只作**合并落点与共享状态载体**,一切开发/设计改动不得直接发生在主工作区: + +1. **开工**:每个任务批次先建独立 worktree 分支再动手:`git worktree add ../g-plan2-<任务名> -b <任务分支>`。所有代码/文档/数值改动与验证跑测都在 worktree 内进行(注意:未跟踪文件不会出现在新 worktree,需手工搬运) +2. **收尾**:验证全绿(冒烟 + 场景探针)后,在主工作区 `git merge --no-ff <任务分支>` 落回 master,然后 `git worktree remove` + 删分支;合并前先看 active.md 尾部台账,避免与并行会话撞号/漏捎带 +3. **豁免面(仅这两处允许主工作区直改)**:`production/session-state/`(会话收口)与 `.zcode/`(钩子配置)——其余一律走 worktree;主工作区 `git commit` 会被钩子直接拦截 +4. 本铁律由 PreToolUse 钩子 `.zcode/hooks/worktree-guard.sh` 强制兜底;钩子只约束 agent 侧,用户本人手工改动不受限 + ## 硬性红线(勿违反) +- **worktree 铁律**:主工作区禁止直接改动与提交(细则见上节;并行会话各占 worktree 互不踩踏) + - **数值单一事实源**:玩法数值只存 `data/tables/`(.tres),经 `ConfigDB` autoload 类型化单点读取;禁止数值字面量与自建 `load()` 表路径 - **RNG 单点**:禁止 `randomize()` / 自建 RandomNumberGenerator——随机只经 `RngService` 封闭命名流 - **Core 层零 UI 依赖**:完整战斗必须可 `--headless --script` 跑通