@michaelxwb/code-flow 0.6.0 → 0.6.2

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-spec.md +13 -0
  3. package/src/adapters/claude/commands/cf-sync.md +25 -0
  4. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  5. package/src/adapters/claude/commands/cf-task/start.md +6 -7
  6. package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
  7. package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
  8. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  9. package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
  10. package/src/adapters/costrict/commands/cf-spec.md +13 -0
  11. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  12. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  13. package/src/adapters/costrict/commands/cf-task/start.md +6 -7
  14. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
  15. package/src/adapters/opencode/commands/cf-spec.md +1 -0
  16. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  17. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  18. package/src/adapters/opencode/commands/cf-task/start.md +6 -7
  19. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
  20. package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
  21. package/src/core/code-flow/config.yml +2 -1
  22. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
  23. package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
  24. package/src/core/code-flow/scripts/cf_core.py +44 -1
  25. package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
  26. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
  27. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  28. package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
  29. package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
  30. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  31. package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
  32. package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
  33. package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
  34. package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
  35. package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
  36. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  37. package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
  38. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +106 -8
@@ -10,9 +10,11 @@ import json
10
10
  from pathlib import Path
11
11
  import re
12
12
  import sys
13
+ import time
13
14
  from typing import IO, Mapping, Optional, Sequence
14
15
 
15
16
  from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
17
+ from cf_core import phase_timing
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -55,7 +57,7 @@ def _waiver_issue(decision: Optional[Decision], now: datetime) -> Optional[str]:
55
57
  return "waiver_expired" if expires <= now else None
56
58
 
57
59
 
58
- def _evidence_issue(rule: RuleBinding, status: RuleStageStatus) -> Optional[str]:
60
+ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Optional[str]) -> Optional[str]:
59
61
  if not status.evidence:
60
62
  return "evidence_missing"
61
63
  for evidence in status.evidence:
@@ -63,17 +65,20 @@ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus) -> Optional[str]
63
65
  continue
64
66
  if evidence.get("rule_text_sha256") != rule.text_sha256:
65
67
  continue
68
+ evidence_diff = evidence.get("diff_sha256")
69
+ if evidence_diff is not None and diff_sha256 is not None and evidence_diff != diff_sha256:
70
+ continue
66
71
  result_hash = evidence.get("result_sha256")
67
72
  if isinstance(result_hash, str) and result_hash:
68
73
  return None
69
74
  return "stale_evidence"
70
75
 
71
76
 
72
- def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) -> Optional[str]:
77
+ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, diff_sha256: Optional[str]) -> Optional[str]:
73
78
  if status.status == "applied":
74
79
  return None
75
80
  if status.status == "verified":
76
- return _evidence_issue(rule, status)
81
+ return _evidence_issue(rule, status, diff_sha256)
77
82
  if status.status == "not_applicable":
78
83
  return None if _decision_valid(status.decision) else "decision_invalid"
79
84
  if status.status == "waived":
@@ -81,7 +86,9 @@ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) ->
81
86
  return status.status
82
87
 
83
88
 
84
- def _binding_issues(binding: SpecBinding, stage: str, now: datetime) -> tuple[list[GateIssue], list[GateIssue]]:
89
+ def _binding_issues(
90
+ binding: SpecBinding, stage: str, now: datetime, diff_sha256: Optional[str]
91
+ ) -> tuple[list[GateIssue], list[GateIssue]]:
85
92
  errors: list[GateIssue] = []
86
93
  warnings: list[GateIssue] = []
87
94
  for rule in binding.rules:
@@ -90,7 +97,7 @@ def _binding_issues(binding: SpecBinding, stage: str, now: datetime) -> tuple[li
90
97
  continue
91
98
  if rule.enforcement == "advisory" and status.status == "pending":
92
99
  continue
93
- code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now)
100
+ code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
94
101
  if code is None:
95
102
  continue
96
103
  issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
@@ -105,13 +112,14 @@ def validate_stage(
105
112
  artifact: str = "",
106
113
  task_id: str = "",
107
114
  now: Optional[datetime] = None,
115
+ diff_sha256: Optional[str] = None,
108
116
  ) -> GateResult:
