@michaelxwb/code-flow 0.6.4 → 0.8.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 (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -40
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,31 @@
1
+ """Programmatic validation entry with complete Git scope and argv execution."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from typing import IO, Optional, Sequence
8
+ import sys
9
+
10
+ from codeflow.task.cf_validation_service import validate_files, validation_scope
11
+
12
+
13
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
14
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or None)
15
+ parser.add_argument("--root", default=os.getcwd())
16
+ parser.add_argument("--files", nargs="*", default=[])
17
+ parser.add_argument("--budget", type=float)
18
+ parser.add_argument("--no-heavy", action="store_true", help="Skip heavy validators (task-level finish scope)")
19
+ parser.add_argument("--no-cache", action="store_true", help="Force validators to run; skip content-fingerprint reuse")
20
+ parser.add_argument("--scope-only", action="store_true")
21
+ parser.add_argument("--json", action="store_true")
22
+ args = parser.parse_args(argv)
23
+ try:
24
+ result = ({"files": validation_scope(args.root, args.files or None)} if args.scope_only
25
+ else validate_files(args.root, args.files or None, args.budget, include_heavy=not args.no_heavy,
26
+ use_cache=not args.no_cache))
27
+ stdout.write(json.dumps(result, ensure_ascii=False))
28
+ return 3 if result.get("decision") == "block" else 0
29
+ except (OSError, ValueError) as exc:
30
+ stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
31
+ return 2
@@ -0,0 +1,67 @@
1
+ """Rebuildable Context parse cache bound to source bytes and parser version."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from codeflow.core import cf_fs
10
+ from codeflow.core.cf_log import warn
11
+ import codeflow.spec.cf_spec_context_model as cf_spec_context_model
12
+ from codeflow.spec.cf_spec_context_model import (ContextError, SpecContext, context_from_data,
13
+ context_sha256, context_to_data)
14
+
15
+
16
+ class ContextLoader(Protocol):
17
+ def __call__(self, path: str, *, source: bytes | None = None) -> SpecContext: ...
18
+
19
+
20
+ def _digest(value: dict[str, object]) -> str:
21
+ encoded = json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
22
+ return hashlib.sha256(encoded).hexdigest()
23
+
24
+
25
+ def _read(path: Path, key: str) -> dict[str, object]:
26
+ try:
27
+ text = cf_fs.read_text(path)
28
+ if text is None:
29
+ return {}
30
+ payload = json.loads(text)
31
+ if not isinstance(payload, dict):
32
+ return {}
33
+ checksum = payload.pop("checksum", None)
34
+ if payload.get("version") == 1 and payload.get("key") == key and checksum == _digest(payload):
35
+ return payload
36
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
37
+ warn(f"Context parse cache unavailable; reloading source: {exc}")
38
+ return {}
39
+
40
+
41
+ def _write(path: Path, payload: dict[str, object]) -> None:
42
+ try:
43
+ payload = dict(payload, checksum=_digest(payload))
44
+ cf_fs.atomic_write_text(path, json.dumps(payload, ensure_ascii=False))
45
+ except (OSError, UnicodeError, TypeError) as exc:
46
+ warn(f"Context parse cache cannot be saved: {exc}")
47
+
48
+
49
+ def load_snapshot(root: str, path: str, loader: ContextLoader) -> tuple[SpecContext, str]:
50
+ source = Path(path).read_bytes()
51
+ parser = Path(cf_spec_context_model.__file__).read_bytes()
52
+ key = hashlib.sha256(str(Path(path).resolve()).encode("utf-8") + b"\0"
53
+ + hashlib.sha256(source).digest() + hashlib.sha256(parser).digest()).hexdigest()
54
+ cache = Path(root) / ".code-flow/.context-state.json"
55
+ payload = _read(cache, key)
56
+ if payload:
57
+ try:
58
+ context = context_from_data(payload.get("context"), path)
59
+ identity = payload.get("identity")
60
+ if isinstance(identity, str) and len(identity) == 64:
61
+ return context, identity
62
+ except ContextError as exc:
63
+ warn(f"Context parse cache invalid; reloading source: {exc}")
64
+ context = loader(path, source=source)
65
+ identity = context_sha256(context)
66
+ _write(cache, {"version": 1, "key": key, "context": context_to_data(context), "identity": identity})
67
+ return context, identity
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  import fnmatch
3
+ import json
3
4
  import os
4
5
  import re
5
6
  import sys
6
7
  import time
7
8
 
9
+ from codeflow.core import cf_log
10
+ from codeflow.core.cf_markdown import parse_frontmatter
11
+
8
12
  # --- Config cache (fix #3: avoid re-parsing YAML on every hook call) ---
9
13
 
10
14
  _config_cache: dict = {}
@@ -13,6 +17,12 @@ _effective_mapping_cache: dict = {}
13
17
  _ext_set_cache: dict = {}
14
18
 
15
19
 
20
+ class ConfigError(ValueError):
21
+ """An existing project configuration cannot be read or validated."""
22
+
23
+ code = "invalid_config"
24
+
25
+
16
26
  def project_instruction_file(project_root: str, platform: str = "") -> str:
17
27
  """Select the installed platform's instruction file, with legacy fallback."""
18
28
  names = ("CLAUDE.md", "AGENTS.md") if platform in ("claude", "costrict") else ("AGENTS.md", "CLAUDE.md")
@@ -24,24 +34,27 @@ def load_config(project_root: str) -> dict:
24
34
  if not os.path.exists(config_path):
25
35
  return {}
26
36
  try:
27
- mtime = os.path.getmtime(config_path)
28
- except OSError:
29
- return {}
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
30
41
  cached = _config_cache.get(config_path)
31
- if cached and cached["mtime"] == mtime:
42
+ if cached and cached["mtime"] == signature:
32
43
  return cached["data"]
33
44
  try:
34
45
  import yaml
35
- except Exception:
36
- return {}
46
+ except ImportError as exc:
47
+ raise ConfigError("PyYAML is required to read code-flow configuration") from exc
37
48
  try:
38
49
  with open(config_path, "r", encoding="utf-8") as file:
39
50
  data = yaml.safe_load(file)
40
- result = data or {}
41
- _config_cache[config_path] = {"mtime": mtime, "data": result}
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}
42
55
  return result
