@michaelxwb/code-flow 0.5.2 → 0.6.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 (82) hide show
  1. package/docs/migrations/spec-workflow-0.6.0.md +42 -0
  2. package/package.json +4 -2
  3. package/src/adapters/claude/CLAUDE.md +11 -17
  4. package/src/adapters/claude/commands/cf-init.md +5 -4
  5. package/src/adapters/claude/commands/cf-spec.md +63 -0
  6. package/src/adapters/claude/commands/cf-sync.md +25 -0
  7. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  8. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  9. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  10. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  11. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  12. package/src/adapters/claude/settings.local.json +1 -11
  13. package/src/adapters/codex/AGENTS.md +11 -17
  14. package/src/adapters/codex/hooks.json +6 -4
  15. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  16. package/src/adapters/codex/skills/cf-spec/SKILL.md +56 -0
  17. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  18. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  19. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  20. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  21. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  22. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  23. package/src/adapters/costrict/CLAUDE.md +11 -17
  24. package/src/adapters/costrict/commands/cf-init.md +5 -4
  25. package/src/adapters/costrict/commands/cf-spec.md +63 -0
  26. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  27. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  28. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  29. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  30. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  31. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  32. package/src/adapters/costrict/settings.local.json +1 -11
  33. package/src/adapters/opencode/AGENTS.md +11 -17
  34. package/src/adapters/opencode/commands/cf-init.md +4 -3
  35. package/src/adapters/opencode/commands/cf-spec.md +55 -0
  36. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  37. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  38. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  39. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  40. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  41. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  42. package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
  43. package/src/cli.js +51 -2
  44. package/src/core/code-flow/config.yml +14 -16
  45. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  46. package/src/core/code-flow/scripts/cf_core.py +36 -256
  47. package/src/core/code-flow/scripts/cf_log.py +8 -0
  48. package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
  49. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
  50. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  51. package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
  52. package/src/core/code-flow/scripts/cf_spec_gate.py +230 -0
  53. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  54. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  55. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  56. package/src/core/code-flow/scripts/cf_spec_router.py +149 -0
  57. package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
  58. package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
  59. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  60. package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
  61. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  62. package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
  63. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
  64. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  65. package/src/core/code-flow/specs/backend/database.md +13 -1
  66. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  67. package/src/core/code-flow/specs/backend/logging.md +13 -1
  68. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  69. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  70. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  71. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  72. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  73. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  74. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  75. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  76. package/src/migrate/spec-workflow.js +238 -0
  77. package/src/adapters/claude/commands/cf-inject.md +0 -55
  78. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  79. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  80. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  81. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  82. package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