109
117
  del artifact, task_id
110
118
  current = now or datetime.now(timezone.utc)
111
119
  errors: list[GateIssue] = []
112
120
  warnings: list[GateIssue] = []
113
121
  for binding in context.bindings:
114
- binding_errors, binding_warnings = _binding_issues(binding, stage, current)
122
+ binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
115
123
  errors.extend(binding_errors)
116
124
  warnings.extend(binding_warnings)
117
125
  refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
@@ -206,13 +214,21 @@ def _parser() -> argparse.ArgumentParser:
206
214
 
207
215
 
208
216
  def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
217
+ started = time.monotonic()
209
218
  try:
210
219
  args = _parser().parse_args(argv)
220
+ phase_started = time.monotonic()
211
221
  context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
222
+ phase_timing("gate.load_context", phase_started)
223
+ phase_started = time.monotonic()
212
224
  result = validate_stage(context, args.stage, task_id=args.task)
225
+ phase_timing("gate.validate_stage", phase_started)
213
226
  if args.stage == "plan" and args.artifact:
227
+ phase_started = time.monotonic()
214
228
  result = _merge_results(result, validate_plan_coverage(context, args.artifact))
229
+ phase_timing("gate.validate_plan", phase_started)
215
230
  stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
231
+ phase_timing("gate.total", started)
216
232
  return 0 if result.decision == "pass" else 3
217
233
  except Exception as exc:
218
234
  sys.stderr.write(f"cf_spec_gate error: {exc}\n")
@@ -10,7 +10,6 @@ from pathlib import Path
10
10
  import re
11
11
  from typing import Mapping, Optional
12
12
 
13
- import yaml
14
13
 
15
14
 
16
15
  ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
@@ -20,6 +19,8 @@ _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
20
19
  _RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
21
20
  _EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
22
21
  _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
22
+ _FRONTMATTER_OPEN_RE = re.compile(br"---[ \t]*\r?\n")
23
+ _FRONTMATTER_CLOSE_RE = re.compile(br"---[ \t]*(?:\r?\n)?")
23
24
  _SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
24
25
  _H2_RE = re.compile(r"^##\s+")
25
26
  _BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
@@ -63,6 +64,13 @@ class SpecRule:
63
64
  line: int
64
65
 
65
66
 
67
+ @dataclass(frozen=True)
68
+ class SpecHeader:
69
+ id: str
70
+ stages: tuple[str, ...]
71
+ enforcement: str
72
+
73
+
66
74
  @dataclass(frozen=True)
67
75
  class SpecMetadata:
68
76
  id: str
@@ -116,6 +124,11 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
116
124
  if not match:
117
125
  raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
118
126
  raw = match.group(1)
127
+ return _parse_frontmatter(raw, path), raw, content[match.end():]
128
+
129
+
130
+ def _parse_frontmatter(raw: str, path: str) -> Mapping[str, object]:
131
+ import yaml
119
132
  try:
120
133
  loaded = yaml.safe_load(raw) or {}
121
134
  except yaml.YAMLError as exc:
@@ -126,7 +139,7 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
126
139
  raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
127
140
  if not all(isinstance(key, str) for key in loaded):
128
141
  raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
129
- return loaded, raw, content[match.end():]
142
+ return loaded
130
143
 
131
144
 
132
145
  def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
