@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,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