@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,954 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import fnmatch
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# --- Config cache (fix #3: avoid re-parsing YAML on every hook call) ---
|
|
9
|
+
|
|
10
|
+
_config_cache: dict = {}
|
|
11
|
+
_spec_domains_cache: dict = {}
|
|
12
|
+
_effective_mapping_cache: dict = {}
|
|
13
|
+
_ext_set_cache: dict = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config(project_root: str) -> dict:
|
|
17
|
+
config_path = os.path.join(project_root, ".code-flow", "config.yml")
|
|
18
|
+
if not os.path.exists(config_path):
|
|
19
|
+
return {}
|
|
20
|
+
try:
|
|
21
|
+
mtime = os.path.getmtime(config_path)
|
|
22
|
+
except OSError:
|
|
23
|
+
return {}
|
|
24
|
+
cached = _config_cache.get(config_path)
|
|
25
|
+
if cached and cached["mtime"] == mtime:
|
|
26
|
+
return cached["data"]
|
|
27
|
+
try:
|
|
28
|
+
import yaml
|
|
29
|
+
except Exception:
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
with open(config_path, "r", encoding="utf-8") as file:
|
|
33
|
+
data = yaml.safe_load(file)
|
|
34
|
+
result = data or {}
|
|
35
|
+
_config_cache[config_path] = {"mtime": mtime, "data": result}
|
|
36
|
+
return result
|
|
37
|
+
except Exception:
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def estimate_tokens(text: str) -> int:
|
|
42
|
+
return len(text) // 4
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_path(path: str) -> str:
|
|
46
|
+
# Unconditional backslash replacement, not `os.sep`, so paths pasted from
|
|
47
|
+
# a Windows machine into a prompt on macOS/Linux still normalize correctly.
|
|
48
|
+
# On Windows `os.sep == '\\'`, so behavior there is unchanged.
|
|
49
|
+
return path.replace("\\", "/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _spec_path_from_entry(entry) -> str:
|
|
53
|
+
cfg = normalize_spec_entry(entry)
|
|
54
|
+
return normalize_path(cfg.get("path", ""))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def discover_spec_domains(project_root: str) -> dict:
|
|
58
|
+
cached = _spec_domains_cache.get(project_root)
|
|
59
|
+
if cached is not None:
|
|
60
|
+
return cached
|
|
61
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
62
|
+
discovered = {}
|
|
63
|
+
if not os.path.isdir(specs_root):
|
|
64
|
+
_spec_domains_cache[project_root] = discovered
|
|
65
|
+
return discovered
|
|
66
|
+
|
|
67
|
+
for root, _, files in os.walk(specs_root):
|
|
68
|
+
for filename in files:
|
|
69
|
+
if not filename.endswith(".md"):
|
|
70
|
+
continue
|
|
71
|
+
full_path = os.path.join(root, filename)
|
|
72
|
+
rel = normalize_path(os.path.relpath(full_path, specs_root))
|
|
73
|
+
parts = rel.split("/", 1)
|
|
74
|
+
if len(parts) < 2:
|
|
75
|
+
continue
|
|
76
|
+
domain = parts[0]
|
|
77
|
+
discovered.setdefault(domain, []).append(rel)
|
|
78
|
+
|
|
79
|
+
for domain in discovered:
|
|
80
|
+
discovered[domain] = sorted(set(discovered[domain]))
|
|
81
|
+
_spec_domains_cache[project_root] = discovered
|
|
82
|
+
return discovered
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _default_spec_entry(rel: str) -> dict:
|
|
86
|
+
tier = 0 if rel.endswith("/_map.md") else 1
|
|
87
|
+
# shared/ 下非 _map.md 的文件是供 cf-task:align / cf-task:prd 命令
|
|
88
|
+
# 显式 Read 的模板,不是约束规范,禁止通配符自动注入。
|
|
89
|
+
if rel.startswith("shared/") and not rel.endswith("/_map.md"):
|
|
90
|
+
return {"path": rel, "tags": [], "tier": tier}
|
|
91
|
+
return {"path": rel, "tags": ["*"], "tier": tier}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_effective_mapping(project_root: str, mapping: dict) -> dict:
|
|
95
|
+
cache_key = (project_root, id(mapping))
|
|
96
|
+
cached = _effective_mapping_cache.get(cache_key)
|
|
97
|
+
if cached is not None:
|
|
98
|
+
return cached
|
|
99
|
+
discovered = discover_spec_domains(project_root)
|
|
100
|
+
effective = {}
|
|
101
|
+
|
|
102
|
+
for domain, rel_paths in discovered.items():
|
|
103
|
+
source_cfg = mapping.get(domain) or {}
|
|
104
|
+
normalized_specs = []
|
|
105
|
+
seen = set()
|
|
106
|
+
|
|
107
|
+
for entry in source_cfg.get("specs") or []:
|
|
108
|
+
rel = _spec_path_from_entry(entry)
|
|
109
|
+
if not rel or rel in seen:
|
|
110
|
+
continue
|
|
111
|
+
normalized_specs.append(normalize_spec_entry(entry))
|
|
112
|
+
seen.add(rel)
|
|
113
|
+
|
|
114
|
+
for rel in rel_paths:
|
|
115
|
+
if rel in seen:
|
|
116
|
+
continue
|
|
117
|
+
normalized_specs.append(_default_spec_entry(rel))
|
|
118
|
+
seen.add(rel)
|
|
119
|
+
|
|
120
|
+
effective[domain] = {
|
|
121
|
+
"patterns": source_cfg.get("patterns") or [],
|
|
122
|
+
"specs": normalized_specs,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for domain, source_cfg in (mapping or {}).items():
|
|
126
|
+
if domain in effective:
|
|
127
|
+
continue
|
|
128
|
+
normalized_specs = []
|
|
129
|
+
seen = set()
|
|
130
|
+
for entry in source_cfg.get("specs") or []:
|
|
131
|
+
rel = _spec_path_from_entry(entry)
|
|
132
|
+
if not rel or rel in seen:
|
|
133
|
+
continue
|
|
134
|
+
normalized_specs.append(normalize_spec_entry(entry))
|
|
135
|
+
seen.add(rel)
|
|
136
|
+
effective[domain] = {
|
|
137
|
+
"patterns": source_cfg.get("patterns") or [],
|
|
138
|
+
"specs": normalized_specs,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_effective_mapping_cache[cache_key] = effective
|
|
142
|
+
return effective
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fallback_domains_for_context(mapping: dict, context_tags: set) -> set:
|
|
146
|
+
"""Search space for per-spec tag matching when no path pattern fires.
|
|
147
|
+
|
|
148
|
+
Returns domains whose name matches context_tags when possible; otherwise
|
|
149
|
+
returns every configured domain so that per-spec tag matching downstream
|
|
150
|
+
can still cherry-pick. The per-spec tags in config.yml remain the real
|
|
151
|
+
filter — this function only widens the search space, never injects.
|
|
152
|
+
"""
|
|
153
|
+
if not mapping:
|
|
154
|
+
return set()
|
|
155
|
+
by_name = {domain for domain in mapping.keys() if domain.lower() in context_tags}
|
|
156
|
+
if by_name:
|
|
157
|
+
return by_name
|
|
158
|
+
return set(mapping.keys())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_code_file(rel_path: str, inject_config: dict) -> bool:
|
|
162
|
+
rel_path = normalize_path(rel_path)
|
|
163
|
+
for pattern in inject_config.get("skip_paths") or []:
|
|
164
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
165
|
+
return False
|
|
166
|
+
_, ext = os.path.splitext(rel_path)
|
|
167
|
+
if ext in (inject_config.get("skip_extensions") or []):
|
|
168
|
+
return False
|
|
169
|
+
code_exts = inject_config.get("code_extensions") or []
|
|
170
|
+
ext_set = _ext_set_cache.get(id(code_exts))
|
|
171
|
+
if ext_set is None:
|
|
172
|
+
ext_set = frozenset(code_exts)
|
|
173
|
+
_ext_set_cache[id(code_exts)] = ext_set
|
|
174
|
+
return ext in ext_set
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def match_domains(rel_path: str, mapping: dict) -> list:
|
|
178
|
+
rel_path = normalize_path(rel_path)
|
|
179
|
+
domains = []
|
|
180
|
+
for domain, cfg in (mapping or {}).items():
|
|
181
|
+
patterns = cfg.get("patterns") or []
|
|
182
|
+
for pattern in patterns:
|
|
183
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
184
|
+
domains.append(domain)
|
|
185
|
+
break
|
|
186
|
+
return domains
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- Fix #2: safe depluralization with allowlist ---
|
|
190
|
+
|
|
191
|
+
_SAFE_DEPLURALS = {
|
|
192
|
+
"models": "model",
|
|
193
|
+
"services": "service",
|
|
194
|
+
"components": "component",
|
|
195
|
+
"handlers": "handler",
|
|
196
|
+
"controllers": "controller",
|
|
197
|
+
"middlewares": "middleware",
|
|
198
|
+
"validators": "validator",
|
|
199
|
+
"schemas": "schema",
|
|
200
|
+
"repositories": "repository",
|
|
201
|
+
"migrations": "migration",
|
|
202
|
+
"fixtures": "fixture",
|
|
203
|
+
"plugins": "plugin",
|
|
204
|
+
"routes": "route",
|
|
205
|
+
"routers": "router",
|
|
206
|
+
"hooks": "hook",
|
|
207
|
+
"pages": "page",
|
|
208
|
+
"stores": "store",
|
|
209
|
+
"styles": "style",
|
|
210
|
+
"types": "type",
|
|
211
|
+
"configs": "config",
|
|
212
|
+
"scripts": "script",
|
|
213
|
+
"tasks": "task",
|
|
214
|
+
"specs": "spec",
|
|
215
|
+
"tests": "test",
|
|
216
|
+
"utils": "util",
|
|
217
|
+
"helpers": "helper",
|
|
218
|
+
"views": "view",
|
|
219
|
+
"templates": "template",
|
|
220
|
+
"errors": "error",
|
|
221
|
+
"exceptions": "exception",
|
|
222
|
+
"docs": "doc",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# --- Fix #1: semantic directory → concept tag mapping ---
|
|
226
|
+
|
|
227
|
+
_DIR_SEMANTIC_TAGS = {
|
|
228
|
+
"handlers": ["api", "error"],
|
|
229
|
+
"controllers": ["api"],
|
|
230
|
+
"middleware": ["api", "config"],
|
|
231
|
+
"middlewares": ["api", "config"],
|
|
232
|
+
"routers": ["api", "route"],
|
|
233
|
+
"routes": ["api", "route"],
|
|
234
|
+
"views": ["ui", "render"],
|
|
235
|
+
"templates": ["ui", "render"],
|
|
236
|
+
"models": ["model", "database", "orm", "schema"],
|
|
237
|
+
"model": ["model", "database", "orm", "schema"],
|
|
238
|
+
"schemas": ["model", "schema", "database"],
|
|
239
|
+
"migrations": ["database", "migration"],
|
|
240
|
+
"repositories": ["database", "query"],
|
|
241
|
+
"dao": ["database", "query"],
|
|
242
|
+
"validators": ["quality", "error"],
|
|
243
|
+
"exceptions": ["error", "exception"],
|
|
244
|
+
"errors": ["error", "exception"],
|
|
245
|
+
"auth": ["api", "config"],
|
|
246
|
+
"config": ["config", "deploy"],
|
|
247
|
+
"configs": ["config", "deploy"],
|
|
248
|
+
"settings": ["config"],
|
|
249
|
+
"tests": ["test", "quality"],
|
|
250
|
+
"test": ["test", "quality"],
|
|
251
|
+
"utils": ["quality"],
|
|
252
|
+
"helpers": ["quality"],
|
|
253
|
+
"lib": ["quality"],
|
|
254
|
+
"common": ["quality"],
|
|
255
|
+
"shared": ["quality"],
|
|
256
|
+
"core": ["quality"],
|
|
257
|
+
"logging": ["log", "logging"],
|
|
258
|
+
"logger": ["log", "logging"],
|
|
259
|
+
"logs": ["log", "logging"],
|
|
260
|
+
"cache": ["cache", "performance"],
|
|
261
|
+
"queue": ["performance"],
|
|
262
|
+
"jobs": ["performance"],
|
|
263
|
+
"workers": ["performance"],
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_context_tags(rel_path: str) -> set:
|
|
268
|
+
"""Extract context tags from a file path for spec matching.
|
|
269
|
+
|
|
270
|
+
Uses three strategies:
|
|
271
|
+
1. Directory names as tags (with safe depluralization)
|
|
272
|
+
2. Semantic mapping: common directory names → concept tags
|
|
273
|
+
3. Filename stem word splitting
|
|
274
|
+
"""
|
|
275
|
+
rel_path = normalize_path(rel_path)
|
|
276
|
+
tags = set()
|
|
277
|
+
parts = rel_path.split("/")
|
|
278
|
+
|
|
279
|
+
for part in parts[:-1]:
|
|
280
|
+
lower = part.lower()
|
|
281
|
+
tags.add(lower)
|
|
282
|
+
deplural = _SAFE_DEPLURALS.get(lower)
|
|
283
|
+
if deplural:
|
|
284
|
+
tags.add(deplural)
|
|
285
|
+
semantic = _DIR_SEMANTIC_TAGS.get(lower)
|
|
286
|
+
if semantic:
|
|
287
|
+
tags.update(semantic)
|
|
288
|
+
|
|
289
|
+
filename = parts[-1] if parts else ""
|
|
290
|
+
stem = os.path.splitext(filename)[0].lower()
|
|
291
|
+
if stem:
|
|
292
|
+
words = _FILENAME_WORDS_RE.findall(stem.replace("_", " ").replace("-", " "))
|
|
293
|
+
tags.update(words)
|
|
294
|
+
for word in words:
|
|
295
|
+
semantic = _DIR_SEMANTIC_TAGS.get(word)
|
|
296
|
+
if semantic:
|
|
297
|
+
tags.update(semantic)
|
|
298
|
+
|
|
299
|
+
return tags
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --- Fix #5: prompt-text → tag mapping (bilingual aliases) ---
|
|
303
|
+
#
|
|
304
|
+
# Maps each canonical English tag (as used in .code-flow/config.yml spec tags)
|
|
305
|
+
# to a list of substrings we look for in user prompt text. When a user writes
|
|
306
|
+
# "写一个用户登录服务,注意性能和异常处理", "性能" → performance and "异常" →
|
|
307
|
+
# exception, so the corresponding specs get injected at prompt submission time
|
|
308
|
+
# instead of waiting for the Edit/Write tool call.
|
|
309
|
+
#
|
|
310
|
+
# Kept in Python (not config.yml) because .code-flow/config.yml is category
|
|
311
|
+
# 'merge' in cli.js — existing installs wouldn't pick up new aliases on
|
|
312
|
+
# upgrade. cf_core.py is category 'tool' so it's overwritten on upgrade.
|
|
313
|
+
_TAG_ALIASES = {
|
|
314
|
+
"quality": ["质量", "代码质量", "规范"],
|
|
315
|
+
"performance": ["性能", "优化", "效率", "perf"],
|
|
316
|
+
"error": ["错误", "报错"],
|
|
317
|
+
"exception": ["异常", "异常处理"],
|
|
318
|
+
"test": ["测试", "单元测试", "unit test"],
|
|
319
|
+
"timeout": ["超时"],
|
|
320
|
+
"retry": ["重试"],
|
|
321
|
+
"cache": ["缓存"],
|
|
322
|
+
"log": ["日志", "日志记录", "打印日志"],
|
|
323
|
+
"logging": ["日志", "日志记录"],
|
|
324
|
+
"database": ["数据库", "db"],
|
|
325
|
+
"query": ["查询", "sql"],
|
|
326
|
+
"migration": ["迁移"],
|
|
327
|
+
"schema": ["数据模型", "模式"],
|
|
328
|
+
"api": ["接口", "api"],
|
|
329
|
+
"deploy": ["部署", "发布"],
|
|
330
|
+
"config": ["配置"],
|
|
331
|
+
"component": ["组件"],
|
|
332
|
+
"render": ["渲染"],
|
|
333
|
+
"ui": ["界面", "ui", "样式"],
|
|
334
|
+
"route": ["路由"],
|
|
335
|
+
"page": ["页面"],
|
|
336
|
+
"state": ["状态"],
|
|
337
|
+
"hook": ["hook", "钩子"],
|
|
338
|
+
"inject": ["inject", "注入", "注入规范"],
|
|
339
|
+
"spec": ["spec", "规范", "约束"],
|
|
340
|
+
"scan": ["scan", "扫描", "审计"],
|
|
341
|
+
"stats": ["stats", "统计"],
|
|
342
|
+
"session": ["session", "会话"],
|
|
343
|
+
"init": ["init", "初始化"],
|
|
344
|
+
"upgrade": ["upgrade", "升级"],
|
|
345
|
+
"merge": ["merge", "合并"],
|
|
346
|
+
"platform": ["platform", "平台", "适配器"],
|
|
347
|
+
"adapter": ["adapter", "适配器"],
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_SHORT_ASCII_ALIAS_THRESHOLD = 3
|
|
351
|
+
|
|
352
|
+
def _is_short_ascii(token: str) -> bool:
|
|
353
|
+
if len(token) > _SHORT_ASCII_ALIAS_THRESHOLD:
|
|
354
|
+
return False
|
|
355
|
+
try:
|
|
356
|
+
token.encode("ascii")
|
|
357
|
+
return True
|
|
358
|
+
except UnicodeEncodeError:
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Precompute (needle, is_short_ascii, compiled_pattern_or_None) at import time
|
|
363
|
+
# so extract_prompt_tags() is a pure lookup loop with zero per-call overhead.
|
|
364
|
+
_PREPARED_ALIASES: list = []
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _init_prepared_aliases() -> None:
|
|
368
|
+
for canonical, aliases in _TAG_ALIASES.items():
|
|
369
|
+
entries = []
|
|
370
|
+
for alias in [canonical, *aliases]:
|
|
371
|
+
needle = alias.lower()
|
|
372
|
+
if _is_short_ascii(needle):
|
|
373
|
+
pattern = re.compile(r"\b" + re.escape(needle) + r"\b", re.IGNORECASE)
|
|
374
|
+
entries.append((needle, True, pattern))
|
|
375
|
+
else:
|
|
376
|
+
entries.append((needle, False, None))
|
|
377
|
+
_PREPARED_ALIASES.append((canonical, entries))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
_init_prepared_aliases()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def extract_prompt_tags(prompt_text) -> set:
|
|
384
|
+
"""Scan prompt text for alias hits and return canonical tags.
|
|
385
|
+
|
|
386
|
+
- Lowercases ASCII (Chinese is unaffected by .lower()).
|
|
387
|
+
- Short ASCII aliases matched with word-boundary regex (precompiled at import).
|
|
388
|
+
- Chinese aliases and long English aliases use plain substring match.
|
|
389
|
+
- Silent on empty/non-string input.
|
|
390
|
+
"""
|
|
391
|
+
if not isinstance(prompt_text, str) or not prompt_text.strip():
|
|
392
|
+
return set()
|
|
393
|
+
lower = prompt_text.lower()
|
|
394
|
+
hits: set = set()
|
|
395
|
+
for canonical, entries in _PREPARED_ALIASES:
|
|
396
|
+
for needle, is_short, pattern in entries:
|
|
397
|
+
if is_short:
|
|
398
|
+
if pattern.search(lower): # type: ignore[union-attr]
|
|
399
|
+
hits.add(canonical)
|
|
400
|
+
break
|
|
401
|
+
else:
|
|
402
|
+
if needle in lower:
|
|
403
|
+
hits.add(canonical)
|
|
404
|
+
break
|
|
405
|
+
return hits
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def normalize_spec_entry(entry) -> dict:
|
|
409
|
+
"""Normalize spec config entry. Supports both old (string) and new (dict) format."""
|
|
410
|
+
if isinstance(entry, str):
|
|
411
|
+
return {"path": entry, "tags": ["*"], "tier": 1}
|
|
412
|
+
if isinstance(entry, dict):
|
|
413
|
+
return {
|
|
414
|
+
"path": entry.get("path", ""),
|
|
415
|
+
"tags": entry.get("tags", ["*"]),
|
|
416
|
+
"tier": entry.get("tier", 1),
|
|
417
|
+
}
|
|
418
|
+
return {}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def match_specs_by_tags(
|
|
422
|
+
specs_config: list, context_tags: set, prompt_tags: set = None
|
|
423
|
+
) -> tuple:
|
|
424
|
+
"""Return (matched_specs, has_tier1_match).
|
|
425
|
+
|
|
426
|
+
Wildcard tag '*' always matches (used by tier 0 specs like _map.md).
|
|
427
|
+
prompt_tags (if provided) are unioned with context_tags so that Chinese/
|
|
428
|
+
English keywords from the user prompt can also gate tier 1 specs.
|
|
429
|
+
The caller uses has_tier1_match to decide whether to fallback to all specs.
|
|
430
|
+
"""
|
|
431
|
+
effective_tags = context_tags if prompt_tags is None else (context_tags | prompt_tags)
|
|
432
|
+
matched = []
|
|
433
|
+
has_tier1_match = False
|
|
434
|
+
for entry in specs_config:
|
|
435
|
+
cfg = normalize_spec_entry(entry)
|
|
436
|
+
if not cfg.get("path"):
|
|
437
|
+
continue
|
|
438
|
+
spec_tags = set(cfg.get("tags") or [])
|
|
439
|
+
if "*" in spec_tags:
|
|
440
|
+
matched.append(cfg)
|
|
441
|
+
elif spec_tags & effective_tags:
|
|
442
|
+
matched.append(cfg)
|
|
443
|
+
if cfg.get("tier", 1) != 0:
|
|
444
|
+
has_tier1_match = True
|
|
445
|
+
return matched, has_tier1_match
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
_FILENAME_WORDS_RE = re.compile(r"[a-z]+")
|
|
449
|
+
_BULLET_PREFIXES = ("- ", "* ", "+ ")
|
|
450
|
+
_COMPRESS_HTML_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
451
|
+
_COMPRESS_TRAILING_WS_RE = re.compile(r"[ \t]+$", re.MULTILINE)
|
|
452
|
+
_COMPRESS_BLANK_LINES_RE = re.compile(r"\n{3,}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def compress_content(text: str) -> str:
|
|
456
|
+
"""Lossless compression for spec content injected at Hook time.
|
|
457
|
+
|
|
458
|
+
Five conservative, semantics-preserving transforms:
|
|
459
|
+
1. Strip multi-line HTML comments ``<!-- ... -->``.
|
|
460
|
+
2. Strip trailing whitespace on every line.
|
|
461
|
+
3. Collapse runs of 2+ blank lines into a single blank line.
|
|
462
|
+
4. Drop a bullet line ( ``-`` / ``*`` / ``+`` ) equal to the previous line.
|
|
463
|
+
5. Strip leading/trailing blank lines of the whole text.
|
|
464
|
+
|
|
465
|
+
Idempotent: ``compress_content(compress_content(x)) == compress_content(x)``.
|
|
466
|
+
On any exception, log to stderr and return the original text so Hook
|
|
467
|
+
injection is never broken by a compression bug.
|
|
468
|
+
"""
|
|
469
|
+
if not isinstance(text, str) or not text:
|
|
470
|
+
return text
|
|
471
|
+
try:
|
|
472
|
+
result = _COMPRESS_HTML_RE.sub("", text)
|
|
473
|
+
result = _COMPRESS_TRAILING_WS_RE.sub("", result)
|
|
474
|
+
result = _COMPRESS_BLANK_LINES_RE.sub("\n\n", result)
|
|
475
|
+
lines = result.split("\n")
|
|
476
|
+
out_lines: list = []
|
|
477
|
+
prev_line: str = ""
|
|
478
|
+
in_fence = False
|
|
479
|
+
for line in lines:
|
|
480
|
+
stripped = line.lstrip()
|
|
481
|
+
if stripped.startswith("```"):
|
|
482
|
+
in_fence = not in_fence
|
|
483
|
+
# never dedup inside fenced code blocks — ✅/❌ example snippets
|
|
484
|
+
# may legitimately repeat identical bullet-looking lines (FEAT-07)
|
|
485
|
+
is_bullet = (not in_fence) and stripped.startswith(_BULLET_PREFIXES)
|
|
486
|
+
if is_bullet and out_lines and line == prev_line:
|
|
487
|
+
continue
|
|
488
|
+
out_lines.append(line)
|
|
489
|
+
prev_line = line
|
|
490
|
+
result = "\n".join(out_lines)
|
|
491
|
+
return result.strip()
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
_log(f"compress_content error: {exc}")
|
|
494
|
+
return text
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def read_matched_specs(
|
|
498
|
+
project_root: str,
|
|
499
|
+
domain: str,
|
|
500
|
+
matched: list,
|
|
501
|
+
compress: bool = True,
|
|
502
|
+
) -> list:
|
|
503
|
+
"""Read matched spec files; optionally apply lossless compression.
|
|
504
|
+
|
|
505
|
+
When ``compress`` is True (default), each spec's content is passed through
|
|
506
|
+
:func:`compress_content` before token estimation so ``select_specs_tiered``
|
|
507
|
+
budget decisions benefit from compression savings.
|
|
508
|
+
|
|
509
|
+
Returned items carry:
|
|
510
|
+
- ``content``: final content (compressed when ``compress=True``)
|
|
511
|
+
- ``tokens``: token count of final content (drives budget decisions)
|
|
512
|
+
- ``tokens_raw``: token count of uncompressed content (for cf-stats)
|
|
513
|
+
|
|
514
|
+
``CF_DEBUG=1`` emits a per-file ``compress path=... raw=... compressed=...
|
|
515
|
+
saved=...%`` record via :func:`debug_log`.
|
|
516
|
+
"""
|
|
517
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
518
|
+
specs: list = []
|
|
519
|
+
for cfg in matched:
|
|
520
|
+
rel = cfg["path"]
|
|
521
|
+
spec_path = os.path.join(specs_root, rel)
|
|
522
|
+
try:
|
|
523
|
+
with open(spec_path, "r", encoding="utf-8") as f:
|
|
524
|
+
raw_content = f.read().strip()
|
|
525
|
+
# Frontmatter feeds the spec catalog only — never spend injection
|
|
526
|
+
# budget on it. Specs without frontmatter pass through unchanged.
|
|
527
|
+
_, raw_content = parse_spec_frontmatter(raw_content)
|
|
528
|
+
raw_content = raw_content.strip()
|
|
529
|
+
if not raw_content:
|
|
530
|
+
continue
|
|
531
|
+
raw_tokens = estimate_tokens(raw_content)
|
|
532
|
+
if compress:
|
|
533
|
+
try:
|
|
534
|
+
content = compress_content(raw_content)
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
_log(f"read_matched_specs compress failed path={rel}: {exc}")
|
|
537
|
+
content = raw_content
|
|
538
|
+
else:
|
|
539
|
+
content = raw_content
|
|
540
|
+
tokens = estimate_tokens(content)
|
|
541
|
+
if compress and raw_tokens != tokens:
|
|
542
|
+
saved_pct = (
|
|
543
|
+
round((raw_tokens - tokens) * 100 / raw_tokens, 1)
|
|
544
|
+
if raw_tokens
|
|
545
|
+
else 0.0
|
|
546
|
+
)
|
|
547
|
+
debug_log(
|
|
548
|
+
f"compress path={rel} raw={raw_tokens} "
|
|
549
|
+
f"compressed={tokens} saved={saved_pct}%",
|
|
550
|
+
project_root,
|
|
551
|
+
)
|
|
552
|
+
specs.append(
|
|
553
|
+
{
|
|
554
|
+
"path": rel,
|
|
555
|
+
"content": content,
|
|
556
|
+
"tokens": tokens,
|
|
557
|
+
"tokens_raw": raw_tokens,
|
|
558
|
+
"domain": domain,
|
|
559
|
+
"tier": cfg.get("tier", 1),
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
except Exception:
|
|
563
|
+
continue
|
|
564
|
+
return specs
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# --- Kept for backward compatibility with old config format ---
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def read_specs(project_root: str, domain: str, domain_cfg: dict) -> list:
|
|
571
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
572
|
+
specs = []
|
|
573
|
+
for entry in domain_cfg.get("specs") or []:
|
|
574
|
+
cfg = normalize_spec_entry(entry)
|
|
575
|
+
rel = cfg.get("path", "")
|
|
576
|
+
if not rel:
|
|
577
|
+
continue
|
|
578
|
+
spec_path = os.path.join(specs_root, rel)
|
|
579
|
+
try:
|
|
580
|
+
with open(spec_path, "r", encoding="utf-8") as file:
|
|
581
|
+
content = file.read().strip()
|
|
582
|
+
if not content:
|
|
583
|
+
continue
|
|
584
|
+
specs.append(
|
|
585
|
+
{
|
|
586
|
+
"path": rel,
|
|
587
|
+
"content": content,
|
|
588
|
+
"tokens": estimate_tokens(content),
|
|
589
|
+
"domain": domain,
|
|
590
|
+
"tier": cfg.get("tier", 1),
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
except Exception:
|
|
594
|
+
continue
|
|
595
|
+
return specs
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def select_specs(specs: list, budget: int, priorities: dict) -> list:
|
|
599
|
+
"""Legacy select by priority. Used by cf-inject manual command."""
|
|
600
|
+
if budget <= 0:
|
|
601
|
+
return []
|
|
602
|
+
|
|
603
|
+
def priority(spec: dict) -> int:
|
|
604
|
+
value = priorities.get(spec.get("path"))
|
|
605
|
+
if isinstance(value, int):
|
|
606
|
+
return value
|
|
607
|
+
try:
|
|
608
|
+
return int(value)
|
|
609
|
+
except Exception:
|
|
610
|
+
return 1000
|
|
611
|
+
|
|
612
|
+
ordered = sorted(specs, key=lambda spec: (priority(spec), spec.get("path", "")))
|
|
613
|
+
selected = []
|
|
614
|
+
total = 0
|
|
615
|
+
for spec in ordered:
|
|
616
|
+
if total + spec.get("tokens", 0) <= budget:
|
|
617
|
+
selected.append(spec)
|
|
618
|
+
total += spec.get("tokens", 0)
|
|
619
|
+
return selected
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def select_specs_tiered(specs: list, budget: int, map_max: int = 400) -> list:
|
|
623
|
+
"""Tier-aware spec selection.
|
|
624
|
+
|
|
625
|
+
Tier 0: included if within map_max budget (fix #4).
|
|
626
|
+
Tier 1: budget-controlled, ordered by list position (preserved).
|
|
627
|
+
"""
|
|
628
|
+
tier0 = [s for s in specs if s.get("tier", 1) == 0]
|
|
629
|
+
tier1 = [s for s in specs if s.get("tier", 1) != 0]
|
|
630
|
+
|
|
631
|
+
selected = []
|
|
632
|
+
for spec in tier0:
|
|
633
|
+
if spec.get("tokens", 0) <= map_max:
|
|
634
|
+
selected.append(spec)
|
|
635
|
+
else:
|
|
636
|
+
_log(
|
|
637
|
+
f"WARNING: {spec['path']} exceeds map_max budget "
|
|
638
|
+
f"({spec['tokens']} > {map_max} tokens), skipped"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
total = 0
|
|
642
|
+
for spec in tier1:
|
|
643
|
+
tokens = spec.get("tokens", 0)
|
|
644
|
+
if total + tokens <= budget:
|
|
645
|
+
selected.append(spec)
|
|
646
|
+
total += tokens
|
|
647
|
+
return selected
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def resolve_inject_mode(inject_config: dict) -> str:
|
|
651
|
+
"""Resolve inject.mode: only the literal string "catalog" enables catalog mode.
|
|
652
|
+
|
|
653
|
+
Missing / None / any other value → "full" (pre-0.5 behavior), so existing
|
|
654
|
+
user configs keep byte-identical injection after upgrade; new `code-flow
|
|
655
|
+
init` deployments opt in via the config template. Mirrors the
|
|
656
|
+
resolve_compress philosophy: only an explicit literal flips behavior.
|
|
657
|
+
"""
|
|
658
|
+
if not isinstance(inject_config, dict):
|
|
659
|
+
return "full"
|
|
660
|
+
mode = inject_config.get("mode")
|
|
661
|
+
if isinstance(mode, str) and mode.strip().lower() == "catalog":
|
|
662
|
+
return "catalog"
|
|
663
|
+
return "full"
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def non_injectable_specs(effective_mapping: dict) -> set:
|
|
667
|
+
"""tags: [] 的命令专用模板(如 shared 的 PRD/设计模板)——永不自动注入,
|
|
668
|
+
预算统计(cf-stats/cf-scan)必须排除,避免利用率虚高。"""
|
|
669
|
+
excluded = set()
|
|
670
|
+
for domain_cfg in (effective_mapping or {}).values():
|
|
671
|
+
for entry in (domain_cfg or {}).get("specs") or []:
|
|
672
|
+
cfg = normalize_spec_entry(entry)
|
|
673
|
+
if cfg.get("path") and not cfg.get("tags"):
|
|
674
|
+
excluded.add(cfg["path"])
|
|
675
|
+
return excluded
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def resolve_quality_loop(config: dict) -> dict:
|
|
679
|
+
"""Resolve quality_loop feature switches with safe defaults (RULE-06).
|
|
680
|
+
|
|
681
|
+
Master `enabled`: only literal True enables — missing/other keeps the
|
|
682
|
+
pre-0.6 behavior for upgraded users; new inits opt in via the config
|
|
683
|
+
template. Sub-switches follow the master and only literal False disables
|
|
684
|
+
them (resolve_compress philosophy).
|
|
685
|
+
"""
|
|
686
|
+
if not isinstance(config, dict):
|
|
687
|
+
return {"enabled": False, "post_check": False,
|
|
688
|
+
"stop_check": False, "correction_capture": False}
|
|
689
|
+
cfg = config.get("quality_loop")
|
|
690
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
691
|
+
enabled = cfg.get("enabled") is True
|
|
692
|
+
|
|
693
|
+
def _sub(key: str) -> bool:
|
|
694
|
+
return enabled and cfg.get(key) is not False
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
"enabled": enabled,
|
|
698
|
+
"post_check": _sub("post_check"),
|
|
699
|
+
"stop_check": _sub("stop_check"),
|
|
700
|
+
"correction_capture": _sub("correction_capture"),
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def parse_spec_frontmatter(content: str) -> tuple:
|
|
708
|
+
"""Split optional YAML frontmatter from spec content → (meta, body).
|
|
709
|
+
|
|
710
|
+
Parses only flat `key: value` string pairs — enough for `description:` —
|
|
711
|
+
so the hook hot path never needs pyyaml for this. Content without
|
|
712
|
+
frontmatter returns ({}, content) unchanged.
|
|
713
|
+
"""
|
|
714
|
+
if not isinstance(content, str):
|
|
715
|
+
return {}, content
|
|
716
|
+
match = _FRONTMATTER_RE.match(content)
|
|
717
|
+
if not match:
|
|
718
|
+
return {}, content
|
|
719
|
+
meta: dict = {}
|
|
720
|
+
for line in match.group(1).splitlines():
|
|
721
|
+
key, sep, value = line.partition(":")
|
|
722
|
+
if not sep:
|
|
723
|
+
continue
|
|
724
|
+
key = key.strip()
|
|
725
|
+
if key:
|
|
726
|
+
meta[key] = value.strip().strip('"').strip("'")
|
|
727
|
+
return meta, content[match.end():]
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def spec_description(content: str) -> str:
|
|
731
|
+
"""One-line applicability summary for the spec catalog.
|
|
732
|
+
|
|
733
|
+
Priority: frontmatter `description:` → first blockquote line → H1 text.
|
|
734
|
+
Returns "" when none is found (cf-scan flags those specs).
|
|
735
|
+
"""
|
|
736
|
+
meta, body = parse_spec_frontmatter(content)
|
|
737
|
+
desc = (meta.get("description") or "").strip()
|
|
738
|
+
if desc:
|
|
739
|
+
return desc
|
|
740
|
+
h1 = ""
|
|
741
|
+
for line in body.splitlines():
|
|
742
|
+
stripped = line.strip()
|
|
743
|
+
if stripped.startswith(">"):
|
|
744
|
+
return stripped.lstrip("> ").strip()
|
|
745
|
+
if not h1 and stripped.startswith("# "):
|
|
746
|
+
h1 = stripped[2:].strip()
|
|
747
|
+
return h1
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
_catalog_cache: dict = {}
|
|
751
|
+
|
|
752
|
+
CATALOG_HEADER = (
|
|
753
|
+
"## Spec Catalog (code-flow)\n\n"
|
|
754
|
+
"以下规范文件位于 `.code-flow/specs/`。开始编码或修改文件前,"
|
|
755
|
+
"必须先 Read 与当前任务场景匹配的 spec 全文;与场景无关的不要读取。"
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
|
|
760
|
+
"""Collect injectable specs as (tier, rel_path, abs_path) sorted tier1-first."""
|
|
761
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
762
|
+
entries = []
|
|
763
|
+
for domain in sorted(effective_mapping.keys()):
|
|
764
|
+
for entry in (effective_mapping[domain] or {}).get("specs") or []:
|
|
765
|
+
cfg = normalize_spec_entry(entry)
|
|
766
|
+
rel = cfg.get("path", "")
|
|
767
|
+
# tags: [] marks command-only templates — never advertised either
|
|
768
|
+
if not rel or not cfg.get("tags"):
|
|
769
|
+
continue
|
|
770
|
+
entries.append((cfg.get("tier", 1), rel, os.path.join(specs_root, rel)))
|
|
771
|
+
# Tier 1 constraints first so budget truncation drops navigation maps first
|
|
772
|
+
entries.sort(key=lambda item: (item[0] != 1, item[1]))
|
|
773
|
+
return entries
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def build_spec_catalog(
|
|
777
|
+
project_root: str, effective_mapping: dict, catalog_max: int = 200
|
|
778
|
+
) -> str:
|
|
779
|
+
"""Build the compact one-line-per-spec catalog for inject.mode=catalog.
|
|
780
|
+
|
|
781
|
+
The model performs the semantic matching itself by reading the catalog and
|
|
782
|
+
pulling relevant specs — replacing keyword-table guessing on prompts with
|
|
783
|
+
no explicit path evidence. Cached per project_root keyed by the mtimes of
|
|
784
|
+
config.yml and every listed spec file.
|
|
785
|
+
"""
|
|
786
|
+
entries = _catalog_entries(project_root, effective_mapping)
|
|
787
|
+
if not entries:
|
|
788
|
+
return ""
|
|
789
|
+
config_path = os.path.join(project_root, ".code-flow", "config.yml")
|
|
790
|
+
signature = []
|
|
791
|
+
for path in [config_path] + [abs_path for _, _, abs_path in entries]:
|
|
792
|
+
try:
|
|
793
|
+
signature.append((path, os.path.getmtime(path)))
|
|
794
|
+
except OSError:
|
|
795
|
+
signature.append((path, 0.0))
|
|
796
|
+
signature = tuple(signature)
|
|
797
|
+
cached = _catalog_cache.get(project_root)
|
|
798
|
+
if cached and cached["signature"] == signature and cached["catalog_max"] == catalog_max:
|
|
799
|
+
return cached["catalog"]
|
|
800
|
+
|
|
801
|
+
lines = []
|
|
802
|
+
for tier, rel, abs_path in entries:
|
|
803
|
+
try:
|
|
804
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
805
|
+
desc = spec_description(f.read())
|
|
806
|
+
except Exception:
|
|
807
|
+
continue
|
|
808
|
+
suffix = "(导航地图)" if tier == 0 else ""
|
|
809
|
+
lines.append(f"- `{rel}` — {desc or '(no description)'}{suffix}")
|
|
810
|
+
if not lines:
|
|
811
|
+
return ""
|
|
812
|
+
|
|
813
|
+
catalog = CATALOG_HEADER
|
|
814
|
+
included = 0
|
|
815
|
+
for line in lines:
|
|
816
|
+
candidate = catalog + "\n" + line
|
|
817
|
+
if estimate_tokens(candidate) > catalog_max:
|
|
818
|
+
break
|
|
819
|
+
catalog = candidate
|
|
820
|
+
included += 1
|
|
821
|
+
if included < len(lines):
|
|
822
|
+
_log(
|
|
823
|
+
f"WARNING: spec catalog truncated to {included}/{len(lines)} "
|
|
824
|
+
f"entries (catalog_max={catalog_max} tokens)"
|
|
825
|
+
)
|
|
826
|
+
if included == 0:
|
|
827
|
+
return ""
|
|
828
|
+
_catalog_cache[project_root] = {
|
|
829
|
+
"signature": signature,
|
|
830
|
+
"catalog_max": catalog_max,
|
|
831
|
+
"catalog": catalog,
|
|
832
|
+
}
|
|
833
|
+
return catalog
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def assemble_context(specs: list, heading: str) -> str:
|
|
837
|
+
parts = [heading]
|
|
838
|
+
parts.append("以上规范是本次开发的约束条件,生成代码必须遵循。")
|
|
839
|
+
parts.append("---")
|
|
840
|
+
tier0 = [s for s in specs if s.get("tier", 1) == 0]
|
|
841
|
+
tier1 = [s for s in specs if s.get("tier", 1) != 0]
|
|
842
|
+
|
|
843
|
+
if tier0:
|
|
844
|
+
parts.append("### Navigation (Retrieval Map)")
|
|
845
|
+
for spec in tier0:
|
|
846
|
+
parts.append(f"#### {spec['path']}")
|
|
847
|
+
parts.append(spec["content"])
|
|
848
|
+
|
|
849
|
+
if tier1:
|
|
850
|
+
parts.append("### Constraints (matched by file context)")
|
|
851
|
+
for spec in tier1:
|
|
852
|
+
parts.append(f"#### {spec['path']}")
|
|
853
|
+
parts.append(spec["content"])
|
|
854
|
+
|
|
855
|
+
return "\n\n".join(parts)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def load_inject_state(project_root: str) -> dict:
|
|
859
|
+
state_path = os.path.join(project_root, ".code-flow", ".inject-state")
|
|
860
|
+
try:
|
|
861
|
+
with open(state_path, "r", encoding="utf-8") as file:
|
|
862
|
+
data = json.load(file)
|
|
863
|
+
if isinstance(data, dict):
|
|
864
|
+
return data
|
|
865
|
+
except Exception:
|
|
866
|
+
return {}
|
|
867
|
+
return {}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def save_inject_state(project_root: str, payload: dict) -> None:
|
|
871
|
+
state_path = os.path.join(project_root, ".code-flow", ".inject-state")
|
|
872
|
+
try:
|
|
873
|
+
with open(state_path, "w", encoding="utf-8") as file:
|
|
874
|
+
json.dump(payload, file)
|
|
875
|
+
except Exception:
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _log(msg: str) -> None:
|
|
880
|
+
"""Log to stderr (fix #9: don't pollute stdout which is hook output)."""
|
|
881
|
+
print(msg, file=sys.stderr)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def ensure_utf8_io() -> None:
|
|
885
|
+
"""Force stdin/stdout/stderr to UTF-8 so Windows hooks don't mojibake.
|
|
886
|
+
|
|
887
|
+
Claude Code/Codex pass UTF-8 JSON over stdin and expect UTF-8 over stdout,
|
|
888
|
+
but Python on Windows defaults streams to the system codepage (cp936 on
|
|
889
|
+
zh-CN locales). That corrupts CJK content end-to-end — both the parsed
|
|
890
|
+
prompt and anything written back, including the CF_DEBUG=1 .debug.log.
|
|
891
|
+
``reconfigure`` is a TextIOWrapper-only method, so test doubles built on
|
|
892
|
+
``io.StringIO`` are silently skipped.
|
|
893
|
+
"""
|
|
894
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
895
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
896
|
+
if callable(reconfigure):
|
|
897
|
+
try:
|
|
898
|
+
reconfigure(encoding="utf-8")
|
|
899
|
+
except Exception:
|
|
900
|
+
pass
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def resolve_compress(inject_config: dict) -> bool:
|
|
904
|
+
"""Return whether Hook-time spec compression is enabled.
|
|
905
|
+
|
|
906
|
+
Only a literal ``False`` turns it off; missing, ``None``, or any
|
|
907
|
+
non-bool value falls back to ``True`` so upgrades pick up compression
|
|
908
|
+
without requiring a config edit.
|
|
909
|
+
"""
|
|
910
|
+
if not isinstance(inject_config, dict):
|
|
911
|
+
return True
|
|
912
|
+
value = inject_config.get("compress")
|
|
913
|
+
if value is False:
|
|
914
|
+
return False
|
|
915
|
+
return True
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def resolve_session_id(hook_data: dict) -> str:
|
|
919
|
+
"""Derive a session id consistent across PreToolUse / UserPromptSubmit hooks.
|
|
920
|
+
|
|
921
|
+
Prefer the session_id Claude Code / Codex pass in the hook payload so all
|
|
922
|
+
hooks within one session share the same id (fix #10 — previously
|
|
923
|
+
cf_inject_hook used PID while cf_codex_user_prompt_hook used the hook-
|
|
924
|
+
provided id, causing .inject-state dedup to break and specs to be
|
|
925
|
+
re-injected). Falls back to the current PID when the hook runtime omits
|
|
926
|
+
the field (older Codex versions, unit tests).
|
|
927
|
+
"""
|
|
928
|
+
if isinstance(hook_data, dict):
|
|
929
|
+
sid = hook_data.get("session_id")
|
|
930
|
+
if sid:
|
|
931
|
+
return str(sid)
|
|
932
|
+
return str(os.getpid())
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def debug_log(msg: str, project_root: str = None) -> None:
|
|
936
|
+
"""Append a debug line to .code-flow/.debug.log when CF_DEBUG=1.
|
|
937
|
+
Silent no-op unless CF_DEBUG=1 so default runs don't pay any IO cost.
|
|
938
|
+
Writes to a dotfile so cf_scan.py skips it and processDir upgrades don't overwrite it.
|
|
939
|
+
Failures (missing dir, unwritable fs) are swallowed on purpose
|
|
940
|
+
— we must never break the hook JSON protocol over logging.
|
|
941
|
+
"""
|
|
942
|
+
if os.environ.get("CF_DEBUG") != "1":
|
|
943
|
+
return
|
|
944
|
+
root = project_root or os.getcwd()
|
|
945
|
+
debug_dir = os.path.join(root, ".code-flow")
|
|
946
|
+
log_path = os.path.join(debug_dir, ".debug.log")
|
|
947
|
+
try:
|
|
948
|
+
os.makedirs(debug_dir, exist_ok=True)
|
|
949
|
+
from datetime import datetime
|
|
950
|
+
ts = datetime.now().isoformat(timespec="seconds")
|
|
951
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
952
|
+
f.write(f"{ts} {msg}\n")
|
|
953
|
+
except Exception:
|
|
954
|
+
return
|