@@ -149,6 +162,46 @@ def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[s
149
162
  return stages
150
163
 
151
164
 
165
+ def _spec_header(meta: Mapping[str, object], raw: str, path: str) -> SpecHeader:
166
+ spec_id = _required_string(meta, "id", path, raw)
167
+ if not _ID_RE.fullmatch(spec_id):
168
+ raise SpecMetadataError(path, "id", _field_line(raw, "id"), "必须是 kebab-case")
169
+ stages = _validate_stages(meta, path, raw)
170
+ enforcement = _required_string(meta, "enforcement", path, raw)
171
+ if enforcement not in ALLOWED_ENFORCEMENT:
172
+ line = _field_line(raw, "enforcement")
173
+ raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
174
+ return SpecHeader(spec_id, stages, enforcement)
175
+
176
+
177
+ def _read_frontmatter(path: str) -> str:
178
+ lines: list[bytes] = []
179
+ try:
180
+ with Path(path).open("rb") as source:
181
+ if not _FRONTMATTER_OPEN_RE.fullmatch(source.readline()):
182
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
183
+ for line in source:
184
+ if _FRONTMATTER_CLOSE_RE.fullmatch(line):
185
+ break
186
+ lines.append(line)
187
+ else:
188
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
189
+ except OSError as exc:
190
+ raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
191
+ raw = b"".join(lines)
192
+ try:
193
+ return raw.decode("utf-8")
194
+ except UnicodeDecodeError as exc:
195
+ line = raw[:exc.start].count(b"\n") + 2
196
+ raise SpecMetadataError(path, "encoding", line, f"必须是 UTF-8: {exc}") from exc
197
+
198
+
199
+ def load_spec_header(path: str) -> SpecHeader:
200
+ """Load only routing fields, stopping at the frontmatter delimiter."""
201
+ raw = _read_frontmatter(path)
202
+ return _spec_header(_parse_frontmatter(raw, path), raw, path)
203
+
204
+
152
205
  def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
153
206
  value = meta.get("checks", [])
154
207
  if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
@@ -265,15 +318,8 @@ def _validate_links(
265
318
 
266
319
  def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
267
320
  meta, raw_frontmatter, body = _split_frontmatter(content, path)
268
- spec_id = _required_string(meta, "id", path, raw_frontmatter)
269
- if not _ID_RE.fullmatch(spec_id):
270
- raise SpecMetadataError(path, "id", _field_line(raw_frontmatter, "id"), "必须是 kebab-case")
321
+ header = _spec_header(meta, raw_frontmatter, path)
271
322
  description = _required_string(meta, "description", path, raw_frontmatter)
272
- stages = _validate_stages(meta, path, raw_frontmatter)
273
- enforcement = _required_string(meta, "enforcement", path, raw_frontmatter)
274
- if enforcement not in ALLOWED_ENFORCEMENT:
275
- line = _field_line(raw_frontmatter, "enforcement")
276
- raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
277
323
  owner = meta.get("owner")
278
324
  if owner is not None and (not isinstance(owner, str) or not owner.strip()):
279
325
  raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
@@ -289,7 +335,10 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
289
335
  metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
290
336
  rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
291
337
  )
292
- return SpecMetadata(spec_id, description, stages, enforcement, owner, checks, verifiers, rules, hashes, path)
338
+ return SpecMetadata(
339
+ header.id, description, header.stages, header.enforcement,
340
+ owner, checks, verifiers, rules, hashes, path,
341
+ )
293
342
 
294
343
 
295
344
  def load_spec_metadata(path: str) -> SpecMetadata:
@@ -8,9 +8,14 @@ import fnmatch
8
8
  from pathlib import Path
9
9
  from typing import Mapping, Optional, Sequence
10
10
 
11
- import yaml
12
-
13
- from cf_spec_metadata import ALLOWED_STAGES, SpecMetadata, SpecRule, load_spec_metadata
11
+ from cf_spec_metadata import (
12
+ ALLOWED_STAGES,
13
+ SpecHeader,
14
+ SpecMetadata,
15
+ SpecRule,
16
+ load_spec_header,
17
+ load_spec_metadata,
18
+ )
14
19
 
15
20
 
16
21
  SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
@@ -44,6 +49,16 @@ class SpecCandidate:
44
49
  return sum(rule.enforcement == "required" for rule in self.metadata.rules)
45
50
 
46
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
+
47
62
  @dataclass(frozen=True)
48
63
  class ResolvedRule:
49
64
  ref: str
@@ -93,6 +108,7 @@ def _fingerprint(path: Path, field: str) -> tuple[int, int]:
93
108
 
94
109
 
95
110
  def _load_config(root: str) -> Mapping[str, object]:
111
+ import yaml
96
112
  path = Path(root) / ".code-flow" / "config.yml"
97
113
  if not path.is_file():
98
114
  raise SpecResolutionError(str(path), "config", "缺失 config.yml")
@@ -180,6 +196,36 @@ def _candidate_for(
180
196
  return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
181
197
 
182
198
 
199
+ def _candidate_header_for(
200
+ path: Path,
201
+ spec_root: Path,
202
+ mapping: Mapping[str, object],
203
+ config_path: str,
204
+ stage: str,
205
+ paths: Sequence[str],
206
+ ) -> Optional[SpecCandidateHeader]:
207
+ relative = path.relative_to(spec_root).as_posix()
208
+ header: SpecHeader = load_spec_header(str(path))
209
+ if stage not in header.stages:
210
+ return None
211
+ domain = relative.split("/", 1)[0]
212
+ config = _domain_config(mapping, domain, config_path)
213
+ patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
214
+ scope, matched = _candidate_scope(relative, patterns, paths)
215
+ return SpecCandidateHeader(
216
+ header.id, relative, scope, SCOPE_PRIORITY[scope], matched, header.enforcement
217
+ )
218
+
219
+
220
+ def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
221
+ result: list[Path] = []
222
+ for path in sorted(spec_root.rglob("*.md")):
223
+ relative = path.relative_to(spec_root).as_posix()
224
+ if path.name != "_map.md" and not relative.startswith("_session/") and relative not in excluded:
225
+ result.append(path)
226
+ return tuple(result)
227
+
228
+
183
229
  def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
184
230
  if stage not in ALLOWED_STAGES:
185
231
  raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
@@ -193,10 +239,7 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
193
239
  raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
194
240
  candidates: list[SpecCandidate] = []
195
241
  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
242
+ for path in _spec_paths(spec_root, excluded):
200
243
  candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
201
244
  if candidate is None:
202
245
  continue
@@ -208,6 +251,34 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
208
251
  return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
209
252
 
210
253
 
254
+ def resolve_candidate_headers(
255
+ root: str, stage: str, paths: Sequence[str]
256
+ ) -> tuple[SpecCandidateHeader, ...]:
257
+ """Resolve candidates from routing frontmatter without parsing bodies or hashes."""
258
+ if stage not in ALLOWED_STAGES:
259
+ raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
260
+ normalized_paths = tuple(path.replace("\\", "/") for path in paths)
261
+ config = _load_config(root)
262
+ config_path = str(Path(root) / ".code-flow" / "config.yml")
263
+ mapping = _path_mapping(config, config_path)
264
+ excluded = _non_injectable(mapping, config_path)
265
+ spec_root = Path(root) / ".code-flow" / "specs"
266
+ if not spec_root.is_dir():
267
+ raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
268
+ candidates: list[SpecCandidateHeader] = []
269
+ ids: dict[str, str] = {}
270
+ for path in _spec_paths(spec_root, excluded):
271
+ candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
272
+ if candidate is None:
273
+ continue
274
+ if candidate.spec_id in ids:
275
+ detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
276
+ raise SpecResolutionError(str(spec_root), "spec_id", detail)
277
+ ids[candidate.spec_id] = candidate.path
278
+ candidates.append(candidate)
279
+ return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
280
+
281
+
211
282
  def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
212
283
  if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
213
284
  return "advisory"
@@ -8,19 +8,31 @@ import json
8
8
  import os
9
9
  from pathlib import Path
10
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
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
+ )
14
22
  from cf_spec_context import ContextError, load_active_task, load_context
15
23
  from cf_spec_resolver import resolve_candidates
16
24
  from cf_spec_session import context_sha256, project_task_session
17
25
 
18
26
 
27
+ _PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
28
+
29
+
19
30
  @dataclass(frozen=True)
20
31
  class RouteResult:
21
32
  mode: str
22
33
  text: str
23
34
  specs: tuple[str, ...]
35
+ context_sha256: Optional[str] = None
24
36
 
25
37
 
26
38
  class RouterError(ValueError):
@@ -43,6 +55,77 @@ def _task_file(task_dir: Path, task_id: str) -> Path:
43
55
  return matches[0]
44
56
 
45
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
+
46
129
  def _active_route(root: str) -> RouteResult:
47
130
  try:
48
131
  active = load_active_task(root)
@@ -52,15 +135,27 @@ def _active_route(root: str) -> RouteResult:
52
135
  raise RouterError("invalid_active_task", str(exc)) from exc
53
136
  current_hash = context_sha256(context)
54
137
  if current_hash != active.context_sha256:
55
- raise RouterError("active_context_drift", "active marker Context hash does not match")
56
- projection = project_task_session(context, str(_task_file(task_dir, active.task_id)), active.task_id)
57
- if projection.truncated:
138
+ raise RouterError(
139
+ "active_context_drift",
140
+ "active marker Context hash does not match; run `cf-spec doctor` (resync) to re-sync the marker before continuing",
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:
58
153
  raise RouterError("task_projection_truncated", "split the TASK before coding")
59
154
  refs = tuple(binding.spec_id for binding in context.bindings)
60
- return RouteResult("task", projection.text, refs)
155
+ return RouteResult("task", text, refs, current_hash)
61
156
 
62
157
 
63
- def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
158
+ def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
64
159
  try:
65
160
  candidates = resolve_candidates(root, "code", tuple(paths))
66
161
  except ValueError as exc:
@@ -74,7 +169,8 @@ def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
74
169
  raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
75
170
  unused_frontmatter, content = parse_spec_frontmatter(raw)
76
171
  del unused_frontmatter
77
- specs.append({"path": candidate.path, "content": content.strip(), "tier": 1})
172
+ final_content = compress_content(content.strip()) if compress else content.strip()
173
+ specs.append({"path": candidate.path, "content": final_content, "tier": 1})
78
174
  text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
79
175
  return RouteResult("path", text, tuple(item.path for item in candidates))
80
176
 
@@ -141,5 +237,6 @@ def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResul
141
237
  if not config:
142
238
  return RouteResult("none", "", ())
143
239
  if paths:
144
- return _read_candidates(root, paths)
240
+ quality = config.get("quality_loop")
241
+ return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
145
242
  return _catalog_route(root, session_id, config)
@@ -5,16 +5,13 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  from dataclasses import dataclass
8
- import hashlib
9
8
  import json
10
9
  from pathlib import Path
11
10
  import re
12
11
  import sys
13
12
  from typing import IO, Mapping, Optional, Sequence
14
13
 
15
- import yaml
16
-
17
- from cf_spec_context import RuleBinding, SpecContext, context_to_data, load_context
14
+ from cf_spec_context import RuleBinding, SpecContext, context_sha256, load_context
18
15
 
19
16
 
20
17
  @dataclass(frozen=True)
@@ -25,9 +22,8 @@ class SessionProjection:
25
22
  truncated: bool
26
23
 
27
24
 
28
- def context_sha256(context: SpecContext) -> str:
29
- data = yaml.safe_dump(context_to_data(context), sort_keys=True, allow_unicode=True).encode()
30
- return hashlib.sha256(data).hexdigest()
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.
31
27
 
32
28
 
33
29
  def _task_section(text: str, task_id: str) -> str:
@@ -70,7 +66,7 @@ def _rule_line(ref: str, rule: RuleBinding, compact: bool = False) -> str:
70
66
 
71
67
 
72
68
  def project_task_session(
73
- context: SpecContext, task_file: str, task_id: str, max_chars: int = 4000
69
+ context: SpecContext, task_file: str, task_id: str, max_chars: int = 12000
74
70
  ) -> SessionProjection:
75
71
  section = _task_section(Path(task_file).read_text(encoding="utf-8"), task_id)
76
72
  refs = _refs(section)
@@ -100,7 +96,7 @@ def _parser() -> argparse.ArgumentParser:
100
96
  parser.add_argument("--task-file", required=True)
101
97
  parser.add_argument("--task", required=True)
102
98
  parser.add_argument("--output", required=True)
103
- parser.add_argument("--budget", type=int, default=4000)
99
+ parser.add_argument("--budget", type=int, default=12000)
104
100
  parser.add_argument("--json", action="store_true")
105
101
  return parser
106
102