43
- except Exception:
44
- return {}
56
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
57
+ raise ConfigError(f"{config_path}: {exc}") from exc
45
58
 
46
59
 
47
60
  def estimate_tokens(text: str) -> int:
@@ -86,9 +99,6 @@ def _spec_path_from_entry(entry) -> str:
86
99
 
87
100
 
88
101
  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
102
  specs_root = os.path.join(project_root, ".code-flow", "specs")
93
103
  discovered = {}
94
104
  if not os.path.isdir(specs_root):
@@ -122,12 +132,24 @@ def _default_spec_entry(rel: str) -> dict:
122
132
  return {"path": rel, "tags": ["*"], "tier": tier}
123
133
 
124
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
+
125
147
  def build_effective_mapping(project_root: str, mapping: dict) -> dict:
126
- cache_key = (project_root, id(mapping))
148
+ discovered = discover_spec_domains(project_root)
149
+ cache_key = (project_root, _mapping_signature(mapping), _mapping_signature(discovered))
127
150
  cached = _effective_mapping_cache.get(cache_key)
128
151
  if cached is not None:
129
152
  return cached
130
- discovered = discover_spec_domains(project_root)
131
153
  effective = {}
132
154
 
133
155
  for domain, rel_paths in discovered.items():
@@ -182,10 +204,12 @@ def is_code_file(rel_path: str, runtime_config: dict) -> bool:
182
204
  if ext in (runtime_config.get("skip_extensions") or []):
183
205
  return False
184
206
  code_exts = runtime_config.get("code_extensions") or []
185
- ext_set = _ext_set_cache.get(id(code_exts))
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)
186
210
  if ext_set is None:
187
211
  ext_set = frozenset(code_exts)
188
- _ext_set_cache[id(code_exts)] = ext_set
212
+ _ext_set_cache[ext_key] = ext_set
189
213
  return ext in ext_set
190
214
 
191
215
 
@@ -201,119 +225,6 @@ def match_domains(rel_path: str, mapping: dict) -> list:
201
225
  return domains
202
226
 
203
227
 
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
228
  def normalize_spec_entry(entry) -> dict:
318
229
  """Normalize spec config entry. Supports both old (string) and new (dict) format."""
319
230
  if isinstance(entry, str):
@@ -327,7 +238,6 @@ def normalize_spec_entry(entry) -> dict:
327
238
  return {}
328
239
 
329
240
 
330
- _FILENAME_WORDS_RE = re.compile(r"[a-z]+")
331
241
  _BULLET_PREFIXES = ("- ", "* ", "+ ")
332
242
  _COMPRESS_HTML_RE = re.compile(r"<!--.*?-->", re.DOTALL)
333
243
  _COMPRESS_TRAILING_WS_RE = re.compile(r"[ \t]+$", re.MULTILINE)
@@ -376,107 +286,6 @@ def compress_content(text: str) -> str:
376
286
  return text
377
287
 
378
288
 
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
289
  def non_injectable_specs(effective_mapping: dict) -> set:
481
290
  """tags: [] 的命令专用模板(如 shared 的 PRD/设计模板)——永不自动注入,
482
291
  预算统计(cf-stats/cf-scan)必须排除,避免利用率虚高。"""
