| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """PreToolUse hook (GLOBAL): 改核心目录前强制要求存在进行中 *_plan.md(对齐 Rules §13)。
- - 对落在「核心/危险目录」的源码文件写操作拦截;tests/、Plan/、依赖/资源目录不拦。
- - 拦截条件:目标在核心目录 且 其 workspace 的 Plan/ 下无任何状态为 规划中/进行中 的 *_plan.md。
- - 放行条件(有意例外):workspace 未建立 Plan/ 目录(无 Plan 体系)时自动放行,避免误伤不采用本工作流的项目。此行为与「强制规划」措辞一致——§13 面向已采用 Plan 体系的项目。
- - 命中 exit 2 阻断,提示先建计划(Trivial Fix 通道见 Rules §14,但核心目录改动仍强制规划)。
- 可配置(环境变量,分号分隔,未设置时用默认值):
- - PLAN_GUARD_CORE_DIRS:核心目录名集合,默认 src;app;lib;core;internal;system;engine
- - PLAN_GUARD_EXCLUDE_DIRS:排除目录名集合,默认 tests;Plan;backup;vendor;node_modules;uploads;.codebuddy;dist;build;public;static;assets;test;__pycache__;.git
- - PLAN_GUARD_TARGET_EXTS:拦截的文件扩展名,默认 .php;.js;.ts;.jsx;.tsx;.py;.java;.go;.vue
- CMS 项目(如帝国CMS)需设置:
- - PLAN_GUARD_CORE_DIRS=e/class;e/data;e/config;e/mods;e/member;e/template;e/admin
- """
- import sys
- import os
- import json
- import re
- def _split_env(name, default):
- """从环境变量读取分号分隔的集合,未设置时用默认值。"""
- val = os.environ.get(name, "")
- if not val.strip():
- return default
- return set(p.strip() for p in val.split(";") if p.strip())
- # 核心目录:可通过 PLAN_GUARD_CORE_DIRS 配置;默认为通用源码根目录
- CORE_DIRS = _split_env(
- "PLAN_GUARD_CORE_DIRS",
- {"src", "app", "lib", "core", "internal", "system", "engine"},
- )
- # 排除目录:可通过 PLAN_GUARD_EXCLUDE_DIRS 配置;默认为通用排除集合
- EXCLUDE_DIRS = _split_env(
- "PLAN_GUARD_EXCLUDE_DIRS",
- {
- "tests", "test", "Plan", "backup", "vendor", "node_modules",
- "uploads", ".codebuddy", "dist", "build", "public",
- "static", "assets", "__pycache__", ".git",
- },
- )
- # 拦截的扩展名:可通过 PLAN_GUARD_TARGET_EXTS 配置
- TARGET_EXTS = _split_env(
- "PLAN_GUARD_TARGET_EXTS",
- {".php", ".js", ".ts", ".jsx", ".tsx", ".py", ".java", ".go", ".vue"},
- )
- # 状态标题行:兼容两种写法,与 handoff_snapshot.py 判定统一。
- # ①「## 状态」独占一行、状态值写在下一行(§13 canonical 模板)
- # ②「## 状态: 进行中」状态值同行带在冒号后(delivery-assurance.md 写法)
- # 不再用严格正则锚定行尾,避免漏匹配「同行带值」格式导致误拦。
- def find_workspace(path):
- """向上找含 Plan/ 子目录的祖先目录作为 workspace。"""
- d = os.path.dirname(path)
- while True:
- if os.path.isdir(os.path.join(d, "Plan")):
- return d
- nd = os.path.dirname(d)
- if nd == d:
- return None
- d = nd
- def has_active_plan(workspace):
- """Plan/*.md 中存在状态为 规划中/进行中 的计划即视为激活。
- 状态值可落在「## 状态」同行(## 状态: 进行中)或下一行(§13 canonical 模板),
- 两种写法均识别,与 handoff_snapshot.py 的 startswith("## 状态") 判定保持一致。
- """
- plan_dir = os.path.join(workspace, "Plan")
- if not os.path.isdir(plan_dir):
- return False
- for fn in os.listdir(plan_dir):
- if not fn.endswith("_plan.md"):
- continue
- p = os.path.join(plan_dir, fn)
- try:
- with open(p, encoding="utf-8", errors="ignore") as f:
- lines = f.read().splitlines()
- except OSError:
- continue
- for i, line in enumerate(lines):
- stripped = line.strip()
- if not stripped.startswith("## 状态"):
- continue
- # 状态值可同行,或标题后隔空行/说明行出现;向后扫到下一个「## 」标题为止
- for j in range(i, min(i + 4, len(lines))):
- if j != i and lines[j].strip().startswith("## "):
- break # 已越过下一个标题,本段无激活状态
- if re.search(r'(规划中|进行中)', lines[j]):
- return True
- return False
- def main():
- try:
- raw = sys.stdin.read()
- if not raw.strip():
- return 0
- data = json.loads(raw)
- except Exception:
- return 0
- tool_input = data.get("tool_input", {}) or {}
- path = (
- tool_input.get("filePath")
- or tool_input.get("file_path")
- or tool_input.get("path")
- or ""
- )
- if not path:
- return 0
- path = os.path.abspath(path)
- ext = os.path.splitext(path)[1].lower()
- if ext not in TARGET_EXTS:
- return 0
- norm = path.replace("\\", "/").lower()
- parts = norm.split("/")
- # 排除非核心/资源/临时目录
- if any(part in EXCLUDE_DIRS for part in parts):
- return 0
- # 是否落在核心目录
- in_core = any(
- ("/%s/" % d) in norm or norm.endswith("/%s" % d) for d in CORE_DIRS
- )
- if not in_core:
- return 0
- workspace = find_workspace(path)
- if workspace is None:
- return 0 # 无 Plan 体系则不拦
- if has_active_plan(workspace):
- return 0
- print("[PLAN-GUARD] 拦截:改动核心目录前须先存在进行中的 *_plan.md(Rules §13)")
- print("目标: %s" % path)
- print("请在 Plan/ 下创建状态为「规划中/进行中」的 *_plan.md 后再改。")
- return 2 # 阻断
- if __name__ == "__main__":
- sys.exit(main())
|