@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.
- package/README.md +157 -0
- package/package.json +16 -0
- package/src/adapters/claude/CLAUDE.md +51 -0
- package/src/adapters/claude/commands/cf-init.md +262 -0
- package/src/adapters/claude/commands/cf-inject.md +55 -0
- package/src/adapters/claude/commands/cf-learn.md +291 -0
- package/src/adapters/claude/commands/cf-stats.md +86 -0
- package/src/adapters/claude/commands/cf-task/align.md +283 -0
- package/src/adapters/claude/commands/cf-task/archive.md +131 -0
- package/src/adapters/claude/commands/cf-task/block.md +30 -0
- package/src/adapters/claude/commands/cf-task/graph.md +73 -0
- package/src/adapters/claude/commands/cf-task/note.md +239 -0
- package/src/adapters/claude/commands/cf-task/plan.md +270 -0
- package/src/adapters/claude/commands/cf-task/prd.md +138 -0
- package/src/adapters/claude/commands/cf-task/start.md +173 -0
- package/src/adapters/claude/commands/cf-task/status.md +59 -0
- package/src/adapters/claude/commands/cf-validate.md +73 -0
- package/src/adapters/claude/settings.local.json +60 -0
- package/src/adapters/codex/AGENTS.md +51 -0
- package/src/adapters/codex/config.toml +3 -0
- package/src/adapters/codex/hooks.json +48 -0
- package/src/adapters/codex/skills/cf-init/SKILL.md +271 -0
- package/src/adapters/codex/skills/cf-inject/SKILL.md +56 -0
- package/src/adapters/codex/skills/cf-learn/SKILL.md +296 -0
- package/src/adapters/codex/skills/cf-stats/SKILL.md +87 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +284 -0
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +132 -0
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +31 -0
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +74 -0
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +236 -0
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +271 -0
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +139 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +174 -0
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +60 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +74 -0
- package/src/adapters/costrict/CLAUDE.md +51 -0
- package/src/adapters/costrict/commands/cf-init.md +262 -0
- package/src/adapters/costrict/commands/cf-inject.md +55 -0
- package/src/adapters/costrict/commands/cf-learn.md +291 -0
- package/src/adapters/costrict/commands/cf-stats.md +86 -0
- package/src/adapters/costrict/commands/cf-task/align.md +283 -0
- package/src/adapters/costrict/commands/cf-task/archive.md +131 -0
- package/src/adapters/costrict/commands/cf-task/block.md +30 -0
- package/src/adapters/costrict/commands/cf-task/graph.md +73 -0
- package/src/adapters/costrict/commands/cf-task/note.md +239 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +270 -0
- package/src/adapters/costrict/commands/cf-task/prd.md +138 -0
- package/src/adapters/costrict/commands/cf-task/start.md +173 -0
- package/src/adapters/costrict/commands/cf-task/status.md +59 -0
- package/src/adapters/costrict/commands/cf-validate.md +73 -0
- package/src/adapters/costrict/settings.local.json +60 -0
- package/src/adapters/opencode/AGENTS.md +51 -0
- package/src/adapters/opencode/commands/cf-init.md +275 -0
- package/src/adapters/opencode/commands/cf-inject.md +59 -0
- package/src/adapters/opencode/commands/cf-learn.md +295 -0
- package/src/adapters/opencode/commands/cf-stats.md +90 -0
- package/src/adapters/opencode/commands/cf-task/align.md +287 -0
- package/src/adapters/opencode/commands/cf-task/archive.md +135 -0
- package/src/adapters/opencode/commands/cf-task/block.md +34 -0
- package/src/adapters/opencode/commands/cf-task/graph.md +77 -0
- package/src/adapters/opencode/commands/cf-task/note.md +243 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +274 -0
- package/src/adapters/opencode/commands/cf-task/prd.md +142 -0
- package/src/adapters/opencode/commands/cf-task/start.md +177 -0
- package/src/adapters/opencode/commands/cf-task/status.md +63 -0
- package/src/adapters/opencode/commands/cf-validate.md +77 -0
- package/src/adapters/opencode/opencode.json +4 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +132 -0
- package/src/adapters/opencode/plugins/code-flow/package.json +6 -0
- package/src/cli.js +810 -0
- package/src/core/code-flow/config.yml +159 -0
- package/src/core/code-flow/scripts/cf_checks.py +306 -0
- package/src/core/code-flow/scripts/cf_core.py +954 -0
- package/src/core/code-flow/scripts/cf_feedback.py +56 -0
- package/src/core/code-flow/scripts/cf_inject_hook.py +178 -0
- package/src/core/code-flow/scripts/cf_log.py +146 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +196 -0
- package/src/core/code-flow/scripts/cf_scan.py +293 -0
- package/src/core/code-flow/scripts/cf_session_hook.py +42 -0
- package/src/core/code-flow/scripts/cf_stats.py +439 -0
- package/src/core/code-flow/scripts/cf_stop_hook.py +191 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +402 -0
- package/src/core/code-flow/specs/backend/_map.md +56 -0
- package/src/core/code-flow/specs/backend/code-quality-performance.md +47 -0
- package/src/core/code-flow/specs/backend/database.md +39 -0
- package/src/core/code-flow/specs/backend/directory-structure.md +42 -0
- package/src/core/code-flow/specs/backend/logging.md +38 -0
- package/src/core/code-flow/specs/backend/platform-rules.md +43 -0
- package/src/core/code-flow/specs/frontend/_map.md +52 -0
- package/src/core/code-flow/specs/frontend/component-specs.md +56 -0
- package/src/core/code-flow/specs/frontend/directory-structure.md +41 -0
- package/src/core/code-flow/specs/frontend/quality-standards.md +46 -0
- package/src/core/code-flow/specs/shared/_map.md +57 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +247 -0
- package/src/core/code-flow/specs/shared/design/design-full.md +494 -0
- package/src/core/code-flow/specs/shared/design/design-lite.md +293 -0
- package/src/core/code-flow/specs/shared/prd-template.md +187 -0
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- 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()
|