@michaelxwb/code-flow 0.6.5 → 1.0.0

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 (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,787 +0,0 @@
1
- #!/usr/bin/env python3
2
- import fnmatch
3
- import os
4
- import re
5
- import sys
6
- import time
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 project_instruction_file(project_root: str, platform: str = "") -> str:
17
- """Select the installed platform's instruction file, with legacy fallback."""
18
- names = ("CLAUDE.md", "AGENTS.md") if platform in ("claude", "costrict") else ("AGENTS.md", "CLAUDE.md")
19
- return next((name for name in names if os.path.isfile(os.path.join(project_root, name))), names[0])
20
-
21
-
22
- def load_config(project_root: str) -> dict:
23
- config_path = os.path.join(project_root, ".code-flow", "config.yml")
24
- if not os.path.exists(config_path):
25
- return {}
26
- try:
27
- mtime = os.path.getmtime(config_path)
28
- except OSError:
29
- return {}
30
- cached = _config_cache.get(config_path)
31
- if cached and cached["mtime"] == mtime:
32
- return cached["data"]
33
- try:
34
- import yaml
35
- except Exception:
36
- return {}
37
- try:
38
- with open(config_path, "r", encoding="utf-8") as file:
39
- data = yaml.safe_load(file)
40
- result = data or {}
41
- _config_cache[config_path] = {"mtime": mtime, "data": result}
42
- return result
43
- except Exception:
44
- return {}
45
-
46
-
47
- def estimate_tokens(text: str) -> int:
48
- return len(text) // 4
49
-
50
-
51
- def normalize_path(path: str) -> str:
52
- # Unconditional backslash replacement, not `os.sep`, so paths pasted from
53
- # a Windows machine into a prompt on macOS/Linux still normalize correctly.
54
- # On Windows `os.sep == '\\'`, so behavior there is unchanged.
55
- return path.replace("\\", "/")
56
-
57
-
58
- def effective_project_root(project_root: str, absolute_path: str) -> str:
59
- """Resolve the project root that owns an edited file.
60
-
61
- Parallel subagents edit `.code-flow/worktrees/<run>/<TASK>/...`; the nearest
62
- `.code-flow` ancestor is that worktree's root, not the session root. Files
63
- outside the session root and unresolvable paths fall back to `project_root`.
64
- Lexical `abspath` (not `realpath`) keeps caller/returned paths in the same
65
- coordinates (macOS /var ↔ /private/var symlink).
66
- """
67
- root = os.path.abspath(project_root)
68
- target = os.path.abspath(absolute_path) if absolute_path else root
69
- if target != root and not target.startswith(root + os.sep):
70
- return root
71
- current = target
72
- while True:
73
- if os.path.isdir(os.path.join(current, ".code-flow")):
74
- return current
75
- if current == root:
76
- return root
77
- parent = os.path.dirname(current)
78
- if parent == current:
79
- return root
80
- current = parent
81
-
82
-
83
- def _spec_path_from_entry(entry) -> str:
84
- cfg = normalize_spec_entry(entry)
85
- return normalize_path(cfg.get("path", ""))
86
-
87
-
88
- def discover_spec_domains(project_root: str) -> dict:
89
- cached = _spec_domains_cache.get(project_root)
90
- if cached is not None:
91
- return cached
92
- specs_root = os.path.join(project_root, ".code-flow", "specs")
93
- discovered = {}
94
- if not os.path.isdir(specs_root):
95
- _spec_domains_cache[project_root] = discovered
96
- return discovered
97
-
98
- for root, _, files in os.walk(specs_root):
99
- for filename in files:
100
- if not filename.endswith(".md"):
101
- continue
102
- full_path = os.path.join(root, filename)
103
- rel = normalize_path(os.path.relpath(full_path, specs_root))
104
- parts = rel.split("/", 1)
105
- if len(parts) < 2:
106
- continue
107
- domain = parts[0]
108
- discovered.setdefault(domain, []).append(rel)
109
-
110
- for domain in discovered:
111
- discovered[domain] = sorted(set(discovered[domain]))
112
- _spec_domains_cache[project_root] = discovered
113
- return discovered
114
-
115
-
116
- def _default_spec_entry(rel: str) -> dict:
117
- tier = 0 if rel.endswith("/_map.md") else 1
118
- # shared/ 下非 _map.md 的文件是供 cf-task:align / cf-task:prd 命令
119
- # 显式 Read 的模板,不是约束规范,禁止通配符自动注入。
120
- if rel.startswith("shared/") and not rel.endswith("/_map.md"):
121
- return {"path": rel, "tags": [], "tier": tier}
122
- return {"path": rel, "tags": ["*"], "tier": tier}
123
-
124
-
125
- def build_effective_mapping(project_root: str, mapping: dict) -> dict:
126
- cache_key = (project_root, id(mapping))
127
- cached = _effective_mapping_cache.get(cache_key)
128
- if cached is not None:
129
- return cached
130
- discovered = discover_spec_domains(project_root)
131
- effective = {}
132
-
133
- for domain, rel_paths in discovered.items():
134
- source_cfg = mapping.get(domain) or {}
135
- normalized_specs = []
136
- seen = set()
137
-
138
- for entry in source_cfg.get("specs") or []:
139
- rel = _spec_path_from_entry(entry)
140
- if not rel or rel in seen:
141
- continue
142
- normalized_specs.append(normalize_spec_entry(entry))
143
- seen.add(rel)
144
-
145
- for rel in rel_paths:
146
- if rel in seen:
147
- continue
148
- normalized_specs.append(_default_spec_entry(rel))
149
- seen.add(rel)
150
-
151
- effective[domain] = {
152
- "patterns": source_cfg.get("patterns") or [],
153
- "specs": normalized_specs,
154
- }
155
-
156
- for domain, source_cfg in (mapping or {}).items():
157
- if domain in effective:
158
- continue
159
- normalized_specs = []
160
- seen = set()
161
- for entry in source_cfg.get("specs") or []:
162
- rel = _spec_path_from_entry(entry)
163
- if not rel or rel in seen:
164
- continue
165
- normalized_specs.append(normalize_spec_entry(entry))
166
- seen.add(rel)
167
- effective[domain] = {
168
- "patterns": source_cfg.get("patterns") or [],
169
- "specs": normalized_specs,
170
- }
171
-
172
- _effective_mapping_cache[cache_key] = effective
173
- return effective
174
-
175
-
176
- def is_code_file(rel_path: str, runtime_config: dict) -> bool:
177
- rel_path = normalize_path(rel_path)
178
- for pattern in runtime_config.get("skip_paths") or []:
179
- if fnmatch.fnmatch(rel_path, pattern):
180
- return False
181
- _, ext = os.path.splitext(rel_path)
182
- if ext in (runtime_config.get("skip_extensions") or []):
183
- return False
184
- code_exts = runtime_config.get("code_extensions") or []
185
- ext_set = _ext_set_cache.get(id(code_exts))
186
- if ext_set is None:
187
- ext_set = frozenset(code_exts)
188
- _ext_set_cache[id(code_exts)] = ext_set
189
- return ext in ext_set
190
-
191
-
192
- def match_domains(rel_path: str, mapping: dict) -> list:
193
- rel_path = normalize_path(rel_path)
194
- domains = []
195
- for domain, cfg in (mapping or {}).items():
196
- patterns = cfg.get("patterns") or []
197
- for pattern in patterns:
198
- if fnmatch.fnmatch(rel_path, pattern):
199
- domains.append(domain)
200
- break
201
- return domains
202
-
203
-
204
- # --- Fix #2: safe depluralization with allowlist ---
205
-
206
- _SAFE_DEPLURALS = {
207
- "models": "model",
208
- "services": "service",
209
- "components": "component",
210
- "handlers": "handler",
211
- "controllers": "controller",
212
- "middlewares": "middleware",
213
- "validators": "validator",
214
- "schemas": "schema",
215
- "repositories": "repository",
216
- "migrations": "migration",
217
- "fixtures": "fixture",
218
- "plugins": "plugin",
219
- "routes": "route",
220
- "routers": "router",
221
- "hooks": "hook",
222
- "pages": "page",
223
- "stores": "store",
224
- "styles": "style",
225
- "types": "type",
226
- "configs": "config",
227
- "scripts": "script",
228
- "tasks": "task",
229
- "specs": "spec",
230
- "tests": "test",
231
- "utils": "util",
232
- "helpers": "helper",
233
- "views": "view",
234
- "templates": "template",
235
- "errors": "error",
236
- "exceptions": "exception",
237
- "docs": "doc",
238
- }
239
-
240
- # --- Fix #1: semantic directory → concept tag mapping ---
241
-
242
- _DIR_SEMANTIC_TAGS = {
243
- "handlers": ["api", "error"],
244
- "controllers": ["api"],
245
- "middleware": ["api", "config"],
246
- "middlewares": ["api", "config"],
247
- "routers": ["api", "route"],
248
- "routes": ["api", "route"],
249
- "views": ["ui", "render"],
250
- "templates": ["ui", "render"],
251
- "models": ["model", "database", "orm", "schema"],
252
- "model": ["model", "database", "orm", "schema"],
253
- "schemas": ["model", "schema", "database"],
254
- "migrations": ["database", "migration"],
255
- "repositories": ["database", "query"],
256
- "dao": ["database", "query"],
257
- "validators": ["quality", "error"],
258
- "exceptions": ["error", "exception"],
259
- "errors": ["error", "exception"],
260
- "auth": ["api", "config"],
261
- "config": ["config", "deploy"],
262
- "configs": ["config", "deploy"],
263
- "settings": ["config"],
264
- "tests": ["test", "quality"],
265
- "test": ["test", "quality"],
266
- "utils": ["quality"],
267
- "helpers": ["quality"],
268
- "lib": ["quality"],
269
- "common": ["quality"],
270
- "shared": ["quality"],
271
- "core": ["quality"],
272
- "logging": ["log", "logging"],
273
- "logger": ["log", "logging"],
274
- "logs": ["log", "logging"],
275
- "cache": ["cache", "performance"],
276
- "queue": ["performance"],
277
- "jobs": ["performance"],
278
- "workers": ["performance"],
279
- }
280
-
281
-
282
- def extract_context_tags(rel_path: str) -> set:
283
- """Extract context tags from a file path for spec matching.
284
-
285
- Uses three strategies:
286
- 1. Directory names as tags (with safe depluralization)
287
- 2. Semantic mapping: common directory names → concept tags
288
- 3. Filename stem word splitting
289
- """
290
- rel_path = normalize_path(rel_path)
291
- tags = set()
292
- parts = rel_path.split("/")
293
-
294
- for part in parts[:-1]:
295
- lower = part.lower()
296
- tags.add(lower)
297
- deplural = _SAFE_DEPLURALS.get(lower)
298
- if deplural:
299
- tags.add(deplural)
300
- semantic = _DIR_SEMANTIC_TAGS.get(lower)
301
- if semantic:
302
- tags.update(semantic)
303
-
304
- filename = parts[-1] if parts else ""
305
- stem = os.path.splitext(filename)[0].lower()
306
- if stem:
307
- words = _FILENAME_WORDS_RE.findall(stem.replace("_", " ").replace("-", " "))
308
- tags.update(words)
309
- for word in words:
310
- semantic = _DIR_SEMANTIC_TAGS.get(word)
311
- if semantic:
312
- tags.update(semantic)
313
-
314
- return tags
315
-
316
-
317
- def normalize_spec_entry(entry) -> dict:
318
- """Normalize spec config entry. Supports both old (string) and new (dict) format."""
319
- if isinstance(entry, str):
320
- return {"path": entry, "tags": ["*"], "tier": 1}
321
- if isinstance(entry, dict):
322
- return {
323
- "path": entry.get("path", ""),
324
- "tags": entry.get("tags", ["*"]),
325
- "tier": entry.get("tier", 1),
326
- }
327
- return {}
328
-
329
-
330
- _FILENAME_WORDS_RE = re.compile(r"[a-z]+")
331
- _BULLET_PREFIXES = ("- ", "* ", "+ ")
332
- _COMPRESS_HTML_RE = re.compile(r"<!--.*?-->", re.DOTALL)
333
- _COMPRESS_TRAILING_WS_RE = re.compile(r"[ \t]+$", re.MULTILINE)
334
- _COMPRESS_BLANK_LINES_RE = re.compile(r"\n{3,}")
335
-
336
-
337
- def compress_content(text: str) -> str:
338
- """Lossless compression for spec content injected at Hook time.
339
-
340
- Five conservative, semantics-preserving transforms:
341
- 1. Strip multi-line HTML comments ``<!-- ... -->``.
342
- 2. Strip trailing whitespace on every line.
343
- 3. Collapse runs of 2+ blank lines into a single blank line.
344
- 4. Drop a bullet line ( ``-`` / ``*`` / ``+`` ) equal to the previous line.
345
- 5. Strip leading/trailing blank lines of the whole text.
346
-
347
- Idempotent: ``compress_content(compress_content(x)) == compress_content(x)``.
348
- On any exception, log to stderr and return the original text so Hook
349
- injection is never broken by a compression bug.
350
- """
351
- if not isinstance(text, str) or not text:
352
- return text
353
- try:
354
- result = _COMPRESS_HTML_RE.sub("", text)
355
- result = _COMPRESS_TRAILING_WS_RE.sub("", result)
356
- result = _COMPRESS_BLANK_LINES_RE.sub("\n\n", result)
357
- lines = result.split("\n")
358
- out_lines: list = []
359
- prev_line: str = ""
360
- in_fence = False
361
- for line in lines:
362
- stripped = line.lstrip()
363
- if stripped.startswith("```"):
364
- in_fence = not in_fence
365
- # never dedup inside fenced code blocks — ✅/❌ example snippets
366
- # may legitimately repeat identical bullet-looking lines (FEAT-07)
367
- is_bullet = (not in_fence) and stripped.startswith(_BULLET_PREFIXES)
368
- if is_bullet and out_lines and line == prev_line:
369
- continue
370
- out_lines.append(line)
371
- prev_line = line
372
- result = "\n".join(out_lines)
373
- return result.strip()
374
- except Exception as exc:
375
- _log(f"compress_content error: {exc}")
376
- return text
377
-
378
-
379
- def read_matched_specs(
380
- project_root: str,
381
- domain: str,
382
- matched: list,
383
- compress: bool = True,
384
- ) -> list:
385
- """Read matched spec files; optionally apply lossless compression.
386
-
387
- When ``compress`` is True (default), each spec's content is passed through
388
- :func:`compress_content` before token estimation so callers can make
389
- explicit budget decisions from the returned token counts.
390
-
391
- Returned items carry:
392
- - ``content``: final content (compressed when ``compress=True``)
393
- - ``tokens``: token count of final content (drives budget decisions)
394
- - ``tokens_raw``: token count of uncompressed content (for cf-stats)
395
-
396
- ``CF_DEBUG=1`` emits a per-file ``compress path=... raw=... compressed=...
397
- saved=...%`` record via :func:`debug_log`.
398
- """
399
- specs_root = os.path.join(project_root, ".code-flow", "specs")
400
- specs: list = []
401
- for cfg in matched:
402
- rel = cfg["path"]
403
- spec_path = os.path.join(specs_root, rel)
404
- try:
405
- with open(spec_path, "r", encoding="utf-8") as f:
406
- raw_content = f.read().strip()
407
- # Frontmatter feeds the spec catalog only — never spend injection
408
- # budget on it. Specs without frontmatter pass through unchanged.
409
- _, raw_content = parse_spec_frontmatter(raw_content)
410
- raw_content = raw_content.strip()
411
- if not raw_content:
412
- continue
413
- raw_tokens = estimate_tokens(raw_content)
414
- if compress:
415
- try:
416
- content = compress_content(raw_content)
417
- except Exception as exc:
418
- _log(f"read_matched_specs compress failed path={rel}: {exc}")
419
- content = raw_content
420
- else:
421
- content = raw_content
422
- tokens = estimate_tokens(content)
423
- if compress and raw_tokens != tokens:
424
- saved_pct = (
425
- round((raw_tokens - tokens) * 100 / raw_tokens, 1)
426
- if raw_tokens
427
- else 0.0
428
- )
429
- debug_log(
430
- f"compress path={rel} raw={raw_tokens} "
431
- f"compressed={tokens} saved={saved_pct}%",
432
- project_root,
433
- )
434
- specs.append(
435
- {
436
- "path": rel,
437
- "content": content,
438
- "tokens": tokens,
439
- "tokens_raw": raw_tokens,
440
- "domain": domain,
441
- "tier": cfg.get("tier", 1),
442
- }
443
- )
444
- except Exception:
445
- continue
446
- return specs
447
-
448
-
449
- # --- Kept for backward compatibility with old config format ---
450
-
451
-
452
- def read_specs(project_root: str, domain: str, domain_cfg: dict) -> list:
453
- specs_root = os.path.join(project_root, ".code-flow", "specs")
454
- specs = []
455
- for entry in domain_cfg.get("specs") or []:
456
- cfg = normalize_spec_entry(entry)
457
- rel = cfg.get("path", "")
458
- if not rel:
459
- continue
460
- spec_path = os.path.join(specs_root, rel)
461
- try:
462
- with open(spec_path, "r", encoding="utf-8") as file:
463
- content = file.read().strip()
464
- if not content:
465
- continue
466
- specs.append(
467
- {
468
- "path": rel,
469
- "content": content,
470
- "tokens": estimate_tokens(content),
471
- "domain": domain,
472
- "tier": cfg.get("tier", 1),
473
- }
474
- )
475
- except Exception:
476
- continue
477
- return specs
478
-
479
-
480
- def non_injectable_specs(effective_mapping: dict) -> set:
481
- """tags: [] 的命令专用模板(如 shared 的 PRD/设计模板)——永不自动注入,
482
- 预算统计(cf-stats/cf-scan)必须排除,避免利用率虚高。"""
483
- excluded = set()
484
- for domain_cfg in (effective_mapping or {}).values():
485
- for entry in (domain_cfg or {}).get("specs") or []:
486
- cfg = normalize_spec_entry(entry)
487
- if cfg.get("path") and not cfg.get("tags"):
488
- excluded.add(cfg["path"])
489
- return excluded
490
-
491
-
492
- def resolve_quality_loop(config: dict) -> dict:
493
- """Resolve quality_loop feature switches with safe defaults (RULE-06).
494
-
495
- Master `enabled`: only literal True enables — missing/other keeps the
496
- pre-0.6 behavior for upgraded users; new inits opt in via the config
497
- template. Sub-switches follow the master and only literal False disables
498
- them (resolve_compress philosophy).
499
- """
500
- if not isinstance(config, dict):
501
- return {"enabled": False, "post_check": False,
502
- "stop_check": False, "finish_check": False,
503
- "correction_capture": False,
504
- "compress_reminder": False}
505
- cfg = config.get("quality_loop")
506
- cfg = cfg if isinstance(cfg, dict) else {}
507
- enabled = cfg.get("enabled") is True
508
-
509
- def _sub(key: str) -> bool:
510
- return enabled and cfg.get(key) is not False
511
-
512
- return {
513
- "enabled": enabled,
514
- "post_check": _sub("post_check"),
515
- "stop_check": _sub("stop_check"),
516
- "finish_check": _sub("finish_check"),
517
- "correction_capture": _sub("correction_capture"),
518
- "compress_reminder": _sub("compress_reminder"),
519
- }
520
-
521
-
522
- ENFORCEMENT_MODES = frozenset(("required", "warn", "inject"))
523
-
524
-
525
- def resolve_enforcement(config: dict) -> str:
526
- """Project-wide Spec Workflow enforcement tier (light-mode ladder).
527
-
528
- required — full fail-closed gates (default; upgraded projects keep it);
529
- warn — gates run but only log/report, never block the session;
530
- inject — routing/injection only; gates and blocks are disabled.
531
- Missing or invalid values fail closed to "required".
532
- """
533
- if not isinstance(config, dict):
534
- return "required"
535
- workflow = config.get("spec_workflow")
536
- workflow = workflow if isinstance(workflow, dict) else {}
537
- value = workflow.get("enforcement")
538
- if isinstance(value, str) and value in ENFORCEMENT_MODES:
539
- return value
540
- return "required"
541
-
542
-
543
- _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
544
-
545
-
546
- def parse_spec_frontmatter(content: str) -> tuple:
547
- """Split optional YAML frontmatter from spec content → (meta, body).
548
-
549
- Parses only flat `key: value` string pairs — enough for `description:` —
550
- so the hook hot path never needs pyyaml for this. Content without
551
- frontmatter returns ({}, content) unchanged.
552
- """
553
- if not isinstance(content, str):
554
- return {}, content
555
- match = _FRONTMATTER_RE.match(content)
556
- if not match:
557
- return {}, content
558
- meta: dict = {}
559
- for line in match.group(1).splitlines():
560
- key, sep, value = line.partition(":")
561
- if not sep:
562
- continue
563
- key = key.strip()
564
- if key:
565
- meta[key] = value.strip().strip('"').strip("'")
566
- return meta, content[match.end():]
567
-
568
-
569
- def spec_description(content: str) -> str:
570
- """One-line applicability summary for the spec catalog.
571
-
572
- Priority: frontmatter `description:` → first blockquote line → H1 text.
573
- Returns "" when none is found (cf-scan flags those specs).
574
- """
575
- meta, body = parse_spec_frontmatter(content)
576
- desc = (meta.get("description") or "").strip()
577
- if desc:
578
- return desc
579
- h1 = ""
580
- for line in body.splitlines():
581
- stripped = line.strip()
582
- if stripped.startswith(">"):
583
- return stripped.lstrip("> ").strip()
584
- if not h1 and stripped.startswith("# "):
585
- h1 = stripped[2:].strip()
586
- return h1
587
-
588
-
589
- _catalog_cache: dict = {}
590
-
591
- CATALOG_HEADER = (
592
- "## Spec Catalog (code-flow)\n\n"
593
- "以下规范文件位于 `.code-flow/specs/`。开始编码或修改文件前,"
594
- "必须先 Read 与当前任务场景匹配的 spec 全文;与场景无关的不要读取。"
595
- )
596
-
597
-
598
- def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
599
- """Collect injectable specs as (tier, rel_path, abs_path) sorted tier1-first."""
600
- specs_root = os.path.join(project_root, ".code-flow", "specs")
601
- entries = []
602
- for domain in sorted(effective_mapping.keys()):
603
- for entry in (effective_mapping[domain] or {}).get("specs") or []:
604
- cfg = normalize_spec_entry(entry)
605
- rel = cfg.get("path", "")
606
- # tags: [] marks command-only templates — never advertised either
607
- if not rel or not cfg.get("tags"):
608
- continue
609
- entries.append((cfg.get("tier", 1), rel, os.path.join(specs_root, rel)))
610
- # Tier 1 constraints first so budget truncation drops navigation maps first
611
- entries.sort(key=lambda item: (item[0] != 1, item[1]))
612
- return entries
613
-
614
-
615
- def build_spec_catalog(
616
- project_root: str, effective_mapping: dict, catalog_max: int = 200
617
- ) -> str:
618
- """Build the compact Catalog used only when no task or path is active.
619
-
620
- The model performs the semantic matching itself by reading the catalog and
621
- pulling relevant specs — replacing keyword-table guessing on prompts with
622
- no explicit path evidence. Cached per project_root keyed by the mtimes of
623
- config.yml and every listed spec file.
624
- """
625
- entries = _catalog_entries(project_root, effective_mapping)
626
- if not entries:
627
- return ""
628
- config_path = os.path.join(project_root, ".code-flow", "config.yml")
629
- signature = []
630
- for path in [config_path] + [abs_path for _, _, abs_path in entries]:
631
- try:
632
- signature.append((path, os.path.getmtime(path)))
633
- except OSError:
634
- signature.append((path, 0.0))
635
- signature = tuple(signature)
636
- cached = _catalog_cache.get(project_root)
637
- if cached and cached["signature"] == signature and cached["catalog_max"] == catalog_max:
638
- return cached["catalog"]
639
-
640
- lines = []
641
- for tier, rel, abs_path in entries:
642
- try:
643
- with open(abs_path, "r", encoding="utf-8") as f:
644
- desc = spec_description(f.read())
645
- except Exception:
646
- continue
647
- suffix = "(导航地图)" if tier == 0 else ""
648
- lines.append(f"- `{rel}` — {desc or '(no description)'}{suffix}")
649
- if not lines:
650
- return ""
651
-
652
- catalog = CATALOG_HEADER
653
- included = 0
654
- for line in lines:
655
- candidate = catalog + "\n" + line
656
- if estimate_tokens(candidate) > catalog_max:
657
- break
658
- catalog = candidate
659
- included += 1
660
- if included < len(lines):
661
- _log(
662
- f"WARNING: spec catalog truncated to {included}/{len(lines)} "
663
- f"entries (catalog_max={catalog_max} tokens)"
664
- )
665
- if included == 0:
666
- return ""
667
- _catalog_cache[project_root] = {
668
- "signature": signature,
669
- "catalog_max": catalog_max,
670
- "catalog": catalog,
671
- }
672
- return catalog
673
-
674
-
675
- def assemble_context(specs: list, heading: str) -> str:
676
- parts = [heading]
677
- parts.append("以上规范是本次开发的约束条件,生成代码必须遵循。")
678
- parts.append("---")
679
- tier0 = [s for s in specs if s.get("tier", 1) == 0]
680
- tier1 = [s for s in specs if s.get("tier", 1) != 0]
681
-
682
- if tier0:
683
- parts.append("### Navigation (Retrieval Map)")
684
- for spec in tier0:
685
- parts.append(f"#### {spec['path']}")
686
- parts.append(spec["content"])
687
-
688
- if tier1:
689
- parts.append("### Constraints (matched by file context)")
690
- for spec in tier1:
691
- parts.append(f"#### {spec['path']}")
692
- parts.append(spec["content"])
693
-
694
- return "\n\n".join(parts)
695
-
696
-
697
- def _log(msg: str) -> None:
698
- """Log to stderr (fix #9: don't pollute stdout which is hook output)."""
699
- print(msg, file=sys.stderr)
700
-
701
-
702
- _PROCESS_START = time.monotonic()
703
-
704
-
705
- def timing_log(name: str) -> None:
706
- """Emit process-elapsed ms to stderr only when CF_DEBUG=1 (diagnostics).
707
-
708
- Never touches stdout, so the hook JSON protocol is unaffected; a default
709
- run pays one env lookup and no IO.
710
- """
711
- if os.environ.get("CF_DEBUG") == "1":
712
- _log(f"{name}: {int((time.monotonic() - _PROCESS_START) * 1000)}ms")
713
-
714
-
715
- def phase_timing(name: str, started: float) -> None:
716
- """Emit a named phase duration to stderr when CF_DEBUG=1."""
717
- if os.environ.get("CF_DEBUG") == "1":
718
- _log(f"cf_phase {name}: {int((time.monotonic() - started) * 1000)}ms")
719
-
720
-
721
- def ensure_utf8_io() -> None:
722
- """Force stdin/stdout/stderr to UTF-8 so Windows hooks don't mojibake.
723
-
724
- Claude Code/Codex pass UTF-8 JSON over stdin and expect UTF-8 over stdout,
725
- but Python on Windows defaults streams to the system codepage (cp936 on
726
- zh-CN locales). That corrupts CJK content end-to-end — both the parsed
727
- prompt and anything written back, including the CF_DEBUG=1 .debug.log.
728
- ``reconfigure`` is a TextIOWrapper-only method, so test doubles built on
729
- ``io.StringIO`` are silently skipped.
730
- """
731
- for stream in (sys.stdin, sys.stdout, sys.stderr):
732
- reconfigure = getattr(stream, "reconfigure", None)
733
- if callable(reconfigure):
734
- try:
735
- reconfigure(encoding="utf-8")
736
- except Exception:
737
- pass
738
-
739
-
740
- def resolve_compress(quality_config: dict) -> bool:
741
- """Return whether Hook-time spec compression is enabled.
742
-
743
- Only a literal ``False`` turns it off; missing, ``None``, or any
744
- non-bool value falls back to ``True`` so upgrades pick up compression
745
- without requiring a config edit.
746
- """
747
- if not isinstance(quality_config, dict):
748
- return True
749
- value = quality_config.get("compress")
750
- if value is False:
751
- return False
752
- return True
753
-
754
-
755
- def resolve_session_id(hook_data: dict) -> str:
756
- """Derive a session id consistent across PreToolUse / UserPromptSubmit hooks.
757
-
758
- Prefer the platform-provided id so prompt, edit, feedback, and stop events
759
- share one trace. Fall back to the PID for older runtimes and unit tests.
760
- """
761
- if isinstance(hook_data, dict):
762
- sid = hook_data.get("session_id")
763
- if sid:
764
- return str(sid)
765
- return str(os.getpid())
766
-
767
-
768
- def debug_log(msg: str, project_root: str = None) -> None:
769
- """Append a debug line to .code-flow/.debug.log when CF_DEBUG=1.
770
- Silent no-op unless CF_DEBUG=1 so default runs don't pay any IO cost.
771
- Writes to a dotfile so cf_scan.py skips it and processDir upgrades don't overwrite it.
772
- Failures (missing dir, unwritable fs) are swallowed on purpose
773
- — we must never break the hook JSON protocol over logging.
774
- """
775
- if os.environ.get("CF_DEBUG") != "1":
776
- return
777
- root = project_root or os.getcwd()
778
- debug_dir = os.path.join(root, ".code-flow")
779
- log_path = os.path.join(debug_dir, ".debug.log")
780
- try:
781
- os.makedirs(debug_dir, exist_ok=True)
782
- from datetime import datetime
783
- ts = datetime.now().isoformat(timespec="seconds")
784
- with open(log_path, "a", encoding="utf-8") as f:
785
- f.write(f"{ts} {msg}\n")
786
- except Exception:
787
- return