@michaelxwb/code-flow 0.6.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,347 +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 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, Mapping[str, object]]] = {}
23
- _metadata_cache: dict[str, tuple[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]:
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_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
- mtime, size = _fingerprint(path, "config")
116
- cached = _config_cache.get(str(path))
117
- if cached and cached[:2] == (mtime, size):
118
- return cached[2]
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)] = (mtime, size, 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
- mtime, size = _fingerprint(path, "spec")
165
- cached = _metadata_cache.get(str(path))
166
- if cached and cached[:2] == (mtime, size):
167
- return cached[2]
168
- metadata = load_spec_metadata(str(path))
169
- _metadata_cache[str(path)] = (mtime, size, 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 resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
235
- if stage not in ALLOWED_STAGES:
236
- raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
237
- normalized_paths = tuple(path.replace("\\", "/") for path in paths)
238
- config = _load_config(root)
239
- config_path = str(Path(root) / ".code-flow" / "config.yml")
240
- mapping = _path_mapping(config, config_path)
241
- excluded = _non_injectable(mapping, config_path)
242
- spec_root = Path(root) / ".code-flow" / "specs"
243
- if not spec_root.is_dir():
244
- raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
245
- candidates: list[SpecCandidate] = []
246
- ids: dict[str, str] = {}
247
- for path in _spec_paths(spec_root, excluded):
248
- candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
249
- if candidate is None:
250
- continue
251
- if candidate.spec_id in ids:
252
- detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
253
- raise SpecResolutionError(str(spec_root), "spec_id", detail)
254
- ids[candidate.spec_id] = candidate.path
255
- candidates.append(candidate)
256
- return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
257
-
258
-
259
- def resolve_candidate_headers(
260
- root: str, stage: str, paths: Sequence[str]
261
- ) -> tuple[SpecCandidateHeader, ...]:
262
- """Resolve candidates from routing frontmatter without parsing bodies or hashes."""
263
- if stage not in ALLOWED_STAGES:
264
- raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
265
- normalized_paths = tuple(path.replace("\\", "/") for path in paths)
266
- config = _load_config(root)
267
- config_path = str(Path(root) / ".code-flow" / "config.yml")
268
- mapping = _path_mapping(config, config_path)
269
- excluded = _non_injectable(mapping, config_path)
270
- spec_root = Path(root) / ".code-flow" / "specs"
271
- if not spec_root.is_dir():
272
- raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
273
- candidates: list[SpecCandidateHeader] = []
274
- ids: dict[str, str] = {}
275
- for path in _spec_paths(spec_root, excluded):
276
- candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
277
- if candidate is None:
278
- continue
279
- if candidate.spec_id in ids:
280
- detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
281
- raise SpecResolutionError(str(spec_root), "spec_id", detail)
282
- ids[candidate.spec_id] = candidate.path
283
- candidates.append(candidate)
284
- return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
285
-
286
-
287
- def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
288
- if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
289
- return "advisory"
290
- return rule.enforcement
291
-
292
-
293
- def _rule_sources(candidates: Sequence[SpecCandidate]) -> dict[str, list[_RuleSource]]:
294
- grouped: dict[str, list[_RuleSource]] = {}
295
- for candidate in candidates:
296
- for rule in candidate.metadata.rules:
297
- source = _RuleSource(candidate, rule, _effective_enforcement(candidate, rule))
298
- grouped.setdefault(rule.ref, []).append(source)
299
- for sources in grouped.values():
300
- sources.sort(key=lambda item: (-item.candidate.priority, item.candidate.path))
301
- return grouped
302
-
303
-
304
- def _resolve_rule(ref: str, sources: Sequence[_RuleSource]) -> tuple[Optional[ResolvedRule], Optional[RuleConflict]]:
305
- priority = sources[0].candidate.priority
306
- top = tuple(source for source in sources if source.candidate.priority == priority)
307
- required = tuple(source for source in top if source.enforcement == "required")
308
- hashes = {source.rule.text_sha256 for source in required}
309
- if len(required) > 1 and len(hashes) > 1:
310
- conflict = RuleConflict(
311
- ref,
312
- priority,
313
- tuple(source.candidate.spec_id for source in required),
314
- tuple(source.rule.text_sha256 for source in required),
315
- )
316
- return None, conflict
317
- winner = required[0] if required else top[0]
318
- overridden = tuple(source.candidate.spec_id for source in sources if source is not winner)
319
- resolved = ResolvedRule(
320
- ref,
321
- winner.candidate.spec_id,
322
- winner.rule.text,
323
- winner.enforcement,
324
- winner.candidate.scope,
325
- priority,
326
- overridden,
327
- )
328
- return resolved, None
329
-
330
-
331
- def resolve_precedence(candidates: Sequence[SpecCandidate]) -> PrecedenceResolution:
332
- effective: list[ResolvedRule] = []
333
- conflicts: list[RuleConflict] = []
334
- for ref, sources in sorted(_rule_sources(candidates).items()):
335
- resolved, conflict = _resolve_rule(ref, sources)
336
- if resolved is not None:
337
- effective.append(resolved)
338
- if conflict is not None:
339
- conflicts.append(conflict)
340
- return PrecedenceResolution(tuple(candidates), tuple(effective), tuple(conflicts))
341
-
342
-
343
- def build_candidate_page(candidates: Sequence[SpecCandidate], limit: int) -> CandidatePage:
344
- if limit <= 0:
345
- raise SpecResolutionError("<memory>", "limit", "必须大于 0")
346
- items = tuple(candidates[:limit])
347
- return CandidatePage(items=items, total=len(candidates), has_more=len(candidates) > limit)
@@ -1,244 +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 json
8
- import os
9
- from pathlib import Path
10
- import tempfile
11
- from typing import Mapping, Optional, Sequence
12
-
13
- from cf_core import (
14
- assemble_context,
15
- build_effective_mapping,
16
- build_spec_catalog,
17
- compress_content,
18
- load_config,
19
- parse_spec_frontmatter,
20
- resolve_compress,
21
- )
22
- from cf_spec_context import ContextError, load_active_task, load_context
23
- from cf_spec_resolver import resolve_candidates
24
- from cf_spec_session import context_sha256, project_task_session
25
-
26
-
27
- _PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
28
-
29
-
30
- @dataclass(frozen=True)
31
- class RouteResult:
32
- mode: str
33
- text: str
34
- specs: tuple[str, ...]
35
- context_sha256: Optional[str] = None
36
-
37
-
38
- class RouterError(ValueError):
39
- """Fail-closed routing error with a stable code."""
40
-
41
- def __init__(self, code: str, message: str) -> None:
42
- self.code = code
43
- super().__init__(message)
44
-
45
-
46
- def _task_file(task_dir: Path, task_id: str) -> Path:
47
- matches = []
48
- for path in sorted(task_dir.glob("*.md")):
49
- if path.name.endswith((".prd.md", ".design.md")):
50
- continue
51
- if f"## {task_id}:" in path.read_text(encoding="utf-8"):
52
- matches.append(path)
53
- if len(matches) != 1:
54
- raise RouterError("task_file_ambiguous", f"{task_id} matched {len(matches)} task files")
55
- return matches[0]
56
-
57
-
58
- def _projection_cache_path(root: str) -> Path:
59
- return Path(root) / _PROJECTION_CACHE_PATH
60
-
61
-
62
- def _load_projection_cache(root: str) -> Mapping[str, object]:
63
- try:
64
- value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
65
- except (OSError, UnicodeError, json.JSONDecodeError):
66
- return {}
67
- return value if isinstance(value, dict) else {}
68
-
69
-
70
- def _cached_projection(
71
- root: str, task_dir: Path, task_id: str, current_hash: str
72
- ) -> Optional[tuple[str, bool]]:
73
- cache = _load_projection_cache(root)
74
- if (
75
- cache.get("version") != 1
76
- or cache.get("task_id") != task_id
77
- or cache.get("context_sha256") != current_hash
78
- or not isinstance(cache.get("task_file"), str)
79
- or not isinstance(cache.get("text"), str)
80
- or not isinstance(cache.get("truncated"), bool)
81
- ):
82
- return None
83
- task_file = Path(str(cache["task_file"]))
84
- if task_file.parent != task_dir or not task_file.is_file():
85
- return None
86
- try:
87
- directory_mtime = task_dir.stat().st_mtime_ns
88
- task_mtime = task_file.stat().st_mtime_ns
89
- except OSError:
90
- return None
91
- if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
92
- return None
93
- return str(cache["text"]), bool(cache["truncated"])
94
-
95
-
96
- def _save_projection_cache(
97
- root: str,
98
- task_dir: Path,
99
- task_file: Path,
100
- task_id: str,
101
- current_hash: str,
102
- text: str,
103
- truncated: bool,
104
- ) -> None:
105
- try:
106
- payload = {
107
- "version": 1,
108
- "task_id": task_id,
109
- "context_sha256": current_hash,
110
- "task_file": str(task_file),
111
- "task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
112
- "task_mtime_ns": task_file.stat().st_mtime_ns,
113
- "text": text,
114
- "truncated": truncated,
115
- }
116
- path = _projection_cache_path(root)
117
- path.parent.mkdir(parents=True, exist_ok=True)
118
- temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
119
- temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
120
- os.replace(temporary, path)
121
- except (OSError, TypeError, UnicodeError):
122
- if "temporary" in locals() and temporary.exists():
123
- try:
124
- temporary.unlink()
125
- except OSError:
126
- pass
127
-
128
-
129
- def _active_route(root: str) -> RouteResult:
130
- try:
131
- active = load_active_task(root)
132
- task_dir = (Path(root) / active.task_dir).resolve()
133
- context = load_context(str(task_dir / "spec-context.yml"))
134
- except (ContextError, OSError, ValueError) as exc:
135
- raise RouterError("invalid_active_task", str(exc)) from exc
136
- current_hash = context_sha256(context)
137
- if current_hash != active.context_sha256:
138
- raise RouterError(
139
- "active_context_drift",
140
- "active marker Context hash does not match; run `cf_spec_context.py refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `active doctor --resync` with the hash from `status --json`",
141
- )
142
- cached = _cached_projection(root, task_dir, active.task_id, current_hash)
143
- if cached is None:
144
- task_file = _task_file(task_dir, active.task_id)
145
- projection = project_task_session(context, str(task_file), active.task_id)
146
- _save_projection_cache(
147
- root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
148
- )
149
- text, truncated = projection.text, projection.truncated
150
- else:
151
- text, truncated = cached
152
- if truncated:
153
- raise RouterError("task_projection_truncated", "split the TASK before coding")
154
- refs = tuple(binding.spec_id for binding in context.bindings)
155
- return RouteResult("task", text, refs, current_hash)
156
-
157
-
158
- def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
159
- try:
160
- candidates = resolve_candidates(root, "code", tuple(paths))
161
- except ValueError as exc:
162
- raise RouterError("invalid_spec_metadata", str(exc)) from exc
163
- specs = []
164
- for candidate in candidates:
165
- if candidate.scope == "unmatched":
166
- continue
167
- target = Path(root) / ".code-flow/specs" / candidate.path
168
- try:
169
- raw = target.read_text(encoding="utf-8")
170
- except OSError as exc:
171
- raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
172
- unused_frontmatter, content = parse_spec_frontmatter(raw)
173
- del unused_frontmatter
174
- final_content = compress_content(content.strip()) if compress else content.strip()
175
- specs.append({"path": candidate.path, "content": final_content, "tier": 1})
176
- text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
177
- return RouteResult("path", text, tuple(item.path for item in candidates))
178
-
179
-
180
- def _catalog_path(root: str) -> Path:
181
- return Path(root) / ".code-flow/.catalog-state.json"
182
-
183
-
184
- def _load_catalog_state(root: str) -> dict[str, object]:
185
- path = _catalog_path(root)
186
- if not path.exists():
187
- return {"version": 1, "session_id": "", "prompt_count": 0, "last_emitted": 0}
188
- try:
189
- value = json.loads(path.read_text(encoding="utf-8"))
190
- except (OSError, json.JSONDecodeError) as exc:
191
- raise RouterError("invalid_catalog_state", str(exc)) from exc
192
- if not isinstance(value, dict) or value.get("version") != 1:
193
- raise RouterError("invalid_catalog_state", "Catalog state schema must be version 1")
194
- return value
195
-
196
-
197
- def _save_catalog_state(root: str, state: Mapping[str, object]) -> None:
198
- path = _catalog_path(root)
199
- path.parent.mkdir(parents=True, exist_ok=True)
200
- temporary = ""
201
- try:
202
- with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
203
- temporary = handle.name
204
- json.dump(state, handle, ensure_ascii=False)
205
- handle.flush()
206
- os.fsync(handle.fileno())
207
- os.replace(temporary, path)
208
- except OSError as exc:
209
- if temporary and Path(temporary).exists():
210
- Path(temporary).unlink()
211
- raise RouterError("catalog_state_write_failed", str(exc)) from exc
212
-
213
-
214
- def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) -> RouteResult:
215
- state = _load_catalog_state(root)
216
- same_session = state.get("session_id") == session_id
217
- count = int(state.get("prompt_count", 0)) + 1 if same_session else 1
218
- last = int(state.get("last_emitted", 0)) if same_session else 0
219
- workflow = config.get("spec_workflow") if isinstance(config.get("spec_workflow"), dict) else {}
220
- catalog_config = workflow.get("catalog") if isinstance(workflow.get("catalog"), dict) else {}
221
- window = int(catalog_config.get("dedup_window", 5))
222
- due = window <= 0 or last == 0 or count - last >= window
223
- next_state = {"version": 1, "session_id": session_id, "prompt_count": count, "last_emitted": count if due else last}
224
- _save_catalog_state(root, next_state)
225
- if not due:
226
- return RouteResult("catalog", "", ())
227
- budget = config.get("budget") if isinstance(config.get("budget"), dict) else {}
228
- maximum = int(budget.get("catalog_max", 200))
229
- mapping = build_effective_mapping(root, config.get("path_mapping") or {})
230
- return RouteResult("catalog", build_spec_catalog(root, mapping, maximum), ())
231
-
232
-
233
- def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResult:
234
- """Choose exactly one route; an invalid active marker never falls back."""
235
- marker = Path(root) / ".code-flow/.active-task.json"
236
- if marker.exists():
237
- return _active_route(root)
238
- config = load_config(root)
239
- if not config:
240
- return RouteResult("none", "", ())
241
- if paths:
242
- quality = config.get("quality_loop")
243
- return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
244
- return _catalog_route(root, session_id, config)