@@ -0,0 +1,271 @@
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
+ import yaml
12
+
13
+ from cf_spec_metadata import ALLOWED_STAGES, SpecMetadata, SpecRule, load_spec_metadata
14
+
15
+
16
+ SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
17
+ _config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
18
+ _metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
19
+
20
+
21
+ class SpecResolutionError(ValueError):
22
+ """An explicit resolver/configuration failure."""
23
+
24
+ def __init__(self, path: str, field: str, message: str) -> None:
25
+ self.path = path
26
+ self.field = field
27
+ self.message = message
28
+ super().__init__(f"{path}: resolution_error {field}: {message}")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SpecCandidate:
33
+ spec_id: str
34
+ path: str
35
+ scope: str
36
+ priority: int
37
+ matched_paths: tuple[str, ...]
38
+ metadata: SpecMetadata
39
+
40
+ @property
41
+ def required_rule_count(self) -> int:
42
+ if self.metadata.enforcement == "advisory":
43
+ return 0
44
+ return sum(rule.enforcement == "required" for rule in self.metadata.rules)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ResolvedRule:
49
+ ref: str
50
+ spec_id: str
51
+ text: str
52
+ enforcement: str
53
+ scope: str
54
+ priority: int
55
+ overridden_spec_ids: tuple[str, ...]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class RuleConflict:
60
+ ref: str
61
+ priority: int
62
+ spec_ids: tuple[str, ...]
63
+ text_sha256: tuple[str, ...]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class PrecedenceResolution:
68
+ candidates: tuple[SpecCandidate, ...]
69
+ effective_rules: tuple[ResolvedRule, ...]
70
+ conflicts: tuple[RuleConflict, ...]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class CandidatePage:
75
+ items: tuple[SpecCandidate, ...]
76
+ total: int
77
+ has_more: bool
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class _RuleSource:
82
+ candidate: SpecCandidate
83
+ rule: SpecRule
84
+ enforcement: str
85
+
86
+
87
+ def _fingerprint(path: Path, field: str) -> tuple[int, int]:
88
+ try:
89
+ stat = path.stat()
90
+ except OSError as exc:
91
+ raise SpecResolutionError(str(path), field, f"读取状态失败: {exc}") from exc
92
+ return stat.st_mtime_ns, stat.st_size
93
+
94
+
95
+ def _load_config(root: str) -> Mapping[str, object]:
96
+ path = Path(root) / ".code-flow" / "config.yml"
97
+ if not path.is_file():
98
+ raise SpecResolutionError(str(path), "config", "缺失 config.yml")
99
+ mtime, size = _fingerprint(path, "config")
100
+ cached = _config_cache.get(str(path))
101
+ if cached and cached[:2] == (mtime, size):
102
+ return cached[2]
103
+ try:
104
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
105
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
106
+ raise SpecResolutionError(str(path), "config", f"解析失败: {exc}") from exc
107
+ if not isinstance(loaded, dict) or not all(isinstance(key, str) for key in loaded):
108
+ raise SpecResolutionError(str(path), "config", "顶层必须是字符串 key mapping")
109
+ _config_cache[str(path)] = (mtime, size, loaded)
110
+ return loaded
111
+
112
+
113
+ def _path_mapping(config: Mapping[str, object], path: str) -> Mapping[str, object]:
114
+ value = config.get("path_mapping")
115
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
116
+ raise SpecResolutionError(path, "path_mapping", "必须是字符串 key mapping")
117
+ return value
118
+
119
+
120
+ def _domain_config(mapping: Mapping[str, object], domain: str, config_path: str) -> Mapping[str, object]:
121
+ value = mapping.get(domain, {})
122
+ if not isinstance(value, dict):
123
+ raise SpecResolutionError(config_path, f"path_mapping.{domain}", "必须是 mapping")
124
+ return value
125
+
126
+
127
+ def _patterns(config: Mapping[str, object], field: str, config_path: str) -> tuple[str, ...]:
128
+ value = config.get("patterns", [])
129
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
130
+ raise SpecResolutionError(config_path, field, "patterns 必须是字符串列表")
131
+ return tuple(item.replace("\\", "/") for item in value)
132
+
133
+
134
+ def _non_injectable(mapping: Mapping[str, object], config_path: str) -> set[str]:
135
+ excluded: set[str] = set()
136
+ for domain in mapping:
137
+ config = _domain_config(mapping, domain, config_path)
138
+ entries = config.get("specs", [])
139
+ if not isinstance(entries, list):
140
+ raise SpecResolutionError(config_path, f"path_mapping.{domain}.specs", "必须是列表")
141
+ for entry in entries:
142
+ if isinstance(entry, dict) and entry.get("tags") == [] and isinstance(entry.get("path"), str):
143
+ excluded.add(str(entry["path"]).replace("\\", "/"))
144
+ return excluded
145
+
146
+
147
+ def _load_metadata(path: Path) -> SpecMetadata:
148
+ mtime, size = _fingerprint(path, "spec")
149
+ cached = _metadata_cache.get(str(path))
150
+ if cached and cached[:2] == (mtime, size):
151
+ return cached[2]
152
+ metadata = load_spec_metadata(str(path))
153
+ _metadata_cache[str(path)] = (mtime, size, metadata)
154
+ return metadata
155
+
156
+
157
+ def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str]) -> tuple[str, tuple[str, ...]]:
158
+ if relative.startswith("_session/"):
159
+ return "task", ()
160
+ matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
161
+ return ("path", matched) if matched else ("global", ())
162
+
163
+
164
+ def _candidate_for(
165
+ path: Path,
166
+ spec_root: Path,
167
+ mapping: Mapping[str, object],
168
+ config_path: str,
169
+ stage: str,
170
+ paths: Sequence[str],
171
+ ) -> Optional[SpecCandidate]:
172
+ relative = path.relative_to(spec_root).as_posix()
173
+ metadata = _load_metadata(path)
174
+ if stage not in metadata.stages:
175
+ return None
176
+ domain = relative.split("/", 1)[0]
177
+ config = _domain_config(mapping, domain, config_path)
178
+ patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
179
+ scope, matched = _candidate_scope(relative, patterns, paths)
180
+ return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
181
+
182
+
183
+ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
184
+ if stage not in ALLOWED_STAGES:
185
+ raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
186
+ normalized_paths = tuple(path.replace("\\", "/") for path in paths)
187
+ config = _load_config(root)
188
+ config_path = str(Path(root) / ".code-flow" / "config.yml")
189
+ mapping = _path_mapping(config, config_path)
190
+ excluded = _non_injectable(mapping, config_path)
191
+ spec_root = Path(root) / ".code-flow" / "specs"
192
+ if not spec_root.is_dir():
193
+ raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
194
+ candidates: list[SpecCandidate] = []
195
+ ids: dict[str, str] = {}
196
+ for path in sorted(spec_root.rglob("*.md")):
197
+ relative = path.relative_to(spec_root).as_posix()
198
+ if path.name == "_map.md" or relative.startswith("_session/") or relative in excluded:
199
+ continue
200
+ candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
201
+ if candidate is None:
202
+ continue
203
+ if candidate.spec_id in ids:
204
+ detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
205
+ raise SpecResolutionError(str(spec_root), "spec_id", detail)
206
+ ids[candidate.spec_id] = candidate.path
207
+ candidates.append(candidate)
208
+ return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
209
+
210
+
211
+ def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
212
+ if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
213
+ return "advisory"
214
+ return rule.enforcement
215
+
216
+
217
+ def _rule_sources(candidates: Sequence[SpecCandidate]) -> dict[str, list[_RuleSource]]:
218
+ grouped: dict[str, list[_RuleSource]] = {}
219
+ for candidate in candidates:
220
+ for rule in candidate.metadata.rules:
221
+ source = _RuleSource(candidate, rule, _effective_enforcement(candidate, rule))
222
+ grouped.setdefault(rule.ref, []).append(source)
223
+ for sources in grouped.values():
224
+ sources.sort(key=lambda item: (-item.candidate.priority, item.candidate.path))
225
+ return grouped
226
+
227
+
228
+ def _resolve_rule(ref: str, sources: Sequence[_RuleSource]) -> tuple[Optional[ResolvedRule], Optional[RuleConflict]]:
229
+ priority = sources[0].candidate.priority
230
+ top = tuple(source for source in sources if source.candidate.priority == priority)
231
+ required = tuple(source for source in top if source.enforcement == "required")
232
+ hashes = {source.rule.text_sha256 for source in required}
233
+ if len(required) > 1 and len(hashes) > 1:
234
+ conflict = RuleConflict(
235
+ ref,
236
+ priority,
237
+ tuple(source.candidate.spec_id for source in required),
238
+ tuple(source.rule.text_sha256 for source in required),
239
+ )
240
+ return None, conflict
241
+ winner = required[0] if required else top[0]
242
+ overridden = tuple(source.candidate.spec_id for source in sources if source is not winner)
243
+ resolved = ResolvedRule(
244
+ ref,
245
+ winner.candidate.spec_id,
246
+ winner.rule.text,
247
+ winner.enforcement,
248
+ winner.candidate.scope,
249
+ priority,
250
+ overridden,
251
+ )
252
+ return resolved, None
253
+
254
+
255
+ def resolve_precedence(candidates: Sequence[SpecCandidate]) -> PrecedenceResolution:
256
+ effective: list[ResolvedRule] = []
257
+ conflicts: list[RuleConflict] = []
258
+ for ref, sources in sorted(_rule_sources(candidates).items()):
259
+ resolved, conflict = _resolve_rule(ref, sources)
260
+ if resolved is not None:
261
+ effective.append(resolved)
262
+ if conflict is not None:
263
+ conflicts.append(conflict)
264
+ return PrecedenceResolution(tuple(candidates), tuple(effective), tuple(conflicts))
265
+
266
+
267
+ def build_candidate_page(candidates: Sequence[SpecCandidate], limit: int) -> CandidatePage:
268
+ if limit <= 0:
269
+ raise SpecResolutionError("<memory>", "limit", "必须大于 0")
270
+ items = tuple(candidates[:limit])
271
+ return CandidatePage(items=items, total=len(candidates), has_more=len(candidates) > limit)
@@ -0,0 +1,149 @@
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, Sequence
12
+
13
+ from cf_core import assemble_context, build_effective_mapping, build_spec_catalog, load_config, parse_spec_frontmatter
14
+ from cf_spec_context import ContextError, load_active_task, load_context
15
+ from cf_spec_resolver import resolve_candidates
16
+ from cf_spec_session import context_sha256, project_task_session
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RouteResult:
21
+ mode: str
22
+ text: str
23
+ specs: tuple[str, ...]
24
+ context_sha256: Optional[str] = None
25
+
26
+
27
+ class RouterError(ValueError):
28
+ """Fail-closed routing error with a stable code."""
29
+
30
+ def __init__(self, code: str, message: str) -> None:
31
+ self.code = code
32
+ super().__init__(message)
33
+
34
+
35
+ def _task_file(task_dir: Path, task_id: str) -> Path:
36
+ matches = []
37
+ for path in sorted(task_dir.glob("*.md")):
38
+ if path.name.endswith((".prd.md", ".design.md")):
39
+ continue
40
+ if f"## {task_id}:" in path.read_text(encoding="utf-8"):
41
+ matches.append(path)
42
+ if len(matches) != 1:
43
+ raise RouterError("task_file_ambiguous", f"{task_id} matched {len(matches)} task files")
44
+ return matches[0]
45
+
46
+
47
+ def _active_route(root: str) -> RouteResult:
48
+ try:
49
+ active = load_active_task(root)
50
+ task_dir = (Path(root) / active.task_dir).resolve()
51
+ context = load_context(str(task_dir / "spec-context.yml"))
52
+ except (ContextError, OSError, ValueError) as exc:
53
+ raise RouterError("invalid_active_task", str(exc)) from exc
54
+ current_hash = context_sha256(context)
55
+ if current_hash != active.context_sha256:
56
+ raise RouterError(
57
+ "active_context_drift",
58
+ "active marker Context hash does not match; run `cf-spec doctor` (resync) to re-sync the marker before continuing",
59
+ )
60
+ projection = project_task_session(context, str(_task_file(task_dir, active.task_id)), active.task_id)
61
+ if projection.truncated:
62
+ raise RouterError("task_projection_truncated", "split the TASK before coding")
63
+ refs = tuple(binding.spec_id for binding in context.bindings)
64
+ return RouteResult("task", projection.text, refs, current_hash)
65
+
66
+
67
+ def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
68
+ try:
69
+ candidates = resolve_candidates(root, "code", tuple(paths))
70
+ except ValueError as exc:
71
+ raise RouterError("invalid_spec_metadata", str(exc)) from exc
72
+ specs = []
73
+ for candidate in candidates:
74
+ target = Path(root) / ".code-flow/specs" / candidate.path
75
+ try:
76
+ raw = target.read_text(encoding="utf-8")
77
+ except OSError as exc:
78
+ raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
79
+ unused_frontmatter, content = parse_spec_frontmatter(raw)
80
+ del unused_frontmatter
81
+ specs.append({"path": candidate.path, "content": content.strip(), "tier": 1})
82
+ text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
83
+ return RouteResult("path", text, tuple(item.path for item in candidates))
84
+
85
+
86
+ def _catalog_path(root: str) -> Path:
87
+ return Path(root) / ".code-flow/.catalog-state.json"
88
+
89
+
90
+ def _load_catalog_state(root: str) -> dict[str, object]:
91
+ path = _catalog_path(root)
92
+ if not path.exists():
93
+ return {"version": 1, "session_id": "", "prompt_count": 0, "last_emitted": 0}
94
+ try:
95
+ value = json.loads(path.read_text(encoding="utf-8"))
96
+ except (OSError, json.JSONDecodeError) as exc:
97
+ raise RouterError("invalid_catalog_state", str(exc)) from exc
98
+ if not isinstance(value, dict) or value.get("version") != 1:
99
+ raise RouterError("invalid_catalog_state", "Catalog state schema must be version 1")
100
+ return value
101
+
102
+
103
+ def _save_catalog_state(root: str, state: Mapping[str, object]) -> None:
104
+ path = _catalog_path(root)
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ temporary = ""
107
+ try:
108
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
109
+ temporary = handle.name
110
+ json.dump(state, handle, ensure_ascii=False)
111
+ handle.flush()
112
+ os.fsync(handle.fileno())
113
+ os.replace(temporary, path)
114
+ except OSError as exc:
115
+ if temporary and Path(temporary).exists():
116
+ Path(temporary).unlink()
117
+ raise RouterError("catalog_state_write_failed", str(exc)) from exc
118
+
119
+
120
+ def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) -> RouteResult:
121
+ state = _load_catalog_state(root)
122
+ same_session = state.get("session_id") == session_id
123
+ count = int(state.get("prompt_count", 0)) + 1 if same_session else 1
124
+ last = int(state.get("last_emitted", 0)) if same_session else 0
125
+ workflow = config.get("spec_workflow") if isinstance(config.get("spec_workflow"), dict) else {}
126
+ catalog_config = workflow.get("catalog") if isinstance(workflow.get("catalog"), dict) else {}
127
+ window = int(catalog_config.get("dedup_window", 5))
128
+ due = window <= 0 or last == 0 or count - last >= window
129
+ next_state = {"version": 1, "session_id": session_id, "prompt_count": count, "last_emitted": count if due else last}
130
+ _save_catalog_state(root, next_state)
131
+ if not due:
132
+ return RouteResult("catalog", "", ())
133
+ budget = config.get("budget") if isinstance(config.get("budget"), dict) else {}
134
+ maximum = int(budget.get("catalog_max", 200))
135
+ mapping = build_effective_mapping(root, config.get("path_mapping") or {})
136
+ return RouteResult("catalog", build_spec_catalog(root, mapping, maximum), ())
137
+
138
+
139
+ def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResult:
140
+ """Choose exactly one route; an invalid active marker never falls back."""
141
+ marker = Path(root) / ".code-flow/.active-task.json"
142
+ if marker.exists():
143
+ return _active_route(root)
144
+ config = load_config(root)
145
+ if not config:
146
+ return RouteResult("none", "", ())
147
+ if paths:
148
+ return _read_candidates(root, paths)
149
+ return _catalog_route(root, session_id, config)
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """Project one TASK's bound rules into a bounded session document."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from dataclasses import dataclass
8
+ import json
9
+ from pathlib import Path
10
+ import re
11
+ import sys
12
+ from typing import IO, Mapping, Optional, Sequence
13
+
14
+ from cf_spec_context import RuleBinding, SpecContext, context_sha256, load_context
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SessionProjection:
19
+ text: str
20
+ included_rules: int
21
+ total_rules: int
22
+ truncated: bool
23
+
24
+
25
+ # context_sha256 is defined next to the identity projection in cf_spec_context
26
+ # and re-exported here for callers that import the session module.
27
+
28
+
29
+ def _task_section(text: str, task_id: str) -> str:
30
+ match = re.search(rf"(?m)^## {re.escape(task_id)}:[^\n]*$", text)
31
+ if match is None:
32
+ raise ValueError(f"task_not_found: {task_id}")
33
+ following = re.search(r"(?m)^## TASK-\d+:", text[match.end():])
34
+ end = match.end() + following.start() if following else len(text)
35
+ return text[match.start():end]
36
+
37
+
38
+ def _refs(section: str) -> tuple[str, ...]:
39
+ match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:\s*(.+)$", section)
40
+ if match is None:
41
+ raise ValueError("spec_refs_missing")
42
+ return tuple(item.strip() for item in match.group(1).split(",") if item.strip())
43
+
44
+
45
+ def _rule_index(context: SpecContext) -> Mapping[str, RuleBinding]:
46
+ return {
47
+ f"{binding.spec_id}#{rule.ref}": rule
48
+ for binding in context.bindings
49
+ for rule in binding.rules
50
+ }
51
+
52
+
53
+ def _acceptance_contract(section: str) -> str:
54
+ match = re.search(r"(?ms)^### Acceptance Contract\s*$\n(.*?)(?=^### |^## |\Z)", section)
55
+ if match is None:
56
+ raise ValueError("acceptance_contract_missing")
57
+ return match.group(1).strip()
58
+
59
+
60
+ def _rule_line(ref: str, rule: RuleBinding, compact: bool = False) -> str:
61
+ hashes = f"rule_sha256={rule.text_sha256} verifier={rule.verifier_ref}"
62
+ if compact:
63
+ return f"- `{ref}` {hashes}\n"
64
+ artifacts = sorted({item.artifact for status in rule.stage_status.values() for item in status.refs})
65
+ return f"- `{ref}`: {rule.summary}\n - {hashes}; artifacts={','.join(artifacts) or 'none'}\n"
66
+
67
+
68
+ def project_task_session(
69
+ context: SpecContext, task_file: str, task_id: str, max_chars: int = 12000
70
+ ) -> SessionProjection:
71
+ section = _task_section(Path(task_file).read_text(encoding="utf-8"), task_id)
72
+ refs = _refs(section)
73
+ index = _rule_index(context)
74
+ missing = tuple(ref for ref in refs if ref not in index)
75
+ if missing:
76
+ raise ValueError(f"unknown_spec_refs: {', '.join(missing)}")
77
+ acceptance = _acceptance_contract(section)
78
+ header = f"# {task_id} Spec Context\n\n- Context-SHA256: `{context_sha256(context)}`\n\n## Required Rules\n"
79
+ footer = f"\n## Acceptance Contract\n{acceptance}\n"
80
+ lines: list[str] = []
81
+ for ref in refs:
82
+ candidate = _rule_line(ref, index[ref])
83
+ if len(header + "".join(lines) + candidate + footer) > max_chars:
84
+ break
85
+ lines.append(candidate)
86
+ truncated = len(lines) < len(refs)
87
+ if truncated:
88
+ footer = "\n> 投影超过预算;完整 Context 保留在需求目录,请拆分 TASK。\n" + footer
89
+ text = (header + "".join(lines) + footer)[:max_chars]
90
+ return SessionProjection(text, len(lines), len(refs), truncated)
91
+
92
+
93
+ def _parser() -> argparse.ArgumentParser:
94
+ parser = argparse.ArgumentParser(prog="cf_spec_session.py")
95
+ parser.add_argument("--task-dir", required=True)
96
+ parser.add_argument("--task-file", required=True)
97
+ parser.add_argument("--task", required=True)
98
+ parser.add_argument("--output", required=True)
99
+ parser.add_argument("--budget", type=int, default=12000)
100
+ parser.add_argument("--json", action="store_true")
101
+ return parser
102
+
103
+
104
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
105
+ try:
106
+ args = _parser().parse_args(argv)
107
+ context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
108
+ result = project_task_session(context, args.task_file, args.task, args.budget)
109
+ Path(args.output).write_text(result.text, encoding="utf-8")
110
+ stdout.write(json.dumps(result.__dict__, ensure_ascii=False))
111
+ return 0
112
+ except Exception as exc:
113
+ stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
114
+ return 3
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())