plan_guard.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """PreToolUse hook (GLOBAL): 改核心目录前强制要求存在进行中 *_plan.md(对齐 Rules §13)。
  4. - 对落在「核心/危险目录」的源码文件写操作拦截;tests/、Plan/、依赖/资源目录不拦。
  5. - 拦截条件:目标在核心目录 且 其 workspace 的 Plan/ 下无任何状态为 规划中/进行中 的 *_plan.md。
  6. - 放行条件(有意例外):workspace 未建立 Plan/ 目录(无 Plan 体系)时自动放行,避免误伤不采用本工作流的项目。此行为与「强制规划」措辞一致——§13 面向已采用 Plan 体系的项目。
  7. - 命中 exit 2 阻断,提示先建计划(Trivial Fix 通道见 Rules §14,但核心目录改动仍强制规划)。
  8. 可配置(环境变量,分号分隔,未设置时用默认值):
  9. - PLAN_GUARD_CORE_DIRS:核心目录名集合,默认 src;app;lib;core;internal;system;engine
  10. - PLAN_GUARD_EXCLUDE_DIRS:排除目录名集合,默认 tests;Plan;backup;vendor;node_modules;uploads;.codebuddy;dist;build;public;static;assets;test;__pycache__;.git
  11. - PLAN_GUARD_TARGET_EXTS:拦截的文件扩展名,默认 .php;.js;.ts;.jsx;.tsx;.py;.java;.go;.vue
  12. CMS 项目(如帝国CMS)需设置:
  13. - PLAN_GUARD_CORE_DIRS=e/class;e/data;e/config;e/mods;e/member;e/template;e/admin
  14. """
  15. import sys
  16. import os
  17. import json
  18. import re
  19. def _split_env(name, default):
  20. """从环境变量读取分号分隔的集合,未设置时用默认值。"""
  21. val = os.environ.get(name, "")
  22. if not val.strip():
  23. return default
  24. return set(p.strip() for p in val.split(";") if p.strip())
  25. # 核心目录:可通过 PLAN_GUARD_CORE_DIRS 配置;默认为通用源码根目录
  26. CORE_DIRS = _split_env(
  27. "PLAN_GUARD_CORE_DIRS",
  28. {"src", "app", "lib", "core", "internal", "system", "engine"},
  29. )
  30. # 排除目录:可通过 PLAN_GUARD_EXCLUDE_DIRS 配置;默认为通用排除集合
  31. EXCLUDE_DIRS = _split_env(
  32. "PLAN_GUARD_EXCLUDE_DIRS",
  33. {
  34. "tests", "test", "Plan", "backup", "vendor", "node_modules",
  35. "uploads", ".codebuddy", "dist", "build", "public",
  36. "static", "assets", "__pycache__", ".git",
  37. },
  38. )
  39. # 拦截的扩展名:可通过 PLAN_GUARD_TARGET_EXTS 配置
  40. TARGET_EXTS = _split_env(
  41. "PLAN_GUARD_TARGET_EXTS",
  42. {".php", ".js", ".ts", ".jsx", ".tsx", ".py", ".java", ".go", ".vue"},
  43. )
  44. # 状态标题行:兼容两种写法,与 handoff_snapshot.py 判定统一。
  45. # ①「## 状态」独占一行、状态值写在下一行(§13 canonical 模板)
  46. # ②「## 状态: 进行中」状态值同行带在冒号后(delivery-assurance.md 写法)
  47. # 不再用严格正则锚定行尾,避免漏匹配「同行带值」格式导致误拦。
  48. def find_workspace(path):
  49. """向上找含 Plan/ 子目录的祖先目录作为 workspace。"""
  50. d = os.path.dirname(path)
  51. while True:
  52. if os.path.isdir(os.path.join(d, "Plan")):
  53. return d
  54. nd = os.path.dirname(d)
  55. if nd == d:
  56. return None
  57. d = nd
  58. def has_active_plan(workspace):
  59. """Plan/*.md 中存在状态为 规划中/进行中 的计划即视为激活。
  60. 状态值可落在「## 状态」同行(## 状态: 进行中)或下一行(§13 canonical 模板),
  61. 两种写法均识别,与 handoff_snapshot.py 的 startswith("## 状态") 判定保持一致。
  62. """
  63. plan_dir = os.path.join(workspace, "Plan")
  64. if not os.path.isdir(plan_dir):
  65. return False
  66. for fn in os.listdir(plan_dir):
  67. if not fn.endswith("_plan.md"):
  68. continue
  69. p = os.path.join(plan_dir, fn)
  70. try:
  71. with open(p, encoding="utf-8", errors="ignore") as f:
  72. lines = f.read().splitlines()
  73. except OSError:
  74. continue
  75. for i, line in enumerate(lines):
  76. stripped = line.strip()
  77. if not stripped.startswith("## 状态"):
  78. continue
  79. # 状态值可同行,或标题后隔空行/说明行出现;向后扫到下一个「## 」标题为止
  80. for j in range(i, min(i + 4, len(lines))):
  81. if j != i and lines[j].strip().startswith("## "):
  82. break # 已越过下一个标题,本段无激活状态
  83. if re.search(r'(规划中|进行中)', lines[j]):
  84. return True
  85. return False
  86. def main():
  87. try:
  88. raw = sys.stdin.read()
  89. if not raw.strip():
  90. return 0
  91. data = json.loads(raw)
  92. except Exception:
  93. return 0
  94. tool_input = data.get("tool_input", {}) or {}
  95. path = (
  96. tool_input.get("filePath")
  97. or tool_input.get("file_path")
  98. or tool_input.get("path")
  99. or ""
  100. )
  101. if not path:
  102. return 0
  103. path = os.path.abspath(path)
  104. ext = os.path.splitext(path)[1].lower()
  105. if ext not in TARGET_EXTS:
  106. return 0
  107. norm = path.replace("\\", "/").lower()
  108. parts = norm.split("/")
  109. # 排除非核心/资源/临时目录
  110. if any(part in EXCLUDE_DIRS for part in parts):
  111. return 0
  112. # 是否落在核心目录
  113. in_core = any(
  114. ("/%s/" % d) in norm or norm.endswith("/%s" % d) for d in CORE_DIRS
  115. )
  116. if not in_core:
  117. return 0
  118. workspace = find_workspace(path)
  119. if workspace is None:
  120. return 0 # 无 Plan 体系则不拦
  121. if has_active_plan(workspace):
  122. return 0
  123. print("[PLAN-GUARD] 拦截:改动核心目录前须先存在进行中的 *_plan.md(Rules §13)")
  124. print("目标: %s" % path)
  125. print("请在 Plan/ 下创建状态为「规划中/进行中」的 *_plan.md 后再改。")
  126. return 2 # 阻断
  127. if __name__ == "__main__":
  128. sys.exit(main())