@michaelxwb/code-flow 0.5.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.
Files changed (99) hide show
  1. package/README.md +157 -0
  2. package/package.json +16 -0
  3. package/src/adapters/claude/CLAUDE.md +51 -0
  4. package/src/adapters/claude/commands/cf-init.md +262 -0
  5. package/src/adapters/claude/commands/cf-inject.md +55 -0
  6. package/src/adapters/claude/commands/cf-learn.md +291 -0
  7. package/src/adapters/claude/commands/cf-stats.md +86 -0
  8. package/src/adapters/claude/commands/cf-task/align.md +283 -0
  9. package/src/adapters/claude/commands/cf-task/archive.md +131 -0
  10. package/src/adapters/claude/commands/cf-task/block.md +30 -0
  11. package/src/adapters/claude/commands/cf-task/graph.md +73 -0
  12. package/src/adapters/claude/commands/cf-task/note.md +239 -0
  13. package/src/adapters/claude/commands/cf-task/plan.md +270 -0
  14. package/src/adapters/claude/commands/cf-task/prd.md +138 -0
  15. package/src/adapters/claude/commands/cf-task/start.md +173 -0
  16. package/src/adapters/claude/commands/cf-task/status.md +59 -0
  17. package/src/adapters/claude/commands/cf-validate.md +73 -0
  18. package/src/adapters/claude/settings.local.json +60 -0
  19. package/src/adapters/codex/AGENTS.md +51 -0
  20. package/src/adapters/codex/config.toml +3 -0
  21. package/src/adapters/codex/hooks.json +48 -0
  22. package/src/adapters/codex/skills/cf-init/SKILL.md +271 -0
  23. package/src/adapters/codex/skills/cf-inject/SKILL.md +56 -0
  24. package/src/adapters/codex/skills/cf-learn/SKILL.md +296 -0
  25. package/src/adapters/codex/skills/cf-stats/SKILL.md +87 -0
  26. package/src/adapters/codex/skills/cf-task-align/SKILL.md +284 -0
  27. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +132 -0
  28. package/src/adapters/codex/skills/cf-task-block/SKILL.md +31 -0
  29. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +74 -0
  30. package/src/adapters/codex/skills/cf-task-note/SKILL.md +236 -0
  31. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +271 -0
  32. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +139 -0
  33. package/src/adapters/codex/skills/cf-task-start/SKILL.md +174 -0
  34. package/src/adapters/codex/skills/cf-task-status/SKILL.md +60 -0
  35. package/src/adapters/codex/skills/cf-validate/SKILL.md +74 -0
  36. package/src/adapters/costrict/CLAUDE.md +51 -0
  37. package/src/adapters/costrict/commands/cf-init.md +262 -0
  38. package/src/adapters/costrict/commands/cf-inject.md +55 -0
  39. package/src/adapters/costrict/commands/cf-learn.md +291 -0
  40. package/src/adapters/costrict/commands/cf-stats.md +86 -0
  41. package/src/adapters/costrict/commands/cf-task/align.md +283 -0
  42. package/src/adapters/costrict/commands/cf-task/archive.md +131 -0
  43. package/src/adapters/costrict/commands/cf-task/block.md +30 -0
  44. package/src/adapters/costrict/commands/cf-task/graph.md +73 -0
  45. package/src/adapters/costrict/commands/cf-task/note.md +239 -0
  46. package/src/adapters/costrict/commands/cf-task/plan.md +270 -0
  47. package/src/adapters/costrict/commands/cf-task/prd.md +138 -0
  48. package/src/adapters/costrict/commands/cf-task/start.md +173 -0
  49. package/src/adapters/costrict/commands/cf-task/status.md +59 -0
  50. package/src/adapters/costrict/commands/cf-validate.md +73 -0
  51. package/src/adapters/costrict/settings.local.json +60 -0
  52. package/src/adapters/opencode/AGENTS.md +51 -0
  53. package/src/adapters/opencode/commands/cf-init.md +275 -0
  54. package/src/adapters/opencode/commands/cf-inject.md +59 -0
  55. package/src/adapters/opencode/commands/cf-learn.md +295 -0
  56. package/src/adapters/opencode/commands/cf-stats.md +90 -0
  57. package/src/adapters/opencode/commands/cf-task/align.md +287 -0
  58. package/src/adapters/opencode/commands/cf-task/archive.md +135 -0
  59. package/src/adapters/opencode/commands/cf-task/block.md +34 -0
  60. package/src/adapters/opencode/commands/cf-task/graph.md +77 -0
  61. package/src/adapters/opencode/commands/cf-task/note.md +243 -0
  62. package/src/adapters/opencode/commands/cf-task/plan.md +274 -0
  63. package/src/adapters/opencode/commands/cf-task/prd.md +142 -0
  64. package/src/adapters/opencode/commands/cf-task/start.md +177 -0
  65. package/src/adapters/opencode/commands/cf-task/status.md +63 -0
  66. package/src/adapters/opencode/commands/cf-validate.md +77 -0
  67. package/src/adapters/opencode/opencode.json +4 -0
  68. package/src/adapters/opencode/plugins/code-flow/index.js +132 -0
  69. package/src/adapters/opencode/plugins/code-flow/package.json +6 -0
  70. package/src/cli.js +810 -0
  71. package/src/core/code-flow/config.yml +159 -0
  72. package/src/core/code-flow/scripts/cf_checks.py +306 -0
  73. package/src/core/code-flow/scripts/cf_core.py +954 -0
  74. package/src/core/code-flow/scripts/cf_feedback.py +56 -0
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +178 -0
  76. package/src/core/code-flow/scripts/cf_log.py +146 -0
  77. package/src/core/code-flow/scripts/cf_post_hook.py +196 -0
  78. package/src/core/code-flow/scripts/cf_scan.py +293 -0
  79. package/src/core/code-flow/scripts/cf_session_hook.py +42 -0
  80. package/src/core/code-flow/scripts/cf_stats.py +439 -0
  81. package/src/core/code-flow/scripts/cf_stop_hook.py +191 -0
  82. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +402 -0
  83. package/src/core/code-flow/specs/backend/_map.md +56 -0
  84. package/src/core/code-flow/specs/backend/code-quality-performance.md +47 -0
  85. package/src/core/code-flow/specs/backend/database.md +39 -0
  86. package/src/core/code-flow/specs/backend/directory-structure.md +42 -0
  87. package/src/core/code-flow/specs/backend/logging.md +38 -0
  88. package/src/core/code-flow/specs/backend/platform-rules.md +43 -0
  89. package/src/core/code-flow/specs/frontend/_map.md +52 -0
  90. package/src/core/code-flow/specs/frontend/component-specs.md +56 -0
  91. package/src/core/code-flow/specs/frontend/directory-structure.md +41 -0
  92. package/src/core/code-flow/specs/frontend/quality-standards.md +46 -0
  93. package/src/core/code-flow/specs/shared/_map.md +57 -0
  94. package/src/core/code-flow/specs/shared/design/design-frontend.md +247 -0
  95. package/src/core/code-flow/specs/shared/design/design-full.md +494 -0
  96. package/src/core/code-flow/specs/shared/design/design-lite.md +293 -0
  97. package/src/core/code-flow/specs/shared/prd-template.md +187 -0
  98. package/src/core/code-flow/tasks/.gitkeep +0 -0
  99. package/src/core/code-flow/validation.yml +48 -0
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import cf_log
8
+ from cf_checks import load_check_state
9
+ from cf_core import (
10
+ build_effective_mapping,
11
+ build_spec_catalog,
12
+ compress_content,
13
+ estimate_tokens,
14
+ load_config,
15
+ non_injectable_specs,
16
+ resolve_inject_mode,
17
+ resolve_quality_loop,
18
+ )
19
+
20
+
21
+ def _violation_fixed(index: int, events: list) -> bool:
22
+ """修正口径:违规后同会话同文件有后续编辑,且其后无同 check 再违规。
23
+
24
+ 用日志追加顺序判先后(ts 仅秒级精度,同秒事件无法靠时间戳排序)。
25
+ """
26
+ violation = events[index]
27
+ v_data = violation.get("data") or {}
28
+ later_edit = False
29
+ for event in events[index + 1:]:
30
+ if event.get("sid") != violation.get("sid"):
31
+ continue
32
+ data = event.get("data") or {}
33
+ if event.get("event") == "edit" and data.get("file") == v_data.get("file"):
34
+ later_edit = True
35
+ if (
36
+ later_edit
37
+ and event.get("event") == "violation"
38
+ and data.get("check_id") == v_data.get("check_id")
39
+ and data.get("file") == v_data.get("file")
40
+ ):
41
+ return False
42
+ return later_edit
43
+
44
+
45
+ def quality_loop_summary(project_root: str, config: dict) -> dict:
46
+ """FEAT-05/02 度量聚合:Top 违规榜 / 修正率 / 误报与停用 / 降级组件。"""
47
+ switches = resolve_quality_loop(config)
48
+ events = cf_log.read_events(project_root, days=30)
49
+ summary = {"switches": switches, "window_days": 30}
50
+ if not events:
51
+ summary["note"] = "暂无数据"
52
+ return summary
53
+
54
+ violations = [e for e in events if e.get("event") == "violation"]
55
+ counts: dict = {}
56
+ for v in violations:
57
+ data = v.get("data") or {}
58
+ key = f"{data.get('spec', '?')}#{data.get('check_id', '?')}"
59
+ counts[key] = counts.get(key, 0) + 1
60
+ summary["top_violations"] = sorted(
61
+ ({"rule": k, "count": n} for k, n in counts.items()),
62
+ key=lambda item: -item["count"],
63
+ )[:10]
64
+
65
+ fixed = sum(
66
+ 1 for i, event in enumerate(events)
67
+ if event.get("event") == "violation" and _violation_fixed(i, events)
68
+ )
69
+ summary["violation_total"] = len(violations)
70
+ summary["fix_rate"] = (
71
+ f"{round(fixed * 100 / len(violations))}%" if violations else "n/a"
72
+ )
73
+
74
+ state = load_check_state(project_root)
75
+ summary["checks"] = {
76
+ cid: {
77
+ "hit_count": entry.get("hit_count", 0),
78
+ "fp_count": entry.get("fp_count", 0),
79
+ "disabled": bool(entry.get("disabled")),
80
+ "disabled_reason": entry.get("disabled_reason", ""),
81
+ }
82
+ for cid, entry in state.items()
83
+ if isinstance(entry, dict) and not cid.startswith("_")
84
+ }
85
+
86
+ degraded: dict = {}
87
+ for event in events:
88
+ if event.get("event") != "degrade":
89
+ continue
90
+ data = event.get("data") or {}
91
+ component = data.get("component", "?")
92
+ item = degraded.setdefault(component, {"count": 0, "last_error": ""})
93
+ item["count"] += 1
94
+ item["last_error"] = data.get("error", "")
95
+ summary["degraded"] = degraded
96
+ return summary
97
+
98
+
99
+ def read_text(path: str) -> str:
100
+ try:
101
+ with open(path, "r", encoding="utf-8") as file:
102
+ return file.read().strip()
103
+ except Exception:
104
+ return ""
105
+
106
+
107
+ def normalize_rel_path(path: str) -> str:
108
+ return path.replace(os.sep, "/")
109
+
110
+
111
+ def extract_spec_path(spec_entry) -> str:
112
+ if isinstance(spec_entry, dict):
113
+ return normalize_rel_path(spec_entry.get("path", ""))
114
+ if isinstance(spec_entry, str):
115
+ return normalize_rel_path(spec_entry)
116
+ return ""
117
+
118
+
119
+ def discover_specs(specs_root: str) -> dict:
120
+ discovered = {}
121
+ if not os.path.isdir(specs_root):
122
+ return discovered
123
+
124
+ for root, _, files in os.walk(specs_root):
125
+ for filename in files:
126
+ if not filename.endswith(".md"):
127
+ continue
128
+ full_path = os.path.join(root, filename)
129
+ rel = normalize_rel_path(os.path.relpath(full_path, specs_root))
130
+ parts = rel.split("/", 1)
131
+ if len(parts) < 2:
132
+ continue
133
+ domain = parts[0]
134
+ discovered.setdefault(domain, []).append(rel)
135
+
136
+ for domain in discovered:
137
+ discovered[domain] = sorted(set(discovered[domain]))
138
+ return discovered
139
+
140
+
141
+ def configured_specs(config: dict, domain: str) -> list:
142
+ mapping = (config.get("path_mapping") or {}).get(domain) or {}
143
+ specs_config = mapping.get("specs") or []
144
+ result = []
145
+ for spec_entry in specs_config:
146
+ rel = extract_spec_path(spec_entry)
147
+ if rel:
148
+ result.append(rel)
149
+ return result
150
+
151
+
152
+ def resolve_domains(config: dict, discovered: dict, domain_filter: Optional[str]) -> list:
153
+ if domain_filter:
154
+ if domain_filter in discovered:
155
+ return [domain_filter]
156
+ if domain_filter in (config.get("path_mapping") or {}):
157
+ return [domain_filter]
158
+ return []
159
+
160
+ if discovered:
161
+ return sorted(discovered.keys())
162
+ return sorted((config.get("path_mapping") or {}).keys())
163
+
164
+
165
+ def _build_item(rel: str, raw_content: str) -> dict:
166
+ raw_tokens = estimate_tokens(raw_content)
167
+ compressed_tokens = estimate_tokens(compress_content(raw_content))
168
+ saved_pct = (
169
+ round((raw_tokens - compressed_tokens) * 100 / raw_tokens, 1)
170
+ if raw_tokens
171
+ else 0.0
172
+ )
173
+ return {
174
+ "path": rel,
175
+ "tokens": compressed_tokens,
176
+ "tokens_raw": raw_tokens,
177
+ "tokens_compressed": compressed_tokens,
178
+ "saved_pct": saved_pct,
179
+ }
180
+
181
+
182
+ def collect_domain_items(
183
+ specs_root: str,
184
+ domain: str,
185
+ configured: list,
186
+ discovered: list,
187
+ ) -> tuple:
188
+ items = []
189
+ missing = []
190
+ seen = set()
191
+
192
+ for rel in configured:
193
+ if rel in seen:
194
+ continue
195
+ seen.add(rel)
196
+ full_path = os.path.join(specs_root, rel)
197
+ if not os.path.exists(full_path):
198
+ missing.append({"domain": domain, "path": rel})
199
+ continue
200
+ content = read_text(full_path)
201
+ if content:
202
+ items.append(_build_item(rel, content))
203
+
204
+ for rel in discovered:
205
+ if rel in seen:
206
+ continue
207
+ seen.add(rel)
208
+ full_path = os.path.join(specs_root, rel)
209
+ content = read_text(full_path)
210
+ if content:
211
+ items.append(_build_item(rel, content))
212
+
213
+ return items, missing
214
+
215
+
216
+ def main() -> None:
217
+ project_root = os.getcwd()
218
+ config = load_config(project_root)
219
+ budget_cfg = config.get("budget") or {}
220
+
221
+ human_output = "--human" in sys.argv
222
+ json_output = not human_output
223
+ audit_mode = "--audit" in sys.argv
224
+ domain_filter = None
225
+ for arg in sys.argv[1:]:
226
+ if arg.startswith("--domain="):
227
+ domain_filter = arg.split("=", 1)[1]
228
+
229
+ l0_budget = budget_cfg.get("l0_max", 800)
230
+ l1_budget = budget_cfg.get("l1_max", 1700)
231
+ total_budget = budget_cfg.get("total", l0_budget + l1_budget)
232
+
233
+ try:
234
+ l0_budget = int(l0_budget)
235
+ except Exception:
236
+ l0_budget = 800
237
+ try:
238
+ total_budget = int(total_budget)
239
+ except Exception:
240
+ total_budget = l0_budget + l1_budget
241
+
242
+ claude_path = os.path.join(project_root, "CLAUDE.md")
243
+ l0_tokens = 0
244
+ if os.path.exists(claude_path):
245
+ l0_tokens = estimate_tokens(read_text(claude_path))
246
+
247
+ l1 = {}
248
+ total_tokens = l0_tokens
249
+ templates_tokens = 0
250
+ specs_root = os.path.join(project_root, ".code-flow", "specs")
251
+ spec_domain_map = {}
252
+ missing_specs = []
253
+ domains_with_no_loaded_specs = []
254
+ discovered = discover_specs(specs_root)
255
+ domains = resolve_domains(config, discovered, domain_filter)
256
+ config_fallback_mode = not discovered
257
+
258
+ effective_mapping = build_effective_mapping(
259
+ project_root, config.get("path_mapping") or {}
260
+ )
261
+ excluded_specs = non_injectable_specs(effective_mapping)
262
+
263
+ for domain in domains:
264
+ configured = configured_specs(config, domain)
265
+ discovered_paths = discovered.get(domain, [])
266
+ items, missing = collect_domain_items(specs_root, domain, configured, discovered_paths)
267
+
268
+ for rel in configured:
269
+ if not rel:
270
+ continue
271
+ spec_domain_map[rel] = domain
272
+ for rel in discovered_paths:
273
+ spec_domain_map[rel] = domain
274
+
275
+ missing_specs.extend(missing)
276
+
277
+ for item in items:
278
+ item["injectable"] = item["path"] not in excluded_specs
279
+ if items:
280
+ l1[domain] = items
281
+ total_tokens += sum(i["tokens"] for i in items if i["injectable"])
282
+ templates_tokens += sum(i["tokens"] for i in items if not i["injectable"])
283
+ elif configured and (config_fallback_mode or discovered_paths):
284
+ domains_with_no_loaded_specs.append(domain)
285
+
286
+ utilization = "0%"
287
+ if total_budget:
288
+ utilization = f"{round(total_tokens * 100 / total_budget)}%"
289
+
290
+ warnings = []
291
+ if l0_tokens > l0_budget:
292
+ warnings.append("L0 超出预算")
293
+ l1_tokens = total_tokens - l0_tokens
294
+ if l1_tokens > l1_budget:
295
+ warnings.append("L1 超出预算")
296
+ if total_tokens > total_budget:
297
+ warnings.append("总预算超出")
298
+ if missing_specs:
299
+ warnings.append(f"配置的 spec 文件缺失: {len(missing_specs)} 个")
300
+ if domains_with_no_loaded_specs:
301
+ domains_text = ", ".join(sorted(set(domains_with_no_loaded_specs)))
302
+ warnings.append(f"以下域未加载到任何 L1 spec: {domains_text}")
303
+
304
+ total_raw = sum(
305
+ item.get("tokens_raw", item["tokens"])
306
+ for items in l1.values()
307
+ for item in items
308
+ if item.get("injectable", True)
309
+ )
310
+ total_compressed = sum(
311
+ item.get("tokens_compressed", item["tokens"])
312
+ for items in l1.values()
313
+ for item in items
314
+ if item.get("injectable", True)
315
+ )
316
+ total_saved_pct = (
317
+ round((total_raw - total_compressed) * 100 / total_raw, 1)
318
+ if total_raw
319
+ else 0.0
320
+ )
321
+ compression_summary = {
322
+ "total_raw": total_raw,
323
+ "total_compressed": total_compressed,
324
+ "total_saved_pct": total_saved_pct,
325
+ }
326
+
327
+ inject_mode = resolve_inject_mode(config.get("inject") or {})
328
+ try:
329
+ catalog_max = int(budget_cfg.get("catalog_max", 200))
330
+ except Exception:
331
+ catalog_max = 200
332
+ catalog_text = build_spec_catalog(project_root, effective_mapping, catalog_max)
333
+ catalog_summary = {
334
+ "mode": inject_mode,
335
+ "tokens": estimate_tokens(catalog_text),
336
+ "budget": catalog_max,
337
+ "entries": catalog_text.count("\n- `"),
338
+ }
339
+
340
+ ql_summary = quality_loop_summary(project_root, config)
341
+
342
+ output = {
343
+ "l0": {"file": "CLAUDE.md", "tokens": l0_tokens, "budget": l0_budget},
344
+ "l1": l1,
345
+ "total_tokens": total_tokens,
346
+ "total_budget": total_budget,
347
+ "utilization": utilization,
348
+ "warnings": warnings,
349
+ "spec_domain_map": spec_domain_map,
350
+ "missing_specs": missing_specs,
351
+ "compression_summary": compression_summary,
352
+ "catalog": catalog_summary,
353
+ "quality_loop": ql_summary,
354
+ "templates": {
355
+ "tokens": templates_tokens,
356
+ "files": sorted(excluded_specs),
357
+ "note": "tags:[] 命令专用模板,永不自动注入,不计预算",
358
+ },
359
+ }
360
+ if audit_mode:
361
+ from cf_scan import build_report
362
+ scan = build_report(project_root)
363
+ output["audit"] = {
364
+ "files": [e for e in scan["files"] if e.get("issues")],
365
+ "review": scan["review"],
366
+ }
367
+
368
+ if json_output:
369
+ print(json.dumps(output, ensure_ascii=False))
370
+ return
371
+
372
+ print("L0 (CLAUDE.md):", f"{l0_tokens} / {l0_budget}")
373
+ for domain, items in l1.items():
374
+ total_domain = sum(i["tokens"] for i in items if i.get("injectable", True))
375
+ print(f"L1 {domain}:", total_domain)
376
+ for item in items:
377
+ raw = item.get("tokens_raw", item["tokens"])
378
+ compressed = item.get("tokens_compressed", item["tokens"])
379
+ saved = item.get("saved_pct", 0.0)
380
+ suffix = "" if item.get("injectable", True) else "(模板,不计预算)"
381
+ print(" -", item["path"], item["tokens"],
382
+ f"(raw={raw}→compressed={compressed}, -{saved}%)" + suffix)
383
+ if templates_tokens:
384
+ print("TEMPLATES (非注入):", f"{templates_tokens} tokens,不计预算")
385
+ if missing_specs:
386
+ print("MISSING SPECS:")
387
+ for item in missing_specs:
388
+ print(" -", item["domain"], item["path"])
389
+ print("TOTAL:", f"{total_tokens} / {total_budget}")
390
+ print("UTILIZATION:", utilization)
391
+ print(
392
+ "COMPRESSION:",
393
+ f"{total_raw} → {total_compressed} (-{total_saved_pct}%)",
394
+ )
395
+ print(
396
+ "CATALOG:",
397
+ f"mode={inject_mode},",
398
+ f"{catalog_summary['tokens']} / {catalog_max} tokens,",
399
+ f"{catalog_summary['entries']} entries",
400
+ )
401
+ switches = ql_summary["switches"]
402
+ print("QUALITY-LOOP:", "enabled" if switches["enabled"] else "disabled")
403
+ if ql_summary.get("note"):
404
+ print(" -", ql_summary["note"])
405
+ else:
406
+ print(
407
+ " - violations:", ql_summary.get("violation_total", 0),
408
+ "| fix_rate:", ql_summary.get("fix_rate", "n/a"),
409
+ )
410
+ for item in ql_summary.get("top_violations", [])[:5]:
411
+ print(" ·", item["rule"], item["count"])
412
+ for cid, info in ql_summary.get("checks", {}).items():
413
+ if info["disabled"] or info["fp_count"]:
414
+ print(
415
+ f" · {cid}: hits={info['hit_count']} fp={info['fp_count']}"
416
+ + (f" DISABLED({info['disabled_reason']})" if info["disabled"] else "")
417
+ )
418
+ for component, info in ql_summary.get("degraded", {}).items():
419
+ print(f" · degraded {component}: {info['count']} ({info['last_error']})")
420
+ if warnings:
421
+ print("WARNINGS:", "; ".join(warnings))
422
+
423
+ if audit_mode:
424
+ audit = output["audit"]
425
+ print("AUDIT (规范质量):")
426
+ if not audit["files"] and not audit["review"]:
427
+ print(" - 无问题")
428
+ for entry in audit["files"]:
429
+ print(" -", entry["path"], "|", " / ".join(entry["issues"]))
430
+ if audit["review"]:
431
+ print(" REVIEW (待复审):")
432
+ for item in audit["review"]:
433
+ print(f" · {item['item']}: {item['reason']}")
434
+ else:
435
+ print("AUDIT: 运行 cf-stats --audit 查看规范质量问题与待复审清单")
436
+
437
+
438
+ if __name__ == "__main__":
439
+ main()
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Stop Hook: run validation.yml checks on session-edited files (FEAT-03).
3
+
4
+ Flow: session edit events (cf_log) → match validator triggers → run commands
5
+ serially under a total budget → failures surface as {"decision": "block",
6
+ "reason": ...} so the agent fixes them before the session truly ends.
7
+
8
+ Protocol notes (deviation from the injection-hook contract, recorded in
9
+ TASK-010): the Stop event's feedback channel is decision/reason — there is no
10
+ additionalContext for Stop. `stop_hook_active` is respected so an already
11
+ continued session is never re-blocked (no loops). All-pass → silent exit 0.
12
+ """
13
+ import fnmatch
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+
20
+ import cf_log
21
+ from cf_core import (
22
+ _log,
23
+ ensure_utf8_io,
24
+ load_config,
25
+ normalize_path,
26
+ resolve_quality_loop,
27
+ resolve_session_id,
28
+ )
29
+
30
+ TOTAL_BUDGET_SECONDS = 30.0
31
+ _BRACE_RE = re.compile(r"\{([^{}]+)\}")
32
+
33
+
34
+ def expand_braces(pattern: str) -> list:
35
+ """Expand one level of `{a,b}` alternation → list of fnmatch patterns."""
36
+ match = _BRACE_RE.search(pattern)
37
+ if not match:
38
+ return [pattern]
39
+ head, tail = pattern[: match.start()], pattern[match.end():]
40
+ out = []
41
+ for option in match.group(1).split(","):
42
+ out.extend(expand_braces(head + option + tail))
43
+ return out
44
+
45
+
46
+ def trigger_matches(trigger: str, rel_path: str) -> bool:
47
+ """fnmatch with brace expansion; `**/` prefix also matches root files."""
48
+ rel_path = normalize_path(rel_path)
49
+ for pattern in expand_braces(trigger or ""):
50
+ candidates = [pattern]
51
+ if pattern.startswith("**/"):
52
+ candidates.append(pattern[3:])
53
+ if any(fnmatch.fnmatch(rel_path, p) for p in candidates):
54
+ return True
55
+ return False
56
+
57
+
58
+ def load_validators(project_root: str) -> list:
59
+ path = os.path.join(project_root, ".code-flow", "validation.yml")
60
+ if not os.path.exists(path):
61
+ return []
62
+ try:
63
+ import yaml
64
+ with open(path, "r", encoding="utf-8") as f:
65
+ data = yaml.safe_load(f) or {}
66
+ validators = data.get("validators")
67
+ return validators if isinstance(validators, list) else []
68
+ except Exception as exc:
69
+ _log(f"cf_stop_hook validation.yml parse failed: {exc}")
70
+ return []
71
+
72
+
73
+ def session_edited_files(project_root: str, sid: str) -> list:
74
+ files = []
75
+ seen = set()
76
+ for event in cf_log.read_events(project_root, days=2, events=("edit",)):
77
+ if event.get("sid") != sid:
78
+ continue
79
+ rel = (event.get("data") or {}).get("file")
80
+ if rel and rel not in seen:
81
+ seen.add(rel)
82
+ files.append(rel)
83
+ return files
84
+
85
+
86
+ def run_validators(
87
+ project_root: str, validators: list, files: list, sid: str,
88
+ total_budget: float = TOTAL_BUDGET_SECONDS,
89
+ ) -> tuple:
90
+ """Run matching validators serially → (failures, truncated)."""
91
+ import time
92
+ failures = []
93
+ truncated = False
94
+ deadline = time.monotonic() + total_budget
95
+ for validator in validators:
96
+ if not isinstance(validator, dict):
97
+ continue
98
+ matched = [f for f in files if trigger_matches(validator.get("trigger", ""), f)]
99
+ if not matched:
100
+ continue
101
+ remaining = deadline - time.monotonic()
102
+ if remaining <= 0:
103
+ truncated = True
104
+ break
105
+ try:
106
+ timeout = min(float(validator.get("timeout", 30000)) / 1000.0, remaining)
107
+ except (ValueError, TypeError):
108
+ timeout = remaining
109
+ command = str(validator.get("command", "")).replace(
110
+ "{files}", " ".join(matched)
111
+ )
112
+ if not command.strip():
113
+ continue
114
+ passed = False
115
+ detail = ""
116
+ try:
117
+ proc = subprocess.run(
118
+ command, shell=True, cwd=project_root,
119
+ capture_output=True, text=True, timeout=timeout,
120
+ )
121
+ passed = proc.returncode == 0
122
+ if not passed:
123
+ detail = (proc.stdout + proc.stderr).strip()[-400:]
124
+ except subprocess.TimeoutExpired:
125
+ detail = f"超时(>{timeout:.0f}s),跳过"
126
+ truncated = True
127
+ except Exception as exc:
128
+ # 命令在环境中不可用等:降级跳过,不打扰(E 场景)
129
+ cf_log.degrade(project_root, "stop_check", f"{validator.get('name')}:{exc}", sid)
130
+ continue
131
+ cf_log.append_event(
132
+ project_root, "stop_check",
133
+ {"trigger": validator.get("trigger", ""),
134
+ "cmd": str(validator.get("command", ""))[:120], "passed": passed},
135
+ sid,
136
+ )
137
+ if not passed:
138
+ failures.append({
139
+ "name": validator.get("name", "validator"),
140
+ "on_fail": validator.get("on_fail", ""),
141
+ "detail": detail,
142
+ })
143
+ return failures, truncated
144
+
145
+
146
+ def _reason_text(failures: list, truncated: bool) -> str:
147
+ lines = ["收尾校验未通过(cf-stop,FEAT-03):"]
148
+ for item in failures:
149
+ lines.append(f"✗ {item['name']}:{item['on_fail']}")
150
+ if item["detail"]:
151
+ lines.append(f" 输出片段: {item['detail'][:200]}")
152
+ if truncated:
153
+ lines.append("(总预算 30s 已用尽,以上为已完成部分)")
154
+ lines.append("请修复后再结束。")
155
+ return "\n".join(lines)
156
+
157
+
158
+ def main() -> None:
159
+ try:
160
+ ensure_utf8_io()
161
+ raw = sys.stdin.read()
162
+ if not raw.strip():
163
+ return
164
+ data = json.loads(raw)
165
+ if data.get("stop_hook_active"):
166
+ return # 已因本 hook 续跑过一轮,避免循环
167
+ project_root = os.getcwd()
168
+ config = load_config(project_root)
169
+ if not config:
170
+ return
171
+ if not resolve_quality_loop(config)["stop_check"]:
172
+ return
173
+ sid = resolve_session_id(data)
174
+ files = session_edited_files(project_root, sid)
175
+ if not files:
176
+ return
177
+ validators = load_validators(project_root)
178
+ if not validators:
179
+ return # E-05: 无 validation.yml 静默
180
+ failures, truncated = run_validators(project_root, validators, files, sid)
181
+ if not failures:
182
+ return # 全过静默(S-05)
183
+ payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
184
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
185
+ except Exception as exc:
186
+ _log(f"cf_stop_hook error: {exc}")
187
+ return
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()