@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,369 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Deterministic stage/path candidate resolution for schema-v1 Specs."""
3
-
4
- from __future__ import annotations
5
-
6
- from dataclasses import dataclass
7
- import fnmatch
8
- from pathlib import Path
9
- from typing import Mapping, Optional, Sequence
10
-
11
- from codeflow.spec.cf_spec_metadata import (
12
- ALLOWED_STAGES,
13
- SpecHeader,
14
- SpecMetadata,
15
- SpecRule,
16
- load_spec_header,
17
- load_spec_metadata,
18
- )
19
-
20
-
21
- SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
22
- _config_cache: dict[str, tuple[int, int, int, Mapping[str, object]]] = {}
23
- _metadata_cache: dict[str, tuple[int, int, int, SpecMetadata]] = {}
24
-
25
-
26
- class SpecResolutionError(ValueError):
27
- """An explicit resolver/configuration failure."""
28
-
29
- def __init__(self, path: str, field: str, message: str) -> None:
30
- self.path = path
31
- self.field = field
32
- self.message = message
33
- super().__init__(f"{path}: resolution_error {field}: {message}")
34
-
35
-
36
- @dataclass(frozen=True)
37
- class SpecCandidate:
38
- spec_id: str
39
- path: str
40
- scope: str
41
- priority: int
42
- matched_paths: tuple[str, ...]
43
- metadata: SpecMetadata
44
-
45
- @property
46
- def required_rule_count(self) -> int:
47
- if self.metadata.enforcement == "advisory":
48
- return 0
49
- return sum(rule.enforcement == "required" for rule in self.metadata.rules)
50
-
51
-
52
- @dataclass(frozen=True)
53
- class SpecCandidateHeader:
54
- spec_id: str
55
- path: str
56
- scope: str
57
- priority: int
58
- matched_paths: tuple[str, ...]
59
- enforcement: str
60
-
61
-
62
- @dataclass(frozen=True)
63
- class ResolvedRule:
64
- ref: str
65
- spec_id: str
66
- text: str
67
- enforcement: str
68
- scope: str
69
- priority: int
70
- overridden_spec_ids: tuple[str, ...]
71
-
72
-
73
- @dataclass(frozen=True)
74
- class RuleConflict:
75
- ref: str
76
- priority: int
77
- spec_ids: tuple[str, ...]
78
- text_sha256: tuple[str, ...]
79
-
80
-
81
- @dataclass(frozen=True)
82
- class PrecedenceResolution:
83
- candidates: tuple[SpecCandidate, ...]
84
- effective_rules: tuple[ResolvedRule, ...]
85
- conflicts: tuple[RuleConflict, ...]
86
-
87
-
88
- @dataclass(frozen=True)
89
- class CandidatePage:
90
- items: tuple[SpecCandidate, ...]
91
- total: int
92
- has_more: bool
93
-
94
-
95
- @dataclass(frozen=True)
96
- class _RuleSource:
97
- candidate: SpecCandidate
98
- rule: SpecRule
99
- enforcement: str
100
-
101
-
102
- def _fingerprint(path: Path, field: str) -> tuple[int, int, int]:
103
- try:
104
- stat = path.stat()
105
- except OSError as exc:
106
- raise SpecResolutionError(str(path), field, f"读取状态失败: {exc}") from exc
107
- return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size
108
-
109
-
110
- def _load_config(root: str) -> Mapping[str, object]:
111
- import yaml
112
- path = Path(root) / ".code-flow" / "config.yml"
113
- if not path.is_file():
114
- raise SpecResolutionError(str(path), "config", "缺失 config.yml")
115
- signature = _fingerprint(path, "config")
116
- cached = _config_cache.get(str(path))
117
- if cached and cached[:3] == signature:
118
- return cached[3]
119
- try:
120
- loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
121
- except (OSError, UnicodeError, yaml.YAMLError) as exc:
122
- raise SpecResolutionError(str(path), "config", f"解析失败: {exc}") from exc
123
- if not isinstance(loaded, dict) or not all(isinstance(key, str) for key in loaded):
124
- raise SpecResolutionError(str(path), "config", "顶层必须是字符串 key mapping")
125
- _config_cache[str(path)] = (*signature, loaded)
126
- return loaded
127
-
128
-
129
- def _path_mapping(config: Mapping[str, object], path: str) -> Mapping[str, object]:
130
- value = config.get("path_mapping")
131
- if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
132
- raise SpecResolutionError(path, "path_mapping", "必须是字符串 key mapping")
133
- return value
134
-
135
-
136
- def _domain_config(mapping: Mapping[str, object], domain: str, config_path: str) -> Mapping[str, object]:
137
- value = mapping.get(domain, {})
138
- if not isinstance(value, dict):
139
- raise SpecResolutionError(config_path, f"path_mapping.{domain}", "必须是 mapping")
140
- return value
141
-
142
-
143
- def _patterns(config: Mapping[str, object], field: str, config_path: str) -> tuple[str, ...]:
144
- value = config.get("patterns", [])
145
- if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
146
- raise SpecResolutionError(config_path, field, "patterns 必须是字符串列表")
147
- return tuple(item.replace("\\", "/") for item in value)
148
-
149
-
150
- def _non_injectable(mapping: Mapping[str, object], config_path: str) -> set[str]:
151
- excluded: set[str] = set()
152
- for domain in mapping:
153
- config = _domain_config(mapping, domain, config_path)
154
- entries = config.get("specs", [])
155
- if not isinstance(entries, list):
156
- raise SpecResolutionError(config_path, f"path_mapping.{domain}.specs", "必须是列表")
157
- for entry in entries:
158
- if isinstance(entry, dict) and entry.get("tags") == [] and isinstance(entry.get("path"), str):
159
- excluded.add(str(entry["path"]).replace("\\", "/"))
160
- return excluded
161
-
162
-
163
- def _load_metadata(path: Path) -> SpecMetadata:
164
- signature = _fingerprint(path, "spec")
165
- cached = _metadata_cache.get(str(path))
166
- if cached and cached[:3] == signature:
167
- return cached[3]
168
- metadata = load_spec_metadata(str(path))
169
- _metadata_cache[str(path)] = (*signature, metadata)
170
- return metadata
171
-
172
-
173
- def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str]) -> tuple[str, tuple[str, ...]]:
174
- if relative.startswith("_session/"):
175
- return "task", ()
176
- matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
177
- if matched:
178
- return "path", matched
179
- # A domain without patterns is truly global. A domain WITH patterns that
180
- # match nothing is irrelevant to these paths ("unmatched") — discovery
181
- # keeps it, but injection and scope expansion must not consume it.
182
- return ("global", ()) if not patterns else ("unmatched", ())
183
-
184
-
185
- def _candidate_for(
186
- path: Path,
187
- spec_root: Path,
188
- mapping: Mapping[str, object],
189
- config_path: str,
190
- stage: str,
191
- paths: Sequence[str],
192
- ) -> Optional[SpecCandidate]:
193
- relative = path.relative_to(spec_root).as_posix()
194
- metadata = _load_metadata(path)
195
- if stage not in metadata.stages:
196
- return None
197
- domain = relative.split("/", 1)[0]
198
- config = _domain_config(mapping, domain, config_path)
199
- patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
200
- scope, matched = _candidate_scope(relative, patterns, paths)
201
- return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
202
-
203
-
204
- def _candidate_header_for(
205
- path: Path,
206
- spec_root: Path,
207
- mapping: Mapping[str, object],
208
- config_path: str,
209
- stage: str,
210
- paths: Sequence[str],
211
- ) -> Optional[SpecCandidateHeader]:
212
- relative = path.relative_to(spec_root).as_posix()
213
- header: SpecHeader = load_spec_header(str(path))
214
- if stage not in header.stages:
215
- return None
216
- domain = relative.split("/", 1)[0]
217
- config = _domain_config(mapping, domain, config_path)
218
- patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
219
- scope, matched = _candidate_scope(relative, patterns, paths)
220
- return SpecCandidateHeader(
221
- header.id, relative, scope, SCOPE_PRIORITY[scope], matched, header.enforcement
222
- )
223
-
224
-
225
- def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
226
- result: list[Path] = []
227
- for path in sorted(spec_root.rglob("*.md")):
228
- relative = path.relative_to(spec_root).as_posix()
229
- if path.name != "_map.md" and not relative.startswith("_session/") and relative not in excluded:
230
- result.append(path)
231
- return tuple(result)
232
-
233
-
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, ...]:
255
- if stage not in ALLOWED_STAGES:
256
- raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
257
- normalized_paths = tuple(path.replace("\\", "/") for path in paths)
258
- config = _load_config(root)
259
- config_path = str(Path(root) / ".code-flow" / "config.yml")
260
- mapping = _path_mapping(config, config_path)
261
- excluded = _non_injectable(mapping, config_path)
262
- spec_root = Path(root) / ".code-flow" / "specs"
263
- if not spec_root.is_dir():
264
- raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
265
- candidates: list[SpecCandidate] = []
266
- ids: dict[str, str] = {}
267
- for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
268
- candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
269
- if candidate is None:
270
- continue
271
- if candidate.spec_id in ids:
272
- detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
273
- raise SpecResolutionError(str(spec_root), "spec_id", detail)
274
- ids[candidate.spec_id] = candidate.path
275
- candidates.append(candidate)
276
- return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
277
-
278
-
279
- def resolve_candidate_headers(
280
- root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
281
- excluded_paths: Sequence[str] = (),
282
- ) -> tuple[SpecCandidateHeader, ...]:
283
- """Resolve candidates from routing frontmatter without parsing bodies or hashes."""
284
- if stage not in ALLOWED_STAGES:
285
- raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
286
- normalized_paths = tuple(path.replace("\\", "/") for path in paths)
287
- config = _load_config(root)
288
- config_path = str(Path(root) / ".code-flow" / "config.yml")
289
- mapping = _path_mapping(config, config_path)
290
- excluded = _non_injectable(mapping, config_path)
291
- excluded.update(excluded_paths)
292
- spec_root = Path(root) / ".code-flow" / "specs"
293
- if not spec_root.is_dir():
294
- raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
295
- candidates: list[SpecCandidateHeader] = []
296
- ids: dict[str, str] = {}
297
- for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
298
- candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
299
- if candidate is None:
300
- continue
301
- if candidate.spec_id in ids:
302
- detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
303
- raise SpecResolutionError(str(spec_root), "spec_id", detail)
304
- ids[candidate.spec_id] = candidate.path
305
- candidates.append(candidate)
306
- return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
307
-
308
-
309
- def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
310
- if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
311
- return "advisory"
312
- return rule.enforcement
313
-
314
-
315
- def _rule_sources(candidates: Sequence[SpecCandidate]) -> dict[str, list[_RuleSource]]:
316
- grouped: dict[str, list[_RuleSource]] = {}
317
- for candidate in candidates:
318
- for rule in candidate.metadata.rules:
319
- source = _RuleSource(candidate, rule, _effective_enforcement(candidate, rule))
320
- grouped.setdefault(rule.ref, []).append(source)
321
- for sources in grouped.values():
322
- sources.sort(key=lambda item: (-item.candidate.priority, item.candidate.path))
323
- return grouped
324
-
325
-
326
- def _resolve_rule(ref: str, sources: Sequence[_RuleSource]) -> tuple[Optional[ResolvedRule], Optional[RuleConflict]]:
327
- priority = sources[0].candidate.priority
328
- top = tuple(source for source in sources if source.candidate.priority == priority)
329
- required = tuple(source for source in top if source.enforcement == "required")
330
- hashes = {source.rule.text_sha256 for source in required}
331
- if len(required) > 1 and len(hashes) > 1:
332
- conflict = RuleConflict(
333
- ref,
334
- priority,
335
- tuple(source.candidate.spec_id for source in required),
336
- tuple(source.rule.text_sha256 for source in required),
337
- )
338
- return None, conflict
339
- winner = required[0] if required else top[0]
340
- overridden = tuple(source.candidate.spec_id for source in sources if source is not winner)
341
- resolved = ResolvedRule(
342
- ref,
343
- winner.candidate.spec_id,
344
- winner.rule.text,
345
- winner.enforcement,
346
- winner.candidate.scope,
347
- priority,
348
- overridden,
349
- )
350
- return resolved, None
351
-
352
-
353
- def resolve_precedence(candidates: Sequence[SpecCandidate]) -> PrecedenceResolution:
354
- effective: list[ResolvedRule] = []
355
- conflicts: list[RuleConflict] = []
356
- for ref, sources in sorted(_rule_sources(candidates).items()):
357
- resolved, conflict = _resolve_rule(ref, sources)
358
- if resolved is not None:
359
- effective.append(resolved)
360
- if conflict is not None:
361
- conflicts.append(conflict)
362
- return PrecedenceResolution(tuple(candidates), tuple(effective), tuple(conflicts))
363
-
364
-
365
- def build_candidate_page(candidates: Sequence[SpecCandidate], limit: int) -> CandidatePage:
366
- if limit <= 0:
367
- raise SpecResolutionError("<memory>", "limit", "必须大于 0")
368
- items = tuple(candidates[:limit])
369
- return CandidatePage(items=items, total=len(candidates), has_more=len(candidates) > limit)
@@ -1,304 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Context-first prompt router for task, path, and Catalog modes."""
3
-
4
- from __future__ import annotations
5
-
6
- from dataclasses import dataclass
7
- import hashlib
8
- import json
9
- import os
10
- from pathlib import Path
11
- import tempfile
12
- import codeflow.cli.cf_spec_session as cf_spec_session
13
- from typing import Mapping, Optional, Sequence
14
-
15
- from codeflow.core.cf_core import (
16
- assemble_context,
17
- ConfigError,
18
- build_effective_mapping,
19
- build_spec_catalog,
20
- estimate_tokens,
21
- load_config,
22
- resolve_compress,
23
- )
24
- from codeflow.core.cf_log import warn
25
- from codeflow.cli.cf_spec_context import ContextError, SpecContext, load_active_task, load_context
26
- from codeflow.core.cf_context_cache import load_snapshot
27
- from codeflow.spec.cf_spec_resolver import resolve_candidate_headers
28
- from codeflow.spec.cf_spec_context_model import context_sha256
29
- from codeflow.spec.cf_task_projection import project_task_session
30
-
31
-
32
- _PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
33
-
34
-
35
- @dataclass(frozen=True)
36
- class RouteResult:
37
- mode: str
38
- text: str
39
- specs: tuple[str, ...]
40
- context_sha256: Optional[str] = None
41
- expanded_specs: tuple[str, ...] = ()
42
-
43
-
44
- class RouterError(ValueError):
45
- """Fail-closed routing error with a stable code."""
46
-
47
- def __init__(self, code: str, message: str) -> None:
48
- self.code = code
49
- super().__init__(message)
50
-
51
-
52
- def _task_file(task_dir: Path, task_id: str) -> Path:
53
- matches = []
54
- for path in sorted(task_dir.glob("*.md")):
55
- if path.name.endswith((".prd.md", ".design.md")):
56
- continue
57
- if f"## {task_id}:" in path.read_text(encoding="utf-8"):
58
- matches.append(path)
59
- if len(matches) != 1:
60
- raise RouterError("task_file_ambiguous", f"{task_id} matched {len(matches)} task files")
61
- return matches[0]
62
-
63
-
64
- def _projection_cache_path(root: str) -> Path:
65
- return Path(root) / _PROJECTION_CACHE_PATH
66
-
67
-
68
- def _context_stat(task_dir: Path) -> list[int]:
69
- info = (task_dir / "spec-context.yml").stat()
70
- return [info.st_mtime_ns, info.st_ctime_ns, info.st_size]
71
-
72
-
73
- def _load_projection_cache(root: str) -> Mapping[str, object]:
74
- try:
75
- value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
76
- except (OSError, UnicodeError, json.JSONDecodeError):
77
- return {}
78
- if not isinstance(value, dict):
79
- return {}
80
- checksum = value.pop("checksum", None)
81
- expected = hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
82
- return value if checksum == expected else {}
83
-
84
-
85
- def _cached_projection(
86
- root: str, task_dir: Path, task_id: str, current_hash: str
87
- ) -> Optional[tuple[str, bool]]:
88
- cache = _load_projection_cache(root)
89
- if (
90
- cache.get("version") != 1
91
- or cache.get("task_id") != task_id
92
- or cache.get("context_sha256") != current_hash
93
- or not isinstance(cache.get("task_file"), str)
94
- or not isinstance(cache.get("text"), str)
95
- or not isinstance(cache.get("truncated"), bool)
96
- ):
97
- return None
98
- task_file = Path(str(cache["task_file"]))
99
- if task_file.parent != task_dir or not task_file.is_file():
100
- return None
101
- try:
102
- directory_mtime = task_dir.stat().st_mtime_ns
103
- task_mtime = task_file.stat().st_mtime_ns
104
- except OSError:
105
- return None
106
- if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
107
- return None
108
- if cache.get("context_stat") != _context_stat(task_dir):
109
- return None
110
- if cache.get("projector_sha256") != hashlib.sha256(Path(cf_spec_session.__file__).read_bytes()).hexdigest():
111
- return None
112
- if cache.get("task_sha256") != hashlib.sha256(task_file.read_bytes()).hexdigest():
113
- return None
114
- return str(cache["text"]), bool(cache["truncated"])
115
-
116
-
117
- def _save_projection_cache(
118
- root: str,
119
- task_dir: Path,
120
- task_file: Path,
121
- task_id: str,
122
- current_hash: str,
123
- text: str,
124
- truncated: bool,
125
- ) -> None:
126
- try:
127
- payload = {
128
- "version": 1,
129
- "task_id": task_id,
130
- "context_sha256": current_hash,
131
- "context_stat": _context_stat(task_dir),
132
- "projector_sha256": hashlib.sha256(Path(cf_spec_session.__file__).read_bytes()).hexdigest(),
133
- "task_file": str(task_file),
134
- "task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
135
- "task_mtime_ns": task_file.stat().st_mtime_ns,
136
- "task_sha256": hashlib.sha256(task_file.read_bytes()).hexdigest(),
137
- "text": text,
138
- "truncated": truncated,
139
- }
140
- path = _projection_cache_path(root)
141
- payload["checksum"] = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
142
- path.parent.mkdir(parents=True, exist_ok=True)
143
- temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
144
- temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
145
- os.replace(temporary, path)
146
- except (OSError, TypeError, UnicodeError):
147
- if "temporary" in locals() and temporary.exists():
148
- try:
149
- temporary.unlink()
150
- except OSError:
151
- pass
152
-
153
-
154
- def _verify_bound_specs(root: str, context: SpecContext) -> None:
155
- spec_root = Path(root) / ".code-flow/specs"
156
- for binding in context.bindings:
157
- target = spec_root / binding.path
158
- try:
159
- current = hashlib.sha256(target.read_bytes()).hexdigest()
160
- except OSError as exc:
161
- raise RouterError("bound_spec_missing", f"{binding.path}: {exc}; refresh Context") from exc
162
- if current != binding.hashes.file_sha256:
163
- raise RouterError("bound_spec_drift", f"{binding.path} changed; refresh Context before coding")
164
-
165
-
166
- def _scope_expansion(root: str, context: SpecContext, paths: Sequence[str]) -> tuple[str, ...]:
167
- bound = {binding.spec_id for binding in context.bindings}
168
- candidates = resolve_candidate_headers(
169
- root, "code", paths, matched_only=True,
170
- excluded_paths=tuple(binding.path for binding in context.bindings),
171
- )
172
- return tuple(item.spec_id for item in candidates
173
- if item.spec_id not in bound and item.enforcement == "required")
174
-
175
-
176
- def _active_route(root: str, paths: Sequence[str] = (), check_scope: bool = False) -> RouteResult:
177
- try:
178
- active = load_active_task(root)
179
- task_dir = (Path(root) / active.task_dir).resolve()
180
- context, current_hash = load_snapshot(root, str(task_dir / "spec-context.yml"), load_context)
181
- except (ContextError, OSError, ValueError) as exc:
182
- raise RouterError("invalid_active_task", str(exc)) from exc
183
- if current_hash != active.context_sha256:
184
- raise RouterError(
185
- "active_context_drift",
186
- "active marker Context hash does not match; run `code-flow spec refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `code-flow spec active doctor --resync` with the hash from `code-flow spec status --json`",
187
- )
188
- _verify_bound_specs(root, context)
189
- cached = _cached_projection(root, task_dir, active.task_id, current_hash)
190
- if cached is None:
191
- task_file = _task_file(task_dir, active.task_id)
192
- try:
193
- projection = project_task_session(context, str(task_file), active.task_id, context_hash=current_hash)
194
- except ValueError as exc:
195
- raise RouterError("invalid_task_projection", str(exc)) from exc
196
- _save_projection_cache(
197
- root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
198
- )
199
- text, truncated = projection.text, projection.truncated
200
- else:
201
- text, truncated = cached
202
- if truncated:
203
- raise RouterError("task_projection_truncated", "split the TASK before coding")
204
- refs = tuple(binding.spec_id for binding in context.bindings
205
- if any(f"`{binding.spec_id}#{rule.ref}`" in text for rule in binding.rules))
206
- expanded = _scope_expansion(root, context, paths) if check_scope else ()
207
- return RouteResult("task", text, refs, current_hash, expanded)
208
-
209
-
210
- def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
211
- from codeflow.spec.cf_spec_budget import SpecBudgetError, path_specs
212
- try:
213
- specs = path_specs(root, paths, compress)
214
- except SpecBudgetError as exc:
215
- raise RouterError(exc.code, str(exc)) from exc
216
- text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
217
- budget = load_config(root).get("budget", {})
218
- if not isinstance(budget, dict):
219
- raise RouterError("invalid_budget", "budget must be a mapping")
220
- maximum = min(int(budget.get("total", 2500)), int(budget.get("l1_max", 1700)))
221
- if maximum <= 0 or estimate_tokens(text) > maximum:
222
- raise RouterError("spec_budget_exceeded", f"Matched Specs exceed {maximum} estimated tokens; create a focused TASK Context or split the editing scope. Required constraints were not truncated.")
223
- return RouteResult("path", text, tuple(item["path"] for item in specs))
224
-
225
-
226
- def _catalog_path(root: str) -> Path:
227
- return Path(root) / ".code-flow/.catalog-state.json"
228
-
229
-
230
- def _load_catalog_state(root: str) -> dict[str, object]:
231
- path = _catalog_path(root)
232
- if not path.exists():
233
- return {"version": 1, "session_id": "", "prompt_count": 0, "last_emitted": 0}
234
- try:
235
- value = json.loads(path.read_text(encoding="utf-8"))
236
- except (OSError, json.JSONDecodeError) as exc:
237
- raise RouterError("invalid_catalog_state", str(exc)) from exc
238
- if not isinstance(value, dict) or value.get("version") != 1:
239
- raise RouterError("invalid_catalog_state", "Catalog state schema must be version 1")
240
- if any(type(value.get(key)) is not int or value[key] < 0 for key in ("prompt_count", "last_emitted")):
241
- raise RouterError("invalid_catalog_state", "Catalog counters must be nonnegative integers")
242
- return value
243
-
244
-
245
- def _save_catalog_state(root: str, state: Mapping[str, object]) -> None:
246
- path = _catalog_path(root)
247
- path.parent.mkdir(parents=True, exist_ok=True)
248
- temporary = ""
249
- try:
250
- with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
251
- temporary = handle.name
252
- json.dump(state, handle, ensure_ascii=False)
253
- handle.flush()
254
- os.fsync(handle.fileno())
255
- os.replace(temporary, path)
256
- except OSError as exc:
257
- if temporary and Path(temporary).exists():
258
- Path(temporary).unlink()
259
- warn(f"Spec Catalog dedup state could not be saved: {exc}")
260
-
261
-
262
- def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) -> RouteResult:
263
- state = _load_catalog_state(root)
264
- same_session = state.get("session_id") == session_id
265
- count = int(state.get("prompt_count", 0)) + 1 if same_session else 1
266
- last = int(state.get("last_emitted", 0)) if same_session else 0
267
- workflow = config.get("spec_workflow") if isinstance(config.get("spec_workflow"), dict) else {}
268
- catalog_config = workflow.get("catalog") if isinstance(workflow.get("catalog"), dict) else {}
269
- window = int(catalog_config.get("dedup_window", 5))
270
- due = window <= 0 or last == 0 or count - last >= window
271
- next_state = {"version": 1, "session_id": session_id, "prompt_count": count, "last_emitted": count if due else last}
272
- if not due:
273
- _save_catalog_state(root, next_state)
274
- return RouteResult("catalog", "", ())
275
- budget = config.get("budget") if isinstance(config.get("budget"), dict) else {}
276
- maximum = int(budget.get("catalog_max", 200))
277
- mapping = build_effective_mapping(root, config.get("path_mapping") or {})
278
- text = build_spec_catalog(root, mapping, maximum)
279
- from codeflow.spec.cf_injection import defer_commit
280
- defer_commit(lambda: _save_catalog_state(root, next_state))
281
- return RouteResult("catalog", text, ())
282
-
283
-
284
- def route_prompt(root: str, paths: Sequence[str], session_id: str, *, check_scope: bool = False) -> RouteResult:
285
- try:
286
- return _route_prompt(root, paths, session_id, check_scope)
287
- except RouterError:
288
- raise
289
- except (ConfigError, ContextError, OSError, UnicodeError, ValueError, TypeError) as exc:
290
- raise RouterError(getattr(exc, "code", "invalid_routing_input"), str(exc)) from exc
291
-
292
-
293
- def _route_prompt(root: str, paths: Sequence[str], session_id: str, check_scope: bool) -> RouteResult:
294
- """Choose exactly one route; an invalid active marker never falls back."""
295
- marker = Path(root) / ".code-flow/.active-task.json"
296
- if marker.exists():
297
- return _active_route(root, paths, check_scope)
298
- config = load_config(root)
299
- if not config:
300
- return RouteResult("none", "", ())
301
- if paths:
302
- quality = config.get("quality_loop")
303
- return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
304
- return _catalog_route(root, session_id, config)