@@ -501,7 +310,9 @@ def resolve_quality_loop(config: dict) -> dict:
501
310
  return {"enabled": False, "post_check": False,
502
311
  "stop_check": False, "finish_check": False,
503
312
  "correction_capture": False,
504
- "compress_reminder": False}
313
+ "compress_reminder": False,
314
+ "heavy_at_finish": False,
315
+ "finish_verifier_budget": 300.0}
505
316
  cfg = config.get("quality_loop")
506
317
  cfg = cfg if isinstance(cfg, dict) else {}
507
318
  enabled = cfg.get("enabled") is True
@@ -509,6 +320,12 @@ def resolve_quality_loop(config: dict) -> dict:
509
320
  def _sub(key: str) -> bool:
510
321
  return enabled and cfg.get(key) is not False
511
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
+
512
329
  return {
513
330
  "enabled": enabled,
514
331
  "post_check": _sub("post_check"),
@@ -516,6 +333,9 @@ def resolve_quality_loop(config: dict) -> dict:
516
333
  "finish_check": _sub("finish_check"),
517
334
  "correction_capture": _sub("correction_capture"),
518
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),
519
339
  }
520
340
 
521
341
 
@@ -540,30 +360,13 @@ def resolve_enforcement(config: dict) -> str:
540
360
  return "required"
541
361
 
542
362
 
543
- _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
544
-
545
-
546
363
  def parse_spec_frontmatter(content: str) -> tuple:
547
364
  """Split optional YAML frontmatter from spec content → (meta, body).
548
365
 
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.
366
+ Flat `key: value` parsing keeps the hook hot path free of pyyaml; content
367
+ without frontmatter returns ({}, content) unchanged.
552
368
  """
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():]
369
+ return parse_frontmatter(content)
567
370
 
568
371
 
569
372
  def spec_description(content: str) -> str:
@@ -595,6 +398,14 @@ CATALOG_HEADER = (
595
398
  )
596
399
 
597
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
+
598
409
  def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
599
410
  """Collect injectable specs as (tier, rel_path, abs_path) sorted tier1-first."""
600
411
  specs_root = os.path.join(project_root, ".code-flow", "specs")
@@ -604,7 +415,7 @@ def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
604
415
  cfg = normalize_spec_entry(entry)
605
416
  rel = cfg.get("path", "")
606
417
  # tags: [] marks command-only templates — never advertised either
607
- if not rel or not cfg.get("tags"):
418
+ if not rel or rel.startswith("_session/") or not cfg.get("tags"):
608
419
  continue
609
420
  entries.append((cfg.get("tier", 1), rel, os.path.join(specs_root, rel)))
610
421
  # Tier 1 constraints first so budget truncation drops navigation maps first
@@ -612,66 +423,77 @@ def _catalog_entries(project_root: str, effective_mapping: dict) -> list:
612
423
  return entries
613
424
 
614
425
 
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
-
426
+ def _catalog_lines(entries: list[tuple[int, str, str]]) -> list[str]:
640
427
  lines = []
641
428
  for tier, rel, abs_path in entries:
642
429
  try:
643
430
  with open(abs_path, "r", encoding="utf-8") as f:
644
431
  desc = spec_description(f.read())
645
- except Exception:
432
+ except (OSError, UnicodeError) as exc:
433
+ _log(f"Spec Catalog read failed: {rel}: {exc}")
646
434
  continue
647
435
  suffix = "(导航地图)" if tier == 0 else ""
648
436
  lines.append(f"- `{rel}` — {desc or '(no description)'}{suffix}")
649
- if not lines:
650
- return ""
437
+ return lines
651
438
 
439
+
440
+ def _bounded_catalog(lines: list[str], maximum: int) -> str:
652
441
  catalog = CATALOG_HEADER
653
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` 获取。")
654
448
  for line in lines:
655
449
  candidate = catalog + "\n" + line
656
- if estimate_tokens(candidate) > catalog_max:
657
- break
450
+ if estimate_tokens(candidate + footer(included + 1)) > maximum:
451
+ continue
658
452
  catalog = candidate
659
453
  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:
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:
666
464
  return ""
667
- _catalog_cache[project_root] = {
668
- "signature": signature,
669
- "catalog_max": catalog_max,
670
- "catalog": catalog,
671
- }
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}
672
478
  return catalog
673
479
 
674
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
+
675
497
  def assemble_context(specs: list, heading: str) -> str:
676
498
  parts = [heading]
677
499
  parts.append("以上规范是本次开发的约束条件,生成代码必须遵循。")
@@ -695,8 +517,8 @@ def assemble_context(specs: list, heading: str) -> str:
695
517
 
696
518
 
697
519
  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)
520
+ """Diagnostics go to stderr through the shared L0 writer (fix #9)."""
521
+ cf_log.warn(msg)
700
522
 
701
523
 
702
524
  _PROCESS_START = time.monotonic()