@michaelxwb/code-flow 0.8.1 → 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 (268) hide show
  1. package/README.md +73 -152
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +156 -1417
  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 +18 -21
  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 +12 -12
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
  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/acceptance-contract.md +0 -217
  54. package/docs/ci/acceptance-evidence.md +0 -77
  55. package/docs/migrations/runtime-0.7.0.md +0 -41
  56. package/docs/migrations/runtime-0.8.0.md +0 -154
  57. package/docs/migrations/runtime-0.9.0.md +0 -40
  58. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  59. package/docs/spec-injection-hardening.md +0 -54
  60. package/docs/spec-injection-performance.json +0 -73
  61. package/docs/spec-optimization.md +0 -12
  62. package/src/adapters/claude/CLAUDE.md +0 -45
  63. package/src/adapters/claude/commands/cf-init.md +0 -262
  64. package/src/adapters/claude/commands/cf-learn.md +0 -291
  65. package/src/adapters/claude/commands/cf-spec.md +0 -87
  66. package/src/adapters/claude/commands/cf-stats.md +0 -96
  67. package/src/adapters/claude/commands/cf-sync.md +0 -39
  68. package/src/adapters/claude/commands/cf-task/align.md +0 -331
  69. package/src/adapters/claude/commands/cf-task/archive.md +0 -155
  70. package/src/adapters/claude/commands/cf-task/block.md +0 -49
  71. package/src/adapters/claude/commands/cf-task/graph.md +0 -92
  72. package/src/adapters/claude/commands/cf-task/note.md +0 -248
  73. package/src/adapters/claude/commands/cf-task/plan.md +0 -477
  74. package/src/adapters/claude/commands/cf-task/prd.md +0 -173
  75. package/src/adapters/claude/commands/cf-task/start.md +0 -317
  76. package/src/adapters/claude/commands/cf-task/status.md +0 -71
  77. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
  78. package/src/adapters/claude/commands/cf-validate.md +0 -100
  79. package/src/adapters/claude/settings.local.json +0 -50
  80. package/src/adapters/codex/AGENTS.md +0 -45
  81. package/src/adapters/codex/config.toml +0 -3
  82. package/src/adapters/codex/hooks.json +0 -54
  83. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  84. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  85. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
  86. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
  87. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
  88. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
  89. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
  90. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
  91. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
  92. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
  93. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
  94. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
  95. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
  96. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
  97. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
  98. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
  99. package/src/adapters/commands.json +0 -115
  100. package/src/adapters/costrict/CLAUDE.md +0 -45
  101. package/src/adapters/costrict/commands/cf-init.md +0 -262
  102. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  103. package/src/adapters/costrict/commands/cf-spec.md +0 -87
  104. package/src/adapters/costrict/commands/cf-stats.md +0 -96
  105. package/src/adapters/costrict/commands/cf-sync.md +0 -39
  106. package/src/adapters/costrict/commands/cf-task/align.md +0 -331
  107. package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
  108. package/src/adapters/costrict/commands/cf-task/block.md +0 -49
  109. package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
  110. package/src/adapters/costrict/commands/cf-task/note.md +0 -248
  111. package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
  112. package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
  113. package/src/adapters/costrict/commands/cf-task/start.md +0 -317
  114. package/src/adapters/costrict/commands/cf-task/status.md +0 -71
  115. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
  116. package/src/adapters/costrict/commands/cf-validate.md +0 -100
  117. package/src/adapters/costrict/settings.local.json +0 -50
  118. package/src/adapters/install-plan.json +0 -100
  119. package/src/adapters/opencode/AGENTS.md +0 -45
  120. package/src/adapters/opencode/commands/cf-init.md +0 -281
  121. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  122. package/src/adapters/opencode/commands/cf-spec.md +0 -91
  123. package/src/adapters/opencode/commands/cf-stats.md +0 -100
  124. package/src/adapters/opencode/commands/cf-sync.md +0 -44
  125. package/src/adapters/opencode/commands/cf-task/align.md +0 -331
  126. package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
  127. package/src/adapters/opencode/commands/cf-task/block.md +0 -54
  128. package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
  129. package/src/adapters/opencode/commands/cf-task/note.md +0 -253
  130. package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
  131. package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
  132. package/src/adapters/opencode/commands/cf-task/start.md +0 -317
  133. package/src/adapters/opencode/commands/cf-task/status.md +0 -76
  134. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
  135. package/src/adapters/opencode/commands/cf-validate.md +0 -105
  136. package/src/adapters/opencode/opencode.json +0 -3
  137. package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
  138. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  139. package/src/adapters/platform-bindings.json +0 -370
  140. package/src/cli.js +0 -1076
  141. package/src/core/code-flow/config.yml +0 -161
  142. package/src/core/code-flow/runtime-commands.json +0 -259
  143. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
  144. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
  145. package/src/core/code-flow/scripts/cf_audit.py +0 -10
  146. package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
  147. package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
  148. package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
  149. package/src/core/code-flow/scripts/cf_feedback.py +0 -10
  150. package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
  151. package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
  152. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
  153. package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
  154. package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
  155. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
  156. package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
  157. package/src/core/code-flow/scripts/cf_stats.py +0 -10
  158. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
  159. package/src/core/code-flow/scripts/cf_sync.py +0 -10
  160. package/src/core/code-flow/scripts/cf_task_index.py +0 -10
  161. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
  162. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
  163. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
  164. package/src/core/code-flow/scripts/cf_validation.py +0 -10
  165. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  166. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  167. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
  168. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
  169. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
  170. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
  171. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
  172. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
  173. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
  174. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
  175. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
  176. package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
  177. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
  178. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
  179. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
  180. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
  181. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  182. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
  183. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
  184. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
  185. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
  186. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
  187. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
  188. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
  189. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
  190. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
  191. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
  192. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
  193. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
  194. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
  195. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
  196. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
  197. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
  198. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  199. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
  200. package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
  201. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
  202. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
  203. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
  204. package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
  205. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
  206. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
  207. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
  208. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
  209. package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
  210. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
  211. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
  212. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
  213. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
  214. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  215. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
  216. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  217. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
  218. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
  219. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
  220. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
  221. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
  222. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
  223. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  224. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
  225. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
  226. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
  227. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
  228. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
  229. package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
  230. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
  231. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
  232. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
  233. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
  234. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
  235. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
  236. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
  237. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
  238. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
  239. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
  240. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
  241. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
  242. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
  243. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
  244. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  245. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
  246. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
  247. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
  248. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
  249. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
  250. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
  251. package/src/core/code-flow/scripts/runtime-resources.json +0 -29
  252. package/src/core/code-flow/specs/backend/_map.md +0 -56
  253. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  254. package/src/core/code-flow/specs/backend/database.md +0 -51
  255. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  256. package/src/core/code-flow/specs/backend/logging.md +0 -50
  257. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  258. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  259. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  260. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  261. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  262. package/src/core/code-flow/specs/shared/_map.md +0 -57
  263. package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
  264. package/src/core/code-flow/tasks/.gitkeep +0 -0
  265. package/src/core/code-flow/validation.yml +0 -59
  266. package/src/migrate/runtime.js +0 -213
  267. package/src/migrate/spec-workflow.js +0 -253
  268. package/src/runtime.js +0 -219
