plan-governance-cli 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/bin/plan-governance-cli.mjs +162 -22
- package/package.json +6 -2
- package/resources/manifest.json +24 -0
- package/resources/skill/SKILL.md +268 -0
- package/resources/skill/agents/openai.yaml +5 -0
- package/resources/skill/assets/PLAN_MAP.template.md +48 -0
- package/resources/skill/assets/adr.template.md +18 -0
- package/resources/skill/assets/migration.template.md +20 -0
- package/resources/skill/assets/plan.template.md +80 -0
- package/scripts/init_plan_governance.py +541 -0
- package/scripts/plan_governance_hook.py +271 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ACTIVE_STATUSES = {"候选", "设计中", "待实施", "实施中"}
|
|
10
|
+
PLACEHOLDER_VALUES = {"-", "待补充", "待补充。", "待确认", "无", "N/A"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_text(path):
|
|
14
|
+
try:
|
|
15
|
+
return path.read_text(encoding="utf-8")
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def table_rows(text, heading):
|
|
21
|
+
pattern = re.compile(rf"^##\s+{re.escape(heading)}\s*$", re.MULTILINE)
|
|
22
|
+
match = pattern.search(text)
|
|
23
|
+
if not match:
|
|
24
|
+
return []
|
|
25
|
+
tail = text[match.end():]
|
|
26
|
+
next_heading = re.search(r"^##\s+", tail, re.MULTILINE)
|
|
27
|
+
section = tail[: next_heading.start()] if next_heading else tail
|
|
28
|
+
rows = []
|
|
29
|
+
for line in section.splitlines():
|
|
30
|
+
stripped = line.strip()
|
|
31
|
+
if not stripped.startswith("|") or "---" in stripped:
|
|
32
|
+
continue
|
|
33
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
34
|
+
if cells and cells[0] not in {"计划", "问题"}:
|
|
35
|
+
rows.append(cells)
|
|
36
|
+
return rows
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_plan_link(cell):
|
|
40
|
+
match = re.search(r"\((plans/[^)]+\.md)\)", cell)
|
|
41
|
+
if match:
|
|
42
|
+
return match.group(1)
|
|
43
|
+
if cell.endswith(".md") and cell.startswith("plans/"):
|
|
44
|
+
return cell
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_plan_name(cell, link):
|
|
49
|
+
if link:
|
|
50
|
+
return Path(link).stem
|
|
51
|
+
label = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", cell)
|
|
52
|
+
return label.strip("` ")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def markdown_section(text, heading_names):
|
|
56
|
+
heading_pattern = "|".join(re.escape(name) for name in heading_names)
|
|
57
|
+
pattern = re.compile(rf"^#+\s+({heading_pattern})\b.*$", re.MULTILINE)
|
|
58
|
+
match = pattern.search(text)
|
|
59
|
+
if not match:
|
|
60
|
+
return None
|
|
61
|
+
tail = text[match.end():]
|
|
62
|
+
next_heading = re.search(r"^#+\s+", tail, re.MULTILINE)
|
|
63
|
+
return tail[: next_heading.start()] if next_heading else tail
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def markdown_list_items(section):
|
|
67
|
+
if section is None:
|
|
68
|
+
return []
|
|
69
|
+
items = []
|
|
70
|
+
for line in section.splitlines():
|
|
71
|
+
match = re.match(r"\s*[-*]\s+(.+?)\s*$", line)
|
|
72
|
+
if not match:
|
|
73
|
+
continue
|
|
74
|
+
item = match.group(1).strip().strip("` ")
|
|
75
|
+
if item and item not in PLACEHOLDER_VALUES:
|
|
76
|
+
items.append(item)
|
|
77
|
+
return items
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalize_scope_path(value):
|
|
81
|
+
normalized = value.strip().strip("`").strip()
|
|
82
|
+
normalized = re.sub(r"/+", "/", normalized)
|
|
83
|
+
while normalized.startswith("./"):
|
|
84
|
+
normalized = normalized[2:]
|
|
85
|
+
normalized = normalized.strip("/")
|
|
86
|
+
return normalized
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_scope_token(item):
|
|
90
|
+
backtick = re.search(r"`([^`]+)`", item)
|
|
91
|
+
if backtick:
|
|
92
|
+
return normalize_scope_path(backtick.group(1))
|
|
93
|
+
token = item.strip().split(None, 1)[0] if item.strip() else ""
|
|
94
|
+
return normalize_scope_path(token.rstrip("::"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_plans(root):
|
|
98
|
+
plan_map_path = root / "docs" / "PLAN_MAP.md"
|
|
99
|
+
text = read_text(plan_map_path)
|
|
100
|
+
plans = []
|
|
101
|
+
for row in table_rows(text, "计划索引"):
|
|
102
|
+
if len(row) < 6:
|
|
103
|
+
continue
|
|
104
|
+
link = extract_plan_link(row[0])
|
|
105
|
+
name = extract_plan_name(row[0], link)
|
|
106
|
+
plan_path = root / "docs" / link if link else None
|
|
107
|
+
plans.append(
|
|
108
|
+
{
|
|
109
|
+
"name": name,
|
|
110
|
+
"path": plan_path,
|
|
111
|
+
"status": row[1].strip("` "),
|
|
112
|
+
"phase": row[2].strip("` "),
|
|
113
|
+
"last_updated": row[3].strip("` "),
|
|
114
|
+
"evidence": row[5].strip(),
|
|
115
|
+
"targets": [],
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
for plan in plans:
|
|
119
|
+
if plan["path"] is None:
|
|
120
|
+
continue
|
|
121
|
+
plan_text = read_text(plan["path"])
|
|
122
|
+
section = markdown_section(plan_text, ["影响模块或文件"])
|
|
123
|
+
plan["targets"] = [
|
|
124
|
+
target
|
|
125
|
+
for item in markdown_list_items(section)
|
|
126
|
+
if (target := extract_scope_token(item))
|
|
127
|
+
and target not in PLACEHOLDER_VALUES
|
|
128
|
+
]
|
|
129
|
+
return plans, table_rows(text, "当前阻塞项")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def active_plans(root):
|
|
133
|
+
plans, blockers = load_plans(root)
|
|
134
|
+
return [plan for plan in plans if plan["status"] in ACTIVE_STATUSES], blockers
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def target_matches_path(target, path):
|
|
138
|
+
normalized_target = normalize_scope_path(target)
|
|
139
|
+
normalized_path = normalize_scope_path(path)
|
|
140
|
+
if not normalized_target or not normalized_path:
|
|
141
|
+
return False
|
|
142
|
+
if normalized_target == normalized_path:
|
|
143
|
+
return True
|
|
144
|
+
return normalized_path.startswith(f"{normalized_target.rstrip('/')}/")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def matching_plans(root, paths):
|
|
148
|
+
active, _ = active_plans(root)
|
|
149
|
+
matches = []
|
|
150
|
+
for plan in active:
|
|
151
|
+
if any(
|
|
152
|
+
target_matches_path(target, path)
|
|
153
|
+
for target in plan["targets"]
|
|
154
|
+
for path in paths
|
|
155
|
+
):
|
|
156
|
+
matches.append(plan)
|
|
157
|
+
return matches
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def print_session_start(root):
|
|
161
|
+
active, blockers = active_plans(root)
|
|
162
|
+
if not active:
|
|
163
|
+
print("[plan-governance] 未发现活跃计划。")
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
print("[plan-governance] 活跃计划摘要:")
|
|
167
|
+
for plan in active:
|
|
168
|
+
print(
|
|
169
|
+
f"- {plan['name']}: {plan['status']},{plan['phase']},"
|
|
170
|
+
f"最后更新 {plan['last_updated']},证据 {plan['evidence']}"
|
|
171
|
+
)
|
|
172
|
+
open_blockers = [
|
|
173
|
+
row for row in blockers if len(row) >= 5 and row[3].strip() in {"是", "Yes"}
|
|
174
|
+
]
|
|
175
|
+
if open_blockers:
|
|
176
|
+
print("[plan-governance] 当前阻塞项:")
|
|
177
|
+
for row in open_blockers:
|
|
178
|
+
print(f"- {row[0]}: {row[4]}")
|
|
179
|
+
else:
|
|
180
|
+
print("[plan-governance] 当前阻塞项:无。")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def print_pre_write(root, paths):
|
|
185
|
+
matches = matching_plans(root, paths)
|
|
186
|
+
if not matches:
|
|
187
|
+
print("[plan-governance] 未匹配到相关活跃计划。")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
print("[plan-governance] 写入前检查:")
|
|
191
|
+
for plan in matches:
|
|
192
|
+
print(f"- {plan['name']}: {plan['status']},{plan['phase']}")
|
|
193
|
+
print(" 当前阶段门禁:确认 Step 0 证据、验证方式、完成条件和公共契约约束。")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_plan_map(path):
|
|
198
|
+
return normalize_scope_path(path) == "docs/PLAN_MAP.md"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_plan_doc(path):
|
|
202
|
+
normalized = normalize_scope_path(path)
|
|
203
|
+
return normalized.startswith("docs/plans/") and normalized.endswith(".md")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def print_post_write(paths):
|
|
207
|
+
if any(is_plan_map(path) for path in paths):
|
|
208
|
+
print(
|
|
209
|
+
"[plan-governance] 已写入 PLAN_MAP.md:请同步状态、当前阶段、"
|
|
210
|
+
"最后更新、证据和反向引用检查。"
|
|
211
|
+
)
|
|
212
|
+
if any(is_plan_doc(path) for path in paths):
|
|
213
|
+
print(
|
|
214
|
+
"[plan-governance] 已写入计划文档:请同步 PLAN_MAP.md、验证证据、"
|
|
215
|
+
"测试覆盖率和反向引用检查。"
|
|
216
|
+
)
|
|
217
|
+
if not any(is_plan_map(path) or is_plan_doc(path) for path in paths):
|
|
218
|
+
print("[plan-governance] 写入完成:如影响计划事实,请同步治理文档。")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def print_stop(root):
|
|
223
|
+
checker = root / "scripts" / "check_plan_governance.py"
|
|
224
|
+
if not checker.exists():
|
|
225
|
+
print("[plan-governance] 未找到 scripts/check_plan_governance.py,跳过停止前检查。")
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
[sys.executable, str(checker), "."],
|
|
230
|
+
cwd=root,
|
|
231
|
+
check=False,
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
)
|
|
235
|
+
output = (result.stdout or result.stderr).strip()
|
|
236
|
+
if output:
|
|
237
|
+
print(output)
|
|
238
|
+
print("[plan-governance] Stop 检查为非阻塞提示;请人工复核 WARNING/ERROR。")
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_args(argv):
|
|
243
|
+
parser = argparse.ArgumentParser(description="计划治理 hook runtime。")
|
|
244
|
+
parser.add_argument("--root", default=".", help="仓库根目录,默认当前目录。")
|
|
245
|
+
parser.add_argument(
|
|
246
|
+
"--event",
|
|
247
|
+
required=True,
|
|
248
|
+
choices=["session-start", "pre-write", "post-write", "stop"],
|
|
249
|
+
help="hook 事件类型。",
|
|
250
|
+
)
|
|
251
|
+
parser.add_argument("--paths", nargs="*", default=[], help="事件相关路径。")
|
|
252
|
+
return parser.parse_args(argv)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main(argv=None):
|
|
256
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
257
|
+
root = Path(args.root)
|
|
258
|
+
|
|
259
|
+
if args.event == "session-start":
|
|
260
|
+
return print_session_start(root)
|
|
261
|
+
if args.event == "pre-write":
|
|
262
|
+
return print_pre_write(root, args.paths)
|
|
263
|
+
if args.event == "post-write":
|
|
264
|
+
return print_post_write(args.paths)
|
|
265
|
+
if args.event == "stop":
|
|
266
|
+
return print_stop(root)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
raise SystemExit(main())
|