@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
@@ -14,14 +14,17 @@ from typing import IO, Mapping, Optional, Sequence
14
14
 
15
15
  import yaml
16
16
 
17
- from cf_spec_context import BindingInput, bind_specs, new_context, save_context
18
- from cf_spec_metadata import SpecMetadata, SpecMetadataError, load_spec_metadata
19
- from cf_spec_resolver import SCOPE_PRIORITY, SpecCandidate
17
+ if __package__ in (None, ""):
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
19
+
20
+ from codeflow.cli.cf_spec_context import BindingInput, bind_specs, new_context, save_context
21
+ from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecMetadataError, load_spec_metadata
22
+ from codeflow.spec.cf_spec_resolver import SCOPE_PRIORITY, SpecCandidate
20
23
 
21
24
 
22
25
  TARGET_VERSION = "0.6.0"
23
26
  TARGET_SCHEMA = 1
24
- PACKAGE_ROOT = Path(__file__).resolve().parents[4]
27
+ PACKAGE_ROOT = Path(__file__).resolve().parents[6]
25
28
  _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
26
29
  _SUFFIXES = (".frontend.design.md", ".backend.design.md", ".design.md", ".prd.md", ".md")
27
30
 
@@ -425,17 +428,31 @@ def _write_target_config(staging: Path) -> None:
425
428
  path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
426
429
 
427
430
 
431
+ def _runtime_gitignore() -> tuple[str, list[str], str]:
432
+ path = Path(__file__).resolve().parents[2] / "runtime-resources.json"
433
+ data = json.loads(path.read_text(encoding="utf-8"))
434
+ block = data["gitignore"]
435
+ return block["start"], list(block["entries"]), block["end"]
436
+
437
+
428
438
  def _write_target_ignore(staging: Path) -> None:
429
439
  path = staging / ".code-flow/.gitignore"
430
440
  existing = path.read_text(encoding="utf-8") if path.exists() else ""
431
- block = (
432
- "# >>> code-flow:runtime schema=1\n.active-task.json\n.active-task.lock\n"
433
- ".catalog-state.json\nmigrations/\nspecs/_session/\n"
434
- "# <<< code-flow:runtime schema=1\n"
435
- )
441
+ # The packaged runtime-resources.json declaration is the single source shared
442
+ # with the CLI: migrate must not leave sessions/worktrees/backups/logs
443
+ # committable after an upgrade.
444
+ start, entries, end = _runtime_gitignore()
445
+ block = "\n".join([start, *entries, end]) + "\n"
436
446
  lines = [line for line in existing.splitlines() if line != ".inject-state"]
437
- if "# >>> code-flow:runtime schema=1" not in existing:
447
+ if start not in existing:
438
448
  path.write_text("\n".join(lines).rstrip() + "\n" + block, encoding="utf-8")
449
+ return
450
+ if end in lines:
451
+ missing = [entry for entry in entries if entry not in lines]
452
+ if missing:
453
+ at = lines.index(end)
454
+ lines[at:at] = missing
455
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
439
456
 
440
457
 
441
458
  def _target_manifest(staging: Path) -> list[dict[str, str]]:
@@ -0,0 +1,193 @@
1
+ """Read-only, input-bound Spec optimization proposals."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import difflib
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Sequence
10
+
11
+ from codeflow.core.cf_core import estimate_tokens, load_config
12
+ from codeflow.spec.cf_spec_budget import measure_path_budget
13
+ from codeflow.spec.cf_spec_impact import scan_impacts
14
+ from codeflow.spec.cf_spec_metadata import parse_spec_metadata
15
+ from codeflow.spec.cf_spec_resolver import resolve_candidates
16
+
17
+
18
+ def _selected(root: str, paths: Sequence[str]) -> list:
19
+ all_candidates = list(resolve_candidates(root, 'code', ()))
20
+ if not paths:
21
+ return all_candidates
22
+ normalized = [item.replace('\\', '/').removeprefix('.code-flow/specs/') for item in paths]
23
+ direct = {item.path for item in all_candidates if item.path in normalized}
24
+ code_paths = [path for path in normalized if path not in direct]
25
+ matched = {item.path for item in resolve_candidates(root, 'code', tuple(code_paths), matched_only=True)} if code_paths else set()
26
+ selected = [item for item in all_candidates if item.path in direct | matched]
27
+ if not selected:
28
+ raise ValueError('no matching specs for requested paths')
29
+ return selected
30
+
31
+
32
+ def _section_tokens(text: str, heading: str) -> int:
33
+ match = re.search(rf'(?im)^##\s+{re.escape(heading)}\s*$([\s\S]*?)(?=^##\s|\Z)', text)
34
+ return estimate_tokens(match.group(1)) if match else 0
35
+
36
+
37
+ def _spec_data(root: Path, candidate: object, config: dict) -> dict[str, object]:
38
+ relative = str(candidate.path)
39
+ base = (root / '.code-flow/specs').resolve()
40
+ path = (base / relative).resolve()
41
+ if base not in path.parents or not path.is_file():
42
+ raise ValueError(f'invalid spec path: {relative}')
43
+ raw = path.read_bytes()
44
+ text = raw.decode('utf-8')
45
+ domain = relative.split('/', 1)[0]
46
+ mapping = config.get('path_mapping') or {}
47
+ entry = mapping.get(domain, {}) if isinstance(mapping, dict) else {}
48
+ patterns = entry.get('patterns', []) if isinstance(entry, dict) else []
49
+ rules = [rule for rule in candidate.metadata.rules if rule.ref.startswith('RULE-')]
50
+ tokens = estimate_tokens(text)
51
+ rule_tokens = _section_tokens(text, 'Rules')
52
+ convention_tokens = _section_tokens(text, 'Conventions')
53
+ return {'id': candidate.spec_id, 'path': relative, 'sha256': hashlib.sha256(raw).hexdigest(),
54
+ 'tokens': tokens, 'rule_tokens': rule_tokens, 'convention_tokens': convention_tokens,
55
+ 'rule_percent': round(100 * rule_tokens / tokens, 2) if tokens else 0,
56
+ 'convention_percent': round(100 * convention_tokens / tokens, 2) if tokens else 0,
57
+ 'rule_ids': sorted(rule.ref for rule in rules), 'patterns': patterns,
58
+ 'verifiers': [{'rule': item.rule, 'type': item.type, 'stage': item.stage, 'config': item.config}
59
+ for item in candidate.metadata.verifiers]}
60
+
61
+
62
+ def _suggestions(specs: list[dict[str, object]], limit: int) -> list[dict[str, object]]:
63
+ return [{'path': item['path'], 'kind': 'review_conventions_split',
64
+ 'reason': '规范超过单次注入上限;需人工选择目标,规则保持原位'}
65
+ for item in specs if int(item['tokens']) > limit and int(item['convention_tokens']) > 0]
66
+
67
+
68
+ def _overlaps(specs: list[dict[str, object]]) -> list[dict[str, object]]:
69
+ groups: list[dict[str, object]] = []
70
+ for index, first in enumerate(specs):
71
+ for second in specs[index + 1:]:
72
+ shared = sorted(set(first['patterns']) & set(second['patterns']))
73
+ if shared:
74
+ groups.append({'paths': [first['path'], second['path']], 'patterns': shared})
75
+ return groups
76
+
77
+
78
+ def _target_path(root: Path, target: str) -> Path:
79
+ relative = Path(target)
80
+ if relative.is_absolute() or '..' in relative.parts or len(relative.parts) < 2 or relative.suffix != '.md':
81
+ raise ValueError('target must be a relative spec path inside an existing domain')
82
+ if relative.parts[0] in ('shared', '_session') or relative.parts[0].startswith('.'):
83
+ raise ValueError('target domain must be an injectable spec domain')
84
+ base = (root / '.code-flow/specs').resolve()
85
+ path = base / relative
86
+ if any((base.joinpath(*relative.parts[:i])).is_symlink() for i in range(1, len(relative.parts) + 1)):
87
+ raise ValueError('target path must not contain symlinks')
88
+ if base not in path.resolve().parents or not path.parent.is_dir():
89
+ raise ValueError('target domain directory must exist within specs')
90
+ if path.exists() or path.is_symlink():
91
+ raise ValueError('target already exists')
92
+ return path
93
+
94
+
95
+ def _movement(root: Path, specs: list[dict[str, object]], target: str, config: dict) -> tuple[list[dict], list[dict]]:
96
+ if len(specs) != 1:
97
+ raise ValueError('target requires exactly one source spec')
98
+ destination = _target_path(root, target)
99
+ source = specs[0]
100
+ base = root / '.code-flow/specs'
101
+ relative_source = Path(str(source['path']))
102
+ if any(base.joinpath(*relative_source.parts[:i]).is_symlink() for i in range(1, len(relative_source.parts) + 1)):
103
+ raise ValueError('source path must not contain symlinks')
104
+ text = (base / relative_source).read_text(encoding='utf-8')
105
+ match = re.search(r'(?im)^##\s+Conventions\s*$([\s\S]*?)(?=^##\s|\Z)', text)
106
+ if not match or 'RULE-' in match.group(0):
107
+ raise ValueError('source conventions missing or contain rule references')
108
+ new_id = str(source['id']) + '-conventions'
109
+ for stage in ('prd', 'design', 'plan', 'code', 'review'):
110
+ if any(item.spec_id == new_id for item in resolve_candidates(str(root), stage, ())):
111
+ raise ValueError('target spec id already exists')
112
+ relative = destination.relative_to(root / '.code-flow/specs').as_posix()
113
+ operation = {'kind': 'move_conventions', 'source': source['path'], 'target': relative, 'target_id': new_id}
114
+ source_after, target_after = render_movement(root, operation)
115
+ operation.update({'source_after_sha256': hashlib.sha256(source_after).hexdigest(),
116
+ 'target_after_sha256': hashlib.sha256(target_after).hexdigest(),
117
+ 'source_after_tokens': estimate_tokens(source_after.decode('utf-8')),
118
+ 'target_after_tokens': estimate_tokens(target_after.decode('utf-8')),
119
+ 'source_diff': ''.join(difflib.unified_diff(text.splitlines(keepends=True),
120
+ source_after.decode('utf-8').splitlines(keepends=True), fromfile=str(source['path']),
121
+ tofile=str(source['path']))),
122
+ 'target_diff': ''.join(difflib.unified_diff([], target_after.decode('utf-8').splitlines(keepends=True),
123
+ fromfile='/dev/null', tofile=relative))})
124
+ domain = relative.split('/')[0]
125
+ mapping = config.get('path_mapping') or {}
126
+ entry = mapping.get(domain, {}) if isinstance(mapping, dict) else {}
127
+ patterns = entry.get('patterns', []) if isinstance(entry, dict) else []
128
+ return [operation], [{'kind': 'auto_discovery_add', 'domain': domain, 'path': relative,
129
+ 'source_patterns': source['patterns'], 'target_patterns': patterns}]
130
+
131
+
132
+ def render_movement(root: Path, operation: dict) -> tuple[bytes, bytes]:
133
+ source = root / '.code-flow/specs' / str(operation['source'])
134
+ text = source.read_text(encoding='utf-8')
135
+ match = re.search(r'(?im)^##\s+Conventions\s*$([\s\S]*?)(?=^##\s|\Z)', text)
136
+ if not match or 'RULE-' in match.group(0):
137
+ raise ValueError('rule guard: invalid conventions section')
138
+ updated = text[:match.start()] + text[match.end():]
139
+ before = parse_spec_metadata(text, path=str(source))
140
+ target = ('---\nid: ' + str(operation['target_id']) + '\ndescription: Migrated conventions\n'
141
+ 'stages: ' + json.dumps(list(before.stages)) + '\nenforcement: advisory\n---\n\n'
142
+ '# Conventions\n\n' + match.group(0) + '\n')
143
+ after = parse_spec_metadata(updated, path=str(source))
144
+ target_meta = parse_spec_metadata(target, path=str(root / '.code-flow/specs' / str(operation['target'])))
145
+ signature = lambda meta: [(rule.ref, rule.text_sha256, rule.section, rule.enforcement) for rule in meta.rules]
146
+ if signature(before) != signature(after) or before.verifiers != after.verifiers or target_meta.verifiers:
147
+ raise ValueError('rule guard: rule or verifier changed')
148
+ if any(item.ref.startswith('RULE-') for item in target_meta.rules):
149
+ raise ValueError('rule guard: target contains rules')
150
+ return updated.encode('utf-8'), target.encode('utf-8')
151
+
152
+
153
+ def build_proposal(root: str, paths: Sequence[str], target: str = '') -> dict[str, object]:
154
+ project = Path(root).resolve()
155
+ config = load_config(root)
156
+ candidates = _selected(root, paths)
157
+ specs = [_spec_data(project, candidate, config) for candidate in candidates]
158
+ operations, mapping_diff = _movement(project, specs, target, config) if target else ([], [])
159
+ identities = {str(item['id']): str(item['path']) for item in specs}
160
+ for operation in operations:
161
+ identities[str(operation['target_id'])] = str(operation['target'])
162
+ impact = scan_impacts(root, identities)
163
+ if impact['truncated'] or impact['errors']:
164
+ raise ValueError('impact scan incomplete; proposal cannot assert complete scope')
165
+ budget = config.get('budget') or {}
166
+ if not isinstance(budget, dict):
167
+ raise ValueError('budget must be a mapping')
168
+ limit = min(int(budget.get('total', 2500)), int(budget.get('l1_max', 1700)))
169
+ if limit <= 0:
170
+ raise ValueError('budget limit must be positive')
171
+ config_hash = hashlib.sha256((project / '.code-flow/config.yml').read_bytes()).hexdigest()
172
+ code_paths = [path for path in paths if not any(path in (item['path'], '.code-flow/specs/' + str(item['path'])) for item in specs)]
173
+ measured = measure_path_budget(root, code_paths) if code_paths else None
174
+ raw_before = sum(int(item['tokens']) for item in specs)
175
+ raw_after = (raw_before - int(specs[0]['tokens']) + int(operations[0]['source_after_tokens'])
176
+ + int(operations[0]['target_after_tokens'])) if operations else raw_before
177
+ affected_files = {str(row.get('context_path') or row.get('task_path')) for row in impact['references']}
178
+ affected_hashes = {path: hashlib.sha256((project / path).read_bytes()).hexdigest() for path in sorted(affected_files)}
179
+ overlap = _overlaps(specs)
180
+ suggestions = _suggestions(specs, limit)
181
+ suggestions.extend({'paths': group['paths'], 'kind': 'review_overlap',
182
+ 'reason': '相同路径模式命中多份规范;人工评估是否合并 Conventions'} for group in overlap)
183
+ proposal = {'schema': 1, 'config_sha256': config_hash, 'specs': specs,
184
+ 'budget': {'before': {'raw_spec_tokens': raw_before, 'path_measurement': measured},
185
+ 'after': {'raw_spec_tokens': raw_after, 'path_measurement': None if operations else measured},
186
+ 'limit': limit, 'note': '变更后单次注入需在应用后重新测量'},
187
+ 'mapping_diff': mapping_diff, 'affected_contexts': impact['references'],
188
+ 'affected_file_hashes': affected_hashes,
189
+ 'scanned_files': impact['scanned_files'], 'path_overlaps': overlap, 'suggestions': suggestions,
190
+ 'operations': operations, 'note': ('请审核提案并交互确认应用。' if target
191
+ else '未指定迁移目标,提案没有可应用的文件变更。')}
192
+ proposal['proposal_id'] = hashlib.sha256(json.dumps(proposal, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
193
+ return proposal
@@ -0,0 +1,187 @@
1
+ """Guarded, recoverable application of a reviewed conventions proposal."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ from typing import Mapping
10
+
11
+ from codeflow.spec.cf_spec_optimize import build_proposal, render_movement
12
+ from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
13
+
14
+
15
+ def _digest(data: bytes) -> str:
16
+ return hashlib.sha256(data).hexdigest()
17
+
18
+
19
+ def _proposal_hash(proposal: Mapping[str, object]) -> str:
20
+ unsigned = {key: value for key, value in proposal.items() if key != 'proposal_id'}
21
+ return _digest(json.dumps(unsigned, ensure_ascii=False, sort_keys=True).encode())
22
+
23
+
24
+ def _check_context_tasks(root: Path, directory: str) -> None:
25
+ task_directory = root / directory
26
+ statuses: list[str] = []
27
+ for path in sorted(task_directory.glob('*.md')):
28
+ if path.name.endswith(('.prd.md', '.design.md', '.scenarios.md')):
29
+ continue
30
+ try:
31
+ document = parse_task_document(path.read_text(encoding='utf-8'), source=str(path))
32
+ except TaskDocumentError as exc:
33
+ if exc.code == 'task_document_empty':
34
+ continue
35
+ raise
36
+ statuses.extend(task.status for task in document.tasks)
37
+ if not statuses or any(status not in ('done', 'verified') for status in statuses):
38
+ raise ValueError('inflight_context: affected Context has incomplete or unproven tasks')
39
+
40
+
41
+ def _check_inflight(root: str, proposal: Mapping[str, object]) -> None:
42
+ refs = proposal.get('affected_contexts')
43
+ if not isinstance(refs, list):
44
+ raise ValueError('proposal impact evidence missing')
45
+ tasks = [row for row in refs if isinstance(row, dict) and row.get('source') == 'task' and not row.get('archived')]
46
+ contexts = [row for row in refs if isinstance(row, dict) and row.get('source') == 'context' and not row.get('archived')]
47
+ if any(row.get('status') not in ('done', 'verified') for row in tasks):
48
+ raise ValueError('inflight_context: affected task is not complete')
49
+ directories = {str(Path(str(row.get('context_path', ''))).parent) for row in contexts}
50
+ for directory in sorted(directories):
51
+ _check_context_tasks(Path(root), directory)
52
+
53
+
54
+ def _validate(root: str, proposal: Mapping[str, object]) -> dict[str, object]:
55
+ if proposal.get('schema') != 1 or proposal.get('proposal_id') != _proposal_hash(proposal):
56
+ raise ValueError('proposal guard: invalid proposal identity')
57
+ operations = proposal.get('operations')
58
+ if not isinstance(operations, list) or len(operations) != 1 or not isinstance(operations[0], dict):
59
+ raise ValueError('proposal guard: exactly one explicit operation is required')
60
+ operation = operations[0]
61
+ if operation.get('kind') != 'move_conventions' or not isinstance(operation.get('source'), str) or not isinstance(operation.get('target'), str):
62
+ raise ValueError('proposal guard: invalid operation')
63
+ try:
64
+ current = build_proposal(root, (operation['source'],), str(operation['target']))
65
+ except (OSError, ValueError) as exc:
66
+ raise ValueError(f'proposal_stale: {exc}') from exc
67
+ if current != proposal:
68
+ raise ValueError('proposal_stale: input, impact, or operation changed')
69
+ _check_inflight(root, proposal)
70
+ return operation
71
+
72
+
73
+ def _write(path: Path, data: bytes, mode: int = 0o600) -> None:
74
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
75
+ with os.fdopen(descriptor, 'wb') as handle:
76
+ os.fchmod(handle.fileno(), mode)
77
+ handle.write(data)
78
+ handle.flush()
79
+ os.fsync(handle.fileno())
80
+
81
+
82
+ def _journal(directory: Path, data: dict[str, object]) -> None:
83
+ temporary = directory / 'journal.tmp'
84
+ if temporary.exists():
85
+ raise ValueError('transaction journal temporary file already exists')
86
+ _write(temporary, (json.dumps(data, ensure_ascii=False, sort_keys=True) + '\n').encode())
87
+ os.replace(temporary, directory / 'journal.json')
88
+ descriptor = os.open(directory, os.O_RDONLY)
89
+ try:
90
+ os.fsync(descriptor)
91
+ finally:
92
+ os.close(descriptor)
93
+
94
+
95
+ def commit_replace(staged: Path, destination: Path) -> None:
96
+ """Replace the source; create a new target exclusively without overwriting."""
97
+ if staged.name == 'target.staged':
98
+ os.link(staged, destination)
99
+ else:
100
+ os.replace(staged, destination)
101
+
102
+
103
+ def _rollback(source: Path, target: Path, backup: Path, original_hash: str,
104
+ source_after_hash: str, target_after_hash: str, target_created: bool) -> None:
105
+ if target_created:
106
+ if not target.is_file() or _digest(target.read_bytes()) != target_after_hash:
107
+ raise ValueError('recovery_required: target changed during rollback')
108
+ target.unlink()
109
+ current = _digest(source.read_bytes()) if source.is_file() else ''
110
+ if current == original_hash:
111
+ return
112
+ if current != source_after_hash or _digest(backup.read_bytes()) != original_hash:
113
+ raise ValueError('recovery_required: source changed during rollback')
114
+ os.replace(backup, source)
115
+
116
+
117
+ def _remove_transaction(directory: Path, parent_existed: bool) -> None:
118
+ parent = directory.parent
119
+ shutil.rmtree(directory)
120
+ if not parent_existed:
121
+ parent.rmdir()
122
+
123
+
124
+ def _transaction(root: Path, proposal: Mapping[str, object], operation: Mapping[str, object]) -> dict[str, object]:
125
+ base = root / '.code-flow/specs'
126
+ source = base / str(operation['source'])
127
+ target = base / str(operation['target'])
128
+ original = source.read_bytes()
129
+ after_source, after_target = render_movement(root, dict(operation))
130
+ specs = proposal.get('specs')
131
+ if (not isinstance(specs, list) or len(specs) != 1 or not isinstance(specs[0], dict)
132
+ or _digest(original) != specs[0].get('sha256')
133
+ or _digest(after_source) != operation.get('source_after_sha256')
134
+ or _digest(after_target) != operation.get('target_after_sha256')):
135
+ raise ValueError('proposal_stale: source or proposed result changed')
136
+ parent = base / '.optimize-transactions'
137
+ if parent.is_symlink() or (parent.exists() and (not parent.is_dir() or any(parent.iterdir()))):
138
+ raise ValueError('recovery_required: unfinished optimization transaction')
139
+ parent_existed = parent.exists()
140
+ parent.mkdir(exist_ok=True)
141
+ directory = parent / str(proposal['proposal_id'])
142
+ directory.mkdir()
143
+ backup, source_stage, target_stage = directory / 'source.backup', directory / 'source.staged', directory / 'target.staged'
144
+ target_created = False
145
+ state = {'schema': 1, 'proposal_id': proposal['proposal_id'], 'source': operation['source'],
146
+ 'target': operation['target'], 'original_sha256': _digest(original),
147
+ 'source_after_sha256': _digest(after_source), 'target_after_sha256': _digest(after_target), 'state': 'prepared'}
148
+ try:
149
+ _write(backup, original, source.stat().st_mode & 0o777)
150
+ _write(source_stage, after_source, source.stat().st_mode & 0o777)
151
+ _write(target_stage, after_target, 0o644)
152
+ _journal(directory, state)
153
+ state['state'] = 'committing'
154
+ _journal(directory, state)
155
+ if _digest(source.read_bytes()) != state['original_sha256']:
156
+ raise ValueError('proposal_stale: source changed before commit')
157
+ commit_replace(source_stage, source)
158
+ commit_replace(target_stage, target)
159
+ target_created = True
160
+ if _digest(source.read_bytes()) != state['source_after_sha256'] or _digest(target.read_bytes()) != state['target_after_sha256']:
161
+ raise ValueError('post-write guard: content differs from approved proposal')
162
+ state['state'] = 'committed'
163
+ _journal(directory, state)
164
+ except (OSError, ValueError):
165
+ _rollback(source, target, backup, _digest(original), _digest(after_source), _digest(after_target), target_created)
166
+ _remove_transaction(directory, parent_existed)
167
+ raise
168
+ _remove_transaction(directory, parent_existed)
169
+ return {'decision': 'pass', 'proposal_id': proposal['proposal_id'],
170
+ 'changed': [str(operation['source']), str(operation['target'])]}
171
+
172
+
173
+ def apply_proposal(root: str, proposal: Mapping[str, object], *, confirmed: bool) -> dict[str, object]:
174
+ if not confirmed:
175
+ raise ValueError('explicit confirmation required')
176
+ project = Path(root).resolve()
177
+ lock = project / '.code-flow/specs/.optimize.lock'
178
+ try:
179
+ descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
180
+ except FileExistsError as exc:
181
+ raise ValueError('recovery_required: optimization lock exists') from exc
182
+ os.close(descriptor)
183
+ try:
184
+ operation = _validate(root, proposal)
185
+ return _transaction(project, proposal, operation)
186
+ finally:
187
+ lock.unlink()
@@ -8,7 +8,7 @@ import fnmatch
8
8
  from pathlib import Path