@@ -1,609 +0,0 @@
1
- #!/usr/bin/env python3
2
- import fnmatch
3
- import json
4
- import os
5
- import re
6
- import sys
7
- import time
8
-
9
- from codeflow.core import cf_log
10
- from codeflow.core.cf_markdown import parse_frontmatter
11
-
12
- # --- Config cache (fix #3: avoid re-parsing YAML on every hook call) ---
13
-
14
- _config_cache: dict = {}
15
- _spec_domains_cache: dict = {}
16
- _effective_mapping_cache: dict = {}
17
- _ext_set_cache: dict = {}
18
-
19
-
20
- class ConfigError(ValueError):
21
- """An existing project configuration cannot be read or validated."""
22
-
23
- code = "invalid_config"
24
-
25
-
26
- def project_instruction_file(project_root: str, platform: str = "") -> str:
27
- """Select the installed platform's instruction file, with legacy fallback."""
28
- names = ("CLAUDE.md", "AGENTS.md") if platform in ("claude", "costrict") else ("AGENTS.md", "CLAUDE.md")
29
- return next((name for name in names if os.path.isfile(os.path.join(project_root, name))), names[0])
30
-
31
-
32
- def load_config(project_root: str) -> dict:
33
- config_path = os.path.join(project_root, ".code-flow", "config.yml")
34
- if not os.path.exists(config_path):
35
- return {}
36
- try:
37
- info = os.stat(config_path)
38
- signature = (info.st_mtime_ns, info.st_ctime_ns, info.st_size)
39
- except OSError as exc:
40
- raise ConfigError(f"{config_path}: {exc}") from exc
41
- cached = _config_cache.get(config_path)
42
- if cached and cached["mtime"] == signature:
43
- return cached["data"]
44
- try:
45
- import yaml
46
- except ImportError as exc:
47
- raise ConfigError("PyYAML is required to read code-flow configuration") from exc
48
- try:
49
- with open(config_path, "r", encoding="utf-8") as file:
50
- data = yaml.safe_load(file)
51
- if not isinstance(data, dict):
52
- raise ConfigError(f"{config_path}: configuration must be a YAML mapping")
53
- result = data
54
- _config_cache[config_path] = {"mtime": signature, "data": result}
55
- return result
56
- except (OSError, UnicodeError, yaml.YAMLError) as exc:
57
- raise ConfigError(f"{config_path}: {exc}") from exc
58
-
59
-
60
- def estimate_tokens(text: str) -> int:
61
- return len(text) // 4
62
-
63
-
64
- def normalize_path(path: str) -> str:
65
- # Unconditional backslash replacement, not `os.sep`, so paths pasted from
66
- # a Windows machine into a prompt on macOS/Linux still normalize correctly.
67
- # On Windows `os.sep == '\\'`, so behavior there is unchanged.
68
- return path.replace("\\", "/")
69
-
70
-
71
- def effective_project_root(project_root: str, absolute_path: str) -> str:
72
- """Resolve the project root that owns an edited file.
73
-
74
- Parallel subagents edit `.code-flow/worktrees/<run>/<TASK>/...`; the nearest
75
- `.code-flow` ancestor is that worktree's root, not the session root. Files
76
- outside the session root and unresolvable paths fall back to `project_root`.
77
- Lexical `abspath` (not `realpath`) keeps caller/returned paths in the same
78
- coordinates (macOS /var ↔ /private/var symlink).
79
- """
80
- root = os.path.abspath(project_root)
81
- target = os.path.abspath(absolute_path) if absolute_path else root
82
- if target != root and not target.startswith(root + os.sep):
83
- return root
84
- current = target
85
- while True:
86
- if os.path.isdir(os.path.join(current, ".code-flow")):
87
- return current
88
- if current == root:
89
- return root
90
- parent = os.path.dirname(current)
91
- if parent == current:
92
- return root
93
- current = parent
94
-
95
-
96
- def _spec_path_from_entry(entry) -> str:
97
- cfg = normalize_spec_entry(entry)
98
- return normalize_path(cfg.get("path", ""))
99
-
100
-
101
- def discover_spec_domains(project_root: str) -> dict:
102
- specs_root = os.path.join(project_root, ".code-flow", "specs")
103
- discovered = {}
104
- if not os.path.isdir(specs_root):
105
- _spec_domains_cache[project_root] = discovered
106
- return discovered
107
-
108
- for root, _, files in os.walk(specs_root):
109
- for filename in files:
110
- if not filename.endswith(".md"):
111
- continue
112
- full_path = os.path.join(root, filename)
113
- rel = normalize_path(os.path.relpath(full_path, specs_root))
114
- parts = rel.split("/", 1)
115
- if len(parts) < 2:
116
- continue
117
- domain = parts[0]
118
- discovered.setdefault(domain, []).append(rel)
119
-
120
- for domain in discovered:
121
- discovered[domain] = sorted(set(discovered[domain]))
122
- _spec_domains_cache[project_root] = discovered
123
- return discovered
124
-
125
-
126
- def _default_spec_entry(rel: str) -> dict:
127
- tier = 0 if rel.endswith("/_map.md") else 1
128
- # shared/ 下非 _map.md 的文件是供 cf-task:align / cf-task:prd 命令
129
- # 显式 Read 的模板,不是约束规范,禁止通配符自动注入。
130
- if rel.startswith("shared/") and not rel.endswith("/_map.md"):
131
- return {"path": rel, "tags": [], "tier": tier}
132
- return {"path": rel, "tags": ["*"], "tier": tier}
133
-
134
-
135
- def _mapping_signature(mapping: dict) -> str:
136
- """Content signature for the mapping cache.
137
-
138
- `id(mapping)` keys were reused after GC, potentially serving a mapping
139
- built from stale config contents. Hash the JSON projection instead.
140
- """
141
- try:
142
- return json.dumps(mapping or {}, ensure_ascii=False, sort_keys=True, default=str)
143
- except (TypeError, ValueError):
144
- return ""
145
-
146
-
147
- def build_effective_mapping(project_root: str, mapping: dict) -> dict:
148
- discovered = discover_spec_domains(project_root)
149
- cache_key = (project_root, _mapping_signature(mapping), _mapping_signature(discovered))
150
- cached = _effective_mapping_cache.get(cache_key)
151
- if cached is not None:
152
- return cached
153
- effective = {}
154
-
155
- for domain, rel_paths in discovered.items():
156
- source_cfg = mapping.get(domain) or {}
157
- normalized_specs = []
158
- seen = set()
159
-
160
- for entry in source_cfg.get("specs") or []:
161
- rel = _spec_path_from_entry(entry)
162
- if not rel or rel in seen:
163
- continue
164
- normalized_specs.append(normalize_spec_entry(entry))
165
- seen.add(rel)
166
-
167
- for rel in rel_paths:
168
- if rel in seen:
169
- continue
170
- normalized_specs.append(_default_spec_entry(rel))
171
- seen.add(rel)
172
-
173
- effective[domain] = {
174
- "patterns": source_cfg.get("patterns") or [],
175
- "specs": normalized_specs,
176
- }
177
-
178
- for domain, source_cfg in (mapping or {}).items():
179
- if domain in effective:
180
- continue
181
- normalized_specs = []
182
- seen = set()
183
- for entry in source_cfg.get("specs") or []:
184
- rel = _spec_path_from_entry(entry)
185
- if not rel or rel in seen:
186
- continue
187
- normalized_specs.append(normalize_spec_entry(entry))
188
- seen.add(rel)
189
- effective[domain] = {
190
- "patterns": source_cfg.get("patterns") or [],
191
- "specs": normalized_specs,
192
- }
193
-
194
- _effective_mapping_cache[cache_key] = effective
195
- return effective
196
-
197
-
198
- def is_code_file(rel_path: str, runtime_config: dict) -> bool:
199
- rel_path = normalize_path(rel_path)
200
- for pattern in runtime_config.get("skip_paths") or []:
201
- if fnmatch.fnmatch(rel_path, pattern):
202
- return False
203
- _, ext = os.path.splitext(rel_path)
204
- if ext in (runtime_config.get("skip_extensions") or []):
205
- return False
206
- code_exts = runtime_config.get("code_extensions") or []
207
- # Content key: id() reuse after GC could serve a stale extension set.
208
- ext_key = tuple(code_exts)
209
- ext_set = _ext_set_cache.get(ext_key)
210
- if ext_set is None:
211
- ext_set = frozenset(code_exts)
212
- _ext_set_cache[ext_key] = ext_set
213
- return ext in ext_set
214
-
215
-
216
- def match_domains(rel_path: str, mapping: dict) -> list:
217
- rel_path = normalize_path(rel_path)
218
- domains = []
219
- for domain, cfg in (mapping or {}).items():
220
- patterns = cfg.get("patterns") or []
221
- for pattern in patterns:
222
- if fnmatch.fnmatch(rel_path, pattern):
223
- domains.append(domain)
224
- break
225
- return domains
226
-
227
-
228
- def normalize_spec_entry(entry) -> dict:
229
- """Normalize spec config entry. Supports both old (string) and new (dict) format."""
230
- if isinstance(entry, str):
231
- return {"path": entry, "tags": ["*"], "tier": 1}
232
- if isinstance(entry, dict):
233
- return {
234
- "path": entry.get("path", ""),
235
- "tags": entry.get("tags", ["*"]),
236
- "tier": entry.get("tier", 1),
237
- }
238
- return {}
239
-
240
-
241
- _BULLET_PREFIXES = ("- ", "* ", "+ ")
242
- _COMPRESS_HTML_RE = re.compile(r"<!--.*?-->", re.DOTALL)
243
- _COMPRESS_TRAILING_WS_RE = re.compile(r"[ \t]+$", re.MULTILINE)
244
- _COMPRESS_BLANK_LINES_RE = re.compile(r"\n{3,}")
245
-
246
-
247
- def compress_content(text: str) -> str:
248
- """Lossless compression for spec content injected at Hook time.
249
-
250
- Five conservative, semantics-preserving transforms:
251
- 1. Strip multi-line HTML comments ``<!-- ... -->``.
252
- 2. Strip trailing whitespace on every line.
253
- 3. Collapse runs of 2+ blank lines into a single blank line.
254
- 4. Drop a bullet line ( ``-`` / ``*`` / ``+`` ) equal to the previous line.
255
- 5. Strip leading/trailing blank lines of the whole text.
256
-
257
- Idempotent: ``compress_content(compress_content(x)) == compress_content(x)``.
258
- On any exception, log to stderr and return the original text so Hook
259
- injection is never broken by a compression bug.
260
- """
261
- if not isinstance(text, str) or not text:
262
- return text
263
- try:
264
- result = _COMPRESS_HTML_RE.sub("", text)
265
- result = _COMPRESS_TRAILING_WS_RE.sub("", result)
266
- result = _COMPRESS_BLANK_LINES_RE.sub("\n\n", result)
267
- lines = result.split("\n")
268
- out_lines: list = []
269
- prev_line: str = ""
270
- in_fence = False
271
- for line in lines:
272
- stripped = line.lstrip()
273
- if stripped.startswith("```"):
274
- in_fence = not in_fence
275
- # never dedup inside fenced code blocks — ✅/❌ example snippets
276
- # may legitimately repeat identical bullet-looking lines (FEAT-07)
277
- is_bullet = (not in_fence) and stripped.startswith(_BULLET_PREFIXES)
278
- if is_bullet and out_lines and line == prev_line:
279
- continue
280
- out_lines.append(line)
281
- prev_line = line
282
- result = "\n".join(out_lines)
283
- return result.strip()
284
- except Exception as exc:
285
- _log(f"compress_content error: {exc}")
286
- return text
287
-
288
-
289
- def non_injectable_specs(effective_mapping: dict) -> set:
290
- """tags: [] 的命令专用模板(如 shared 的 PRD/设计模板)——永不自动注入,
291
- 预算统计(cf-stats/cf-scan)必须排除,避免利用率虚高。"""
292
- excluded = set()
293
- for domain_cfg in (effective_mapping or {}).values():
294
- for entry in (domain_cfg or {}).get("specs") or []:
295
- cfg = normalize_spec_entry(entry)
296
- if cfg.get("path") and not cfg.get("tags"):
297
- excluded.add(cfg["path"])
298
- return excluded
299
-
300
-
301
- def resolve_quality_loop(config: dict) -> dict:
302
- """Resolve quality_loop feature switches with safe defaults (RULE-06).
303
-
304
- Master `enabled`: only literal True enables — missing/other keeps the
305
- pre-0.6 behavior for upgraded users; new inits opt in via the config
306
- template. Sub-switches follow the master and only literal False disables
307
- them (resolve_compress philosophy).
308
- """
309
- if not isinstance(config, dict):
310
- return {"enabled": False, "post_check": False,
311
- "stop_check": False, "finish_check": False,
312
- "correction_capture": False,
313
- "compress_reminder": False,
314
- "heavy_at_finish": False,
315
- "finish_verifier_budget": 300.0}
316
- cfg = config.get("quality_loop")
317
- cfg = cfg if isinstance(cfg, dict) else {}
318
- enabled = cfg.get("enabled") is True
319
-
320
- def _sub(key: str) -> bool:
321
- return enabled and cfg.get(key) is not False
322
-
323
- # 任务级 verifier 执行预算(秒):超预算的 command/test verifier 延后到
324
- # 需求级 verify-e2e;0 = 不限制(恢复旧行为)。非法值回落默认 300。
325
- budget = cfg.get("finish_verifier_budget", 300)
326
- if isinstance(budget, bool) or not isinstance(budget, (int, float)) or budget < 0 or budget != budget:
327
- budget = 300.0
328
-
329
- return {
330
- "enabled": enabled,
331
- "post_check": _sub("post_check"),
332
- "stop_check": _sub("stop_check"),
333
- "finish_check": _sub("finish_check"),
334
- "correction_capture": _sub("correction_capture"),
335
- "compress_reminder": _sub("compress_reminder"),
336
- # heavy validator 默认只在归档 cf_validation / /cf-validate 全量执行
337
- "heavy_at_finish": enabled and cfg.get("heavy_at_finish") is True,
338
- "finish_verifier_budget": float(budget),
339
- }
340
-
341
-
342
- ENFORCEMENT_MODES = frozenset(("required", "warn", "inject"))
343
-
344
-
345
- def resolve_enforcement(config: dict) -> str:
346
- """Project-wide Spec Workflow enforcement tier (light-mode ladder).
347
-
348
- required — full fail-closed gates (default; upgraded projects keep it);
349
- warn — gates run but only log/report, never block the session;
350
- inject — routing/injection only; gates and blocks are disabled.
351
- Missing or invalid values fail closed to "required".
352
- """
353
- if not isinstance(config, dict):
354
- return "required"
355
- workflow = config.get("spec_workflow")
356
- workflow = workflow if isinstance(workflow, dict) else {}
357
- value = workflow.get("enforcement")
358
- if isinstance(value, str) and value in ENFORCEMENT_MODES:
359
- return value
360
- return "required"
361
-
362
-
363
- def parse_spec_frontmatter(content: str) -> tuple:
364
- """Split optional YAML frontmatter from spec content → (meta, body).
365
-
366
- Flat `key: value` parsing keeps the hook hot path free of pyyaml; content
367
- without frontmatter returns ({}, content) unchanged.
368
- """
369
- return parse_frontmatter(content)
370
-
371
-
372
- def spec_description(content: str) -> str:
373
- """One-line applicability summary for the spec catalog.
374
-
375
- Priority: frontmatter `description:` → first blockquote line → H1 text.
376
- Returns "" when none is found (cf-scan flags those specs).
377
- """
378
- meta, body = parse_spec_frontmatter(content)
379
- desc = (meta.get("description") or "").strip()
380
- if desc:
381
- return desc
382
- h1 = ""
383
- for line in body.splitlines():
384
- stripped = line.strip()
385
- if stripped.startswith(">"):
386
- return stripped.lstrip("> ").strip()
387
- if not h1 and stripped.startswith("# "):
388
- h1 = stripped[2:].strip()
389
- return h1
390
-
391
-
392
- _catalog_cache: dict = {}
393
-
394
- CATALOG_HEADER = (
395
- "## Spec Catalog (code-flow)\n\n"
396
- "以下规范文件位于 `.code-flow/specs/`。开始编码或修改文件前,"
397
- "必须先 Read 与当前任务场景匹配的 spec 全文;与场景无关的不要读取。"
398
- )
399
-
400
-
401
- class CatalogBudgetError(ConfigError):
402
- """Catalog cannot fit its mandatory header and complete-list notice."""
403
-
404
- def __init__(self, minimum: int) -> None:
405
- self.minimum = minimum
406
- super().__init__(f"catalog_max is too small; minimum is {minimum} estimated tokens")
407
-
408
-
409
- def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
410
- """Collect injectable specs as (tier, rel_path, abs_path) sorted tier1-first."""
411
- specs_root = os.path.join(project_root, ".code-flow", "specs")
412
- entries = []
413
- for domain in sorted(effective_mapping.keys()):
414
- for entry in (effective_mapping[domain] or {}).get("specs") or []:
415
- cfg = normalize_spec_entry(entry)
416
- rel = cfg.get("path", "")
417
- # tags: [] marks command-only templates — never advertised either
418
- if not rel or rel.startswith("_session/") or not cfg.get("tags"):
419
- continue
420
- entries.append((cfg.get("tier", 1), rel, os.path.join(specs_root, rel)))
421
- # Tier 1 constraints first so budget truncation drops navigation maps first
422
- entries.sort(key=lambda item: (item[0] != 1, item[1]))
423
- return entries
424
-
425
-
426
- def _catalog_lines(entries: list[tuple[int, str, str]]) -> list[str]:
427
- lines = []
428
- for tier, rel, abs_path in entries:
429
- try:
430
- with open(abs_path, "r", encoding="utf-8") as f:
431
- desc = spec_description(f.read())
432
- except (OSError, UnicodeError) as exc:
433
- _log(f"Spec Catalog read failed: {rel}: {exc}")
434
- continue
435
- suffix = "(导航地图)" if tier == 0 else ""
436
- lines.append(f"- `{rel}` — {desc or '(no description)'}{suffix}")
437
- return lines
438
-
439
-
440
- def _bounded_catalog(lines: list[str], maximum: int) -> str:
441
- catalog = CATALOG_HEADER
442
- included = 0
443
- def footer(count: int) -> str:
444
- if count == len(lines):
445
- return ""
446
- return (f"\n已展示 {count}/{len(lines)} 项;其余规范请运行 "
447
- "`code-flow spec catalog --root . --stage code --json` 获取。")
448
- for line in lines:
449
- candidate = catalog + "\n" + line
450
- if estimate_tokens(candidate + footer(included + 1)) > maximum:
451
- continue
452
- catalog = candidate
453
- included += 1
454
- text = catalog + footer(included)
455
- if estimate_tokens(text) > maximum:
456
- raise CatalogBudgetError(estimate_tokens(text))
457
- return text
458
-
459
-
460
- def build_spec_catalog(project_root: str, effective_mapping: dict, catalog_max: int = 200) -> str:
461
- """Publish bounded navigation with an explicit complete-catalog entry point."""
462
- entries = _catalog_entries(project_root, effective_mapping)
463
- if not entries:
464
- return ""
465
- signature = []
466
- for _, _, path in entries:
467
- try:
468
- info = os.stat(path)
469
- signature.append((path, info.st_mtime_ns, info.st_ctime_ns, info.st_size))
470
- except OSError:
471
- signature.append((path, 0, 0, 0))
472
- cached = _catalog_cache.get(project_root)
473
- if cached and cached["signature"] == signature and cached["catalog_max"] == catalog_max:
474
- return cached["catalog"]
475
- lines = _catalog_lines(entries)
476
- catalog = _bounded_catalog(lines, catalog_max) if lines else ""
477
- _catalog_cache[project_root] = {"signature": signature, "catalog_max": catalog_max, "catalog": catalog}
478
- return catalog
479
-
480
-
481
- def catalog_omissions(project_root: str, effective_mapping: dict, catalog_max: int) -> dict[str, object]:
482
- """Compute omissions from the actual bounded catalog without changing its ordering."""
483
- from collections import Counter
484
- lines = _catalog_lines(_catalog_entries(project_root, effective_mapping))
485
- text = build_spec_catalog(project_root, effective_mapping, catalog_max)
486
- remaining = Counter(line for line in text.splitlines() if line.startswith('- `'))
487
- dropped: list[str] = []
488
- for line in lines:
489
- if remaining[line]:
490
- remaining[line] -= 1
491
- else:
492
- dropped.append(line.split('`', 2)[1])
493
- return {"dropped_count": len(dropped),
494
- "dropped_domains": sorted({path.split('/', 1)[0] for path in dropped})}
495
-
496
-
497
- def assemble_context(specs: list, heading: str) -> str:
498
- parts = [heading]
499
- parts.append("以上规范是本次开发的约束条件,生成代码必须遵循。")
500
- parts.append("---")
501
- tier0 = [s for s in specs if s.get("tier", 1) == 0]
502
- tier1 = [s for s in specs if s.get("tier", 1) != 0]
503
-
504
- if tier0:
505
- parts.append("### Navigation (Retrieval Map)")
506
- for spec in tier0:
507
- parts.append(f"#### {spec['path']}")
508
- parts.append(spec["content"])
509
-
510
- if tier1:
511
- parts.append("### Constraints (matched by file context)")
512
- for spec in tier1:
513
- parts.append(f"#### {spec['path']}")
514
- parts.append(spec["content"])
515
-
516
- return "\n\n".join(parts)
517
-
518
-
519
- def _log(msg: str) -> None:
520
- """Diagnostics go to stderr through the shared L0 writer (fix #9)."""
521
- cf_log.warn(msg)
522
-
523
-
524
- _PROCESS_START = time.monotonic()
525
-
526
-
527
- def timing_log(name: str) -> None:
528
- """Emit process-elapsed ms to stderr only when CF_DEBUG=1 (diagnostics).
529
-
530
- Never touches stdout, so the hook JSON protocol is unaffected; a default
531
- run pays one env lookup and no IO.
532
- """
533
- if os.environ.get("CF_DEBUG") == "1":
534
- _log(f"{name}: {int((time.monotonic() - _PROCESS_START) * 1000)}ms")
535
-
536
-
537
- def phase_timing(name: str, started: float) -> None:
538
- """Emit a named phase duration to stderr when CF_DEBUG=1."""
539
- if os.environ.get("CF_DEBUG") == "1":
540
- _log(f"cf_phase {name}: {int((time.monotonic() - started) * 1000)}ms")
541
-
542
-
543
- def ensure_utf8_io() -> None:
544
- """Force stdin/stdout/stderr to UTF-8 so Windows hooks don't mojibake.
545
-
546
- Claude Code/Codex pass UTF-8 JSON over stdin and expect UTF-8 over stdout,
547
- but Python on Windows defaults streams to the system codepage (cp936 on
548
- zh-CN locales). That corrupts CJK content end-to-end — both the parsed
549
- prompt and anything written back, including the CF_DEBUG=1 .debug.log.
550
- ``reconfigure`` is a TextIOWrapper-only method, so test doubles built on
551
- ``io.StringIO`` are silently skipped.
552
- """
553
- for stream in (sys.stdin, sys.stdout, sys.stderr):
554
- reconfigure = getattr(stream, "reconfigure", None)
555
- if callable(reconfigure):
556
- try:
557
- reconfigure(encoding="utf-8")
558
- except Exception:
559
- pass
560
-
561
-
562
- def resolve_compress(quality_config: dict) -> bool:
563
- """Return whether Hook-time spec compression is enabled.
564
-
565
- Only a literal ``False`` turns it off; missing, ``None``, or any
566
- non-bool value falls back to ``True`` so upgrades pick up compression
567
- without requiring a config edit.
568
- """
569
- if not isinstance(quality_config, dict):
570
- return True
571
- value = quality_config.get("compress")
572
- if value is False:
573
- return False
574
- return True
575
-
576
-
577
- def resolve_session_id(hook_data: dict) -> str:
578
- """Derive a session id consistent across PreToolUse / UserPromptSubmit hooks.
579
-
580
- Prefer the platform-provided id so prompt, edit, feedback, and stop events
581
- share one trace. Fall back to the PID for older runtimes and unit tests.
582
- """
583
- if isinstance(hook_data, dict):
584
- sid = hook_data.get("session_id")
585
- if sid:
586
- return str(sid)
587
- return str(os.getpid())
588
-
589
-
590
- def debug_log(msg: str, project_root: str = None) -> None:
591
- """Append a debug line to .code-flow/.debug.log when CF_DEBUG=1.
592
- Silent no-op unless CF_DEBUG=1 so default runs don't pay any IO cost.
593
- Writes to a dotfile so cf_scan.py skips it and processDir upgrades don't overwrite it.
594
- Failures (missing dir, unwritable fs) are swallowed on purpose
595
- — we must never break the hook JSON protocol over logging.
596
- """
597
- if os.environ.get("CF_DEBUG") != "1":
598
- return
599
- root = project_root or os.getcwd()
600
- debug_dir = os.path.join(root, ".code-flow")
601
- log_path = os.path.join(debug_dir, ".debug.log")
602
- try:
603
- os.makedirs(debug_dir, exist_ok=True)
604
- from datetime import datetime
605
- ts = datetime.now().isoformat(timespec="seconds")
606
- with open(log_path, "a", encoding="utf-8") as f:
607
- f.write(f"{ts} {msg}\n")
608
- except Exception:
609
- return