9
9
  from typing import Mapping, Optional, Sequence
10
10
 
11
- from cf_spec_metadata import (
11
+ from codeflow.spec.cf_spec_metadata import (
12
12
  ALLOWED_STAGES,
13
13
  SpecHeader,
14
14
  SpecMetadata,
@@ -19,8 +19,8 @@ from cf_spec_metadata import (
19
19
 
20
20
 
21
21
  SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
22
- _config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
23
- _metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
22
+ _config_cache: dict[str, tuple[int, int, int, Mapping[str, object]]] = {}
23
+ _metadata_cache: dict[str, tuple[int, int, int, SpecMetadata]] = {}
24
24
 
25
25
 
26
26
  class SpecResolutionError(ValueError):
@@ -99,12 +99,12 @@ class _RuleSource:
99
99
  enforcement: str
100
100
 
101
101
 
102
- def _fingerprint(path: Path, field: str) -> tuple[int, int]:
102
+ def _fingerprint(path: Path, field: str) -> tuple[int, int, int]:
103
103
  try:
104
104
  stat = path.stat()
105
105
  except OSError as exc:
106
106
  raise SpecResolutionError(str(path), field, f"读取状态失败: {exc}") from exc
107
- return stat.st_mtime_ns, stat.st_size
107
+ return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size
108
108
 
109
109
 
110
110
  def _load_config(root: str) -> Mapping[str, object]:
@@ -112,17 +112,17 @@ def _load_config(root: str) -> Mapping[str, object]:
112
112
  path = Path(root) / ".code-flow" / "config.yml"
113
113
  if not path.is_file():
114
114
  raise SpecResolutionError(str(path), "config", "缺失 config.yml")
115
- mtime, size = _fingerprint(path, "config")
115
+ signature = _fingerprint(path, "config")
116
116
  cached = _config_cache.get(str(path))
117
- if cached and cached[:2] == (mtime, size):
118
- return cached[2]
117
+ if cached and cached[:3] == signature:
118
+ return cached[3]
119
119
  try:
120
120
  loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
121
121
  except (OSError, UnicodeError, yaml.YAMLError) as exc:
122
122
  raise SpecResolutionError(str(path), "config", f"解析失败: {exc}") from exc
123
123
  if not isinstance(loaded, dict) or not all(isinstance(key, str) for key in loaded):
124
124
  raise SpecResolutionError(str(path), "config", "顶层必须是字符串 key mapping")
125
- _config_cache[str(path)] = (mtime, size, loaded)
125
+ _config_cache[str(path)] = (*signature, loaded)
126
126
  return loaded
127
127
 
128
128
 
@@ -161,12 +161,12 @@ def _non_injectable(mapping: Mapping[str, object], config_path: str) -> set[str]
161
161
 
162
162
 
163
163
  def _load_metadata(path: Path) -> SpecMetadata:
164
- mtime, size = _fingerprint(path, "spec")
164
+ signature = _fingerprint(path, "spec")
165
165
  cached = _metadata_cache.get(str(path))
166
- if cached and cached[:2] == (mtime, size):
167
- return cached[2]
166
+ if cached and cached[:3] == signature:
167
+ return cached[3]
168
168
  metadata = load_spec_metadata(str(path))
169
- _metadata_cache[str(path)] = (mtime, size, metadata)
169
+ _metadata_cache[str(path)] = (*signature, metadata)
170
170
  return metadata
171
171
 
172
172
 
@@ -231,7 +231,27 @@ def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
231
231
  return tuple(result)
232
232
 
233
233
 
234
- def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
234
+ def _routing_paths(
235
+ spec_root: Path, excluded: set[str], mapping: Mapping[str, object],
236
+ config_path: str, paths: Sequence[str], matched_only: bool,
237
+ ) -> tuple[Path, ...]:
238
+ discovered = _spec_paths(spec_root, excluded)
239
+ if not matched_only:
240
+ return discovered
241
+ relevant = []
242
+ for path in discovered:
243
+ relative = path.relative_to(spec_root).as_posix()
244
+ domain = relative.split("/", 1)[0]
245
+ patterns = _patterns(_domain_config(mapping, domain, config_path),
246
+ f"path_mapping.{domain}.patterns", config_path)
247
+ if _candidate_scope(relative, patterns, paths)[0] != "unmatched":
248
+ relevant.append(path)
249
+ return tuple(relevant)
250
+
251
+
252
+ def resolve_candidates(
253
+ root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
254
+ ) -> tuple[SpecCandidate, ...]:
235
255
  if stage not in ALLOWED_STAGES:
236
256
  raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
237
257
  normalized_paths = tuple(path.replace("\\", "/") for path in paths)
@@ -244,7 +264,7 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
244
264
  raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
245
265
  candidates: list[SpecCandidate] = []
246
266
  ids: dict[str, str] = {}
247
- for path in _spec_paths(spec_root, excluded):
267
+ for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
248
268
  candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
249
269
  if candidate is None:
250
270
  continue
@@ -257,7 +277,8 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
257
277
 
258
278
 
259
279
  def resolve_candidate_headers(
260
- root: str, stage: str, paths: Sequence[str]
280
+ root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
281
+ excluded_paths: Sequence[str] = (),
261
282
  ) -> tuple[SpecCandidateHeader, ...]:
262
283
  """Resolve candidates from routing frontmatter without parsing bodies or hashes."""
263
284
  if stage not in ALLOWED_STAGES:
@@ -267,12 +288,13 @@ def resolve_candidate_headers(
267
288
  config_path = str(Path(root) / ".code-flow" / "config.yml")
268
289
  mapping = _path_mapping(config, config_path)
269
290
  excluded = _non_injectable(mapping, config_path)
291
+ excluded.update(excluded_paths)
270
292
  spec_root = Path(root) / ".code-flow" / "specs"
271
293
  if not spec_root.is_dir():
272
294
  raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
273
295
  candidates: list[SpecCandidateHeader] = []
274
296
  ids: dict[str, str] = {}
275
- for path in _spec_paths(spec_root, excluded):
297
+ for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
276
298
  candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
277
299
  if candidate is None:
278
300
  continue