@michaelxwb/code-flow 0.6.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -40
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -4,24 +4,29 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  from dataclasses import dataclass
7
+ import hashlib
7
8
  import json
8
9
  import os
9
10
  from pathlib import Path
10
11
  import tempfile
12
+ import codeflow.cli.cf_spec_session as cf_spec_session
11
13
  from typing import Mapping, Optional, Sequence
12
14
 
13
- from cf_core import (
15
+ from codeflow.core.cf_core import (
14
16
  assemble_context,
17
+ ConfigError,
15
18
  build_effective_mapping,
16
19
  build_spec_catalog,
17
- compress_content,
20
+ estimate_tokens,
18
21
  load_config,
19
- parse_spec_frontmatter,
20
22
  resolve_compress,
21
23
  )
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
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
25
30
 
26
31
 
27
32
  _PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
@@ -33,6 +38,7 @@ class RouteResult:
33
38
  text: str
34
39
  specs: tuple[str, ...]
35
40
  context_sha256: Optional[str] = None
41
+ expanded_specs: tuple[str, ...] = ()
36
42
 
37
43
 
38
44
  class RouterError(ValueError):
@@ -59,12 +65,21 @@ def _projection_cache_path(root: str) -> Path:
59
65
  return Path(root) / _PROJECTION_CACHE_PATH
60
66
 
61
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
+
62
73
  def _load_projection_cache(root: str) -> Mapping[str, object]:
63
74
  try:
64
75
  value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
65
76
  except (OSError, UnicodeError, json.JSONDecodeError):
66
77
  return {}
67
- return value if isinstance(value, dict) else {}
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 {}
68
83
 
69
84
 
70
85
  def _cached_projection(
@@ -90,6 +105,12 @@ def _cached_projection(
90
105
  return None
91
106
  if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
92
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
93
114
  return str(cache["text"]), bool(cache["truncated"])
94
115
 
95
116
 
@@ -107,13 +128,17 @@ def _save_projection_cache(
107
128
  "version": 1,
108
129
  "task_id": task_id,
109
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(),
110
133
  "task_file": str(task_file),
111
134
  "task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
112
135
  "task_mtime_ns": task_file.stat().st_mtime_ns,
136
+ "task_sha256": hashlib.sha256(task_file.read_bytes()).hexdigest(),
113
137
  "text": text,
114
138
  "truncated": truncated,
115
139
  }
116
140
  path = _projection_cache_path(root)
141
+ payload["checksum"] = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
117
142
  path.parent.mkdir(parents=True, exist_ok=True)
118
143
  temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
119
144
  temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
@@ -126,23 +151,48 @@ def _save_projection_cache(
126
151
  pass
127
152
 
128
153
 
129
- def _active_route(root: str) -> RouteResult:
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:
130
177
  try:
131
178
  active = load_active_task(root)
132
179
  task_dir = (Path(root) / active.task_dir).resolve()
133
- context = load_context(str(task_dir / "spec-context.yml"))
180
+ context, current_hash = load_snapshot(root, str(task_dir / "spec-context.yml"), load_context)
134
181
  except (ContextError, OSError, ValueError) as exc:
135
182
  raise RouterError("invalid_active_task", str(exc)) from exc
136
- current_hash = context_sha256(context)
137
183
  if current_hash != active.context_sha256:
138
184
  raise RouterError(
139
185
  "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`",
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`",
141
187
  )
188
+ _verify_bound_specs(root, context)
142
189
  cached = _cached_projection(root, task_dir, active.task_id, current_hash)
143
190
  if cached is None:
144
191
  task_file = _task_file(task_dir, active.task_id)
145
- projection = project_task_session(context, str(task_file), 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
146
196
  _save_projection_cache(
147
197
  root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
148
198
  )
@@ -151,30 +201,26 @@ def _active_route(root: str) -> RouteResult:
151
201
  text, truncated = cached
152
202
  if truncated:
153
203
  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)
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)
156
208
 
157
209
 
158
210
  def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
211
+ from codeflow.spec.cf_spec_budget import SpecBudgetError, path_specs
159
212
  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})
213
+ specs = path_specs(root, paths, compress)
214
+ except SpecBudgetError as exc:
215
+ raise RouterError(exc.code, str(exc)) from exc
176
216
  text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
177
- return RouteResult("path", text, tuple(item.path for item in candidates))
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))
178
224
 
179
225
 
180
226
  def _catalog_path(root: str) -> Path:
@@ -191,6 +237,8 @@ def _load_catalog_state(root: str) -> dict[str, object]:
191
237
  raise RouterError("invalid_catalog_state", str(exc)) from exc
192
238
  if not isinstance(value, dict) or value.get("version") != 1:
193
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")
194
242
  return value
195
243
 
196
244
 
@@ -208,7 +256,7 @@ def _save_catalog_state(root: str, state: Mapping[str, object]) -> None:
208
256
  except OSError as exc:
209
257
  if temporary and Path(temporary).exists():
210
258
  Path(temporary).unlink()
211
- raise RouterError("catalog_state_write_failed", str(exc)) from exc
259
+ warn(f"Spec Catalog dedup state could not be saved: {exc}")
212
260
 
213
261
 
214
262
  def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) -> RouteResult:
@@ -221,20 +269,32 @@ def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) ->
221
269
  window = int(catalog_config.get("dedup_window", 5))
222
270
  due = window <= 0 or last == 0 or count - last >= window
223
271
  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
272
  if not due:
273
+ _save_catalog_state(root, next_state)
226
274
  return RouteResult("catalog", "", ())
227
275
  budget = config.get("budget") if isinstance(config.get("budget"), dict) else {}
228
276
  maximum = int(budget.get("catalog_max", 200))
229
277
  mapping = build_effective_mapping(root, config.get("path_mapping") or {})
230
- return RouteResult("catalog", build_spec_catalog(root, mapping, maximum), ())
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
231
291
 
232
292
 
233
- def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResult:
293
+ def _route_prompt(root: str, paths: Sequence[str], session_id: str, check_scope: bool) -> RouteResult:
234
294
  """Choose exactly one route; an invalid active marker never falls back."""
235
295
  marker = Path(root) / ".code-flow/.active-task.json"
236
296
  if marker.exists():
237
- return _active_route(root)
297
+ return _active_route(root, paths, check_scope)
238
298
  config = load_config(root)
239
299
  if not config:
240
300
  return RouteResult("none", "", ())
@@ -4,21 +4,23 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import ast
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, replace
8
8
  from datetime import datetime, timezone
9
9
  import fnmatch
10
10
  import hashlib
11
11
  import json
12
+ import os
12
13
  from pathlib import Path
13
- import subprocess
14
14
  from threading import Lock
15
15
  import time
16
16
  from concurrent.futures import ThreadPoolExecutor
17
+ from contextvars import copy_context
17
18
  from typing import Mapping, Optional, Sequence
18
19
 
19
- from cf_checks import run_regex_verifier
20
- from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
21
- from cf_exec_base import execution_session, run_command
20
+ from codeflow.acceptance.cf_checks import run_regex_verifier
21
+ from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
22
+ from codeflow.core.cf_exec_base import execution_session, record_report, redact_sensitive_text, report_requested, run_command
23
+ from codeflow.core import cf_git
22
24
 
23
25
 
24
26
  _RESULT_CACHE: dict[str, dict[str, object]] = {}
@@ -59,19 +61,82 @@ def _cache_file(root: str) -> Path:
59
61
  return Path(root) / ".code-flow" / ".verifier-cache.json"
60
62
 
61
63
 
62
- def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
64
+ def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]], stage: str = "code") -> str:
63
65
  payload = {
64
66
  "spec": metadata.hashes.file_sha256,
65
67
  "rule": rule.text_sha256,
66
68
  "verifier": {"type": verifier.type, "config": verifier.config},
67
69
  "files": scope.files,
68
70
  "diff": scope.diff_sha256,
71
+ "stage": stage,
69
72
  "confirmation": confirmation or {},
70
73
  "artifact_content": _artifact_fingerprint(verifier, scope),
71
74
  }
72
75
  return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
73
76
 
74
77
 
78
+ def _normalize_scope_path(value: str) -> str:
79
+ return value.replace("\\", "/").strip()
80
+
81
+
82
+ def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
83
+ for pattern in patterns:
84
+ if fnmatch.fnmatchcase(path, pattern):
85
+ return True
86
+ bare = pattern.rstrip("/")
87
+ if not any(char in pattern for char in "*?[") and path.startswith(bare + "/"):
88
+ return True
89
+ return False
90
+
91
+
92
+ def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
93
+ """作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
94
+
95
+ 决议仓库级集合(而非仅当前任务 diff)才能让未触及作用域的其他任务命中缓存。
96
+ """
97
+ if not verifier.files:
98
+ return ()
99
+ candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
100
+ candidates.update(cf_git.tracked_files(scope.root))
101
+ return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
102
+
103
+
104
+ def _scope_fingerprint(root: str, files: Sequence[str]) -> str:
105
+ digest = hashlib.sha256()
106
+ for relative in files:
107
+ path = Path(root) / relative
108
+ digest.update(relative.encode("utf-8"))
109
+ try:
110
+ digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
111
+ except OSError:
112
+ digest.update(b"<unreadable>")
113
+ return digest.hexdigest()
114
+
115
+
116
+ def _scoped_cache_key(
117
+ metadata: SpecMetadata,
118
+ rule: SpecRule,
119
+ verifier: SpecVerifier,
120
+ scope: VerificationScope,
121
+ confirmation: Optional[Mapping[str, object]],
122
+ stage: str,
123
+ ) -> Optional[str]:
124
+ """command/test 仅在声明 files 作用域时缓存;未声明保持全量执行。"""
125
+ if verifier.type not in ("command", "test") or not verifier.files:
126
+ return None
127
+ files = scoped_files(verifier, scope)
128
+ payload = {
129
+ "spec": metadata.hashes.file_sha256,
130
+ "rule": rule.text_sha256,
131
+ "verifier": {"type": verifier.type, "config": verifier.config, "files": list(verifier.files)},
132
+ "stage": stage,
133
+ "scope_files": files,
134
+ "scope_sha256": _scope_fingerprint(scope.root, files),
135
+ "confirmation": confirmation or {},
136
+ }
137
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
138
+
139
+
75
140
  def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
76
141
  if verifier.type != "document":
77
142
  return None
@@ -143,13 +208,38 @@ def _cached_or_run(
143
208
  verifier: SpecVerifier,
144
209
  scope: VerificationScope,
145
210
  confirmation: Optional[Mapping[str, object]],
211
+ stage: str = "code",
146
212
  ) -> VerificationEvidence:
213
+ if verifier.type == "regex" and not _regex_scope_files(verifier.config, metadata, scope):
214
+ # The fallback checks tracked files outside the diff. Do not reuse a
215
+ # diff-keyed result when those files may have changed independently.
216
+ return _evidence(metadata, rule, verifier, scope, confirmation)
147
217
  cacheable = verifier.type in _PURE_VERIFIER_TYPES
148
- key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
218
+ scoped_key: Optional[str] = None
219
+ if cacheable:
220
+ key = _cache_key(metadata, rule, verifier, scope, confirmation, stage)
221
+ else:
222
+ scoped_key = _scoped_cache_key(metadata, rule, verifier, scope, confirmation, stage)
223
+ if scoped_key is None:
224
+ key = ""
225
+ else:
226
+ cacheable, key = True, scoped_key
149
227
  if cacheable:
150
228
  cached = _cached_evidence(key)
151
229
  if cached is not None:
152
- return cached
230
+ record_report({"kind": "spec_check", "check_id": f"{metadata.id}#{rule.ref}",
231
+ "reused": 1, "execution_ms": 0.0})
232
+ if scoped_key is None:
233
+ return cached
234
+ details = dict(cached.details or {})
235
+ details["cache_reused"] = True
236
+ details["scope_files"] = list(scoped_files(verifier, scope))
237
+ return replace(
238
+ cached,
239
+ executed_at=datetime.now(timezone.utc).isoformat(),
240
+ diff_sha256=scope.diff_sha256,
241
+ details=details,
242
+ )
153
243
  current = _evidence(metadata, rule, verifier, scope, confirmation)
154
244
  if cacheable and current.status == "verified":
155
245
  _save_result_cache(key, current)
@@ -162,6 +252,8 @@ class _Outcome:
162
252
  error_code: Optional[str]
163
253
  artifact_sha256: Optional[str]
164
254
  details: Mapping[str, object]
255
+ execution_ms: Optional[float] = None
256
+ execution_reused: bool = False
165
257
 
166
258
 
167
259
  def _hash_bytes(data: bytes) -> str:
@@ -204,10 +296,31 @@ def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcom
204
296
  except (OSError, UnicodeError) as exc:
205
297
  return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
206
298
  expected = _config_string(config, "artifact_sha256")
207
- present = section in content and item in content
299
+ from codeflow.spec.cf_rule_readiness import referenced_item, unresolved_text
300
+ selected = referenced_item(content, section, item)
301
+ present = bool(selected)
208
302
  hash_matches = expected is None or expected == artifact_hash
209
- details = {"artifact": relative, "section_present": section in content, "item_present": item in content, "hash_matches": hash_matches}
210
- return _Outcome(present and hash_matches, None if present and hash_matches else "document_mismatch", artifact_hash, details)
303
+ unresolved = unresolved_text(selected)
304
+ details = {"artifact": relative, "section_present": section in content, "item_present": bool(selected),
305
+ "hash_matches": hash_matches, "unresolved": unresolved}
306
+ error = "rule_applied_unresolved" if unresolved else "document_mismatch"
307
+ passed = present and hash_matches and not unresolved
308
+ return _Outcome(passed, None if passed else error, artifact_hash, details)
309
+
310
+
311
+ def _regex_file_pattern(config: Mapping[str, object], metadata: SpecMetadata) -> str:
312
+ check_id = _config_string(config, "check_id")
313
+ check = next((item for item in metadata.checks if item.get("id") == check_id), None)
314
+ if check is not None and isinstance(check.get("files"), str):
315
+ return str(check["files"])
316
+ return _config_string(config, "files") or "*"
317
+
318
+
319
+ def _regex_scope_files(config: Mapping[str, object], metadata: SpecMetadata,
320
+ scope: VerificationScope) -> tuple[str, ...]:
321
+ files = _regex_file_pattern(config, metadata)
322
+ return tuple(relative for relative in scope.files
323
+ if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file())
211
324
 
212
325
 
213
326
  def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
@@ -222,11 +335,16 @@ def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: Verifica
222
335
  files = check.get("files") if isinstance(check.get("files"), str) else "*"
223
336
  if pattern is None:
224
337
  return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
225
- selected = tuple(
226
- relative
227
- for relative in scope.files
228
- if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file()
229
- )
338
+ selected = _regex_scope_files(config, metadata, scope)
339
+ if not selected:
340
+ tracked = tuple(relative for relative in cf_git.tracked_files(scope.root)
341
+ if fnmatch.fnmatch(relative, files))
342
+ if any(not (Path(scope.root) / relative).is_file() for relative in tracked):
343
+ return _Outcome(False, "no_matching_files", None,
344
+ {"checked_files": 0, "files": files, "reason": "tracked_files_missing"})
345
+ selected = tracked
346
+ if not selected:
347
+ return _Outcome(False, "no_matching_files", None, {"checked_files": 0, "files": files})
230
348
  contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
231
349
  if error is not None:
232
350
  return error
@@ -284,6 +402,13 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
284
402
  allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
285
403
  deadline = None if timeout_cap is None else time.monotonic() + timeout_cap
286
404
  completed = run_command(argv, str(Path(scope.root) / cwd), timeout, deadline)
405
+ outcome = _command_result(completed, argv, timeout, allowed)
406
+ return replace(outcome, execution_ms=float(completed.get("execution_ms", 0)),
407
+ execution_reused=bool(completed.get("execution_reused")))
408
+
409
+
410
+ def _command_result(completed: Mapping[str, object], argv: Sequence[str], timeout: float,
411
+ allowed: Sequence[int]) -> _Outcome:
287
412
  if completed["status"] in ("timeout", "deadline_exceeded"):
288
413
  return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
289
414
  if completed["status"] == "spawn_error":
@@ -293,7 +418,7 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
293
418
  "exit_code": completed["returncode"],
294
419
  "stdout_sha256": _hash_bytes(str(completed["stdout"]).encode()),
295
420
  "stderr_sha256": _hash_bytes(str(completed["stderr"]).encode()),
296
- "output": (str(completed["stdout"]) + str(completed["stderr"]))[-4000:],
421
+ "output": redact_sensitive_text((str(completed["stdout"]) + str(completed["stderr"]))[-4000:]),
297
422
  }
298
423
  passed = completed["returncode"] in allowed
299
424
  return _Outcome(passed, None if passed else "command_failed", None, details)
@@ -339,7 +464,20 @@ def _evidence(
339
464
  confirmation: Optional[Mapping[str, object]],
340
465
  timeout_cap: Optional[float] = None,
341
466
  ) -> VerificationEvidence:
467
+ started = time.monotonic()
342
468
  outcome = _run(verifier, metadata, scope, confirmation, timeout_cap)
469
+ execution_ms = (time.monotonic() - started) * 1000
470
+ if outcome.execution_ms is not None:
471
+ execution_ms = outcome.execution_ms
472
+ if report_requested():
473
+ identity_data = [_cache_key(metadata, rule, verifier, scope, confirmation, verifier.stage),
474
+ str(Path(scope.root).resolve()), dict(os.environ), timeout_cap]
475
+ identity = hashlib.sha256(json.dumps(identity_data, sort_keys=True, default=str).encode()).hexdigest()
476
+ report = {"kind": "spec_check", "check_id": f"{metadata.id}#{rule.ref}",
477
+ "reused": int(outcome.execution_reused), "execution_ms": execution_ms}
478
+ if not outcome.execution_reused:
479
+ report["identity"] = identity
480
+ record_report(report)
343
481
  status = "verified" if outcome.passed else "unverified"
344
482
  diff_hash = None if verifier.type == "document" else scope.diff_sha256
345
483
  return VerificationEvidence(
@@ -370,8 +508,47 @@ def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verificatio
370
508
  )
371
509
 
372
510
 
511
+ def _deferral_kind(reason: str) -> str:
512
+ """Map a task-gate deferral reason to its evidence category.
513
+
514
+ Budget and env deferrals are recorded but never treated as a pass: the
515
+ required check did not actually run. Only review-stage work may move to the
516
+ requirement-level verify-e2e gate.
517
+ """
518
+ if reason in ("out_of_task_scope", "review_stage"):
519
+ return "review_stage"
520
+ if reason in ("budget", "over_finish_budget", "verifier_budget_exhausted"):
521
+ return "budget"
522
+ if reason in ("env", "env_blocked", "environment_unavailable"):
523
+ return "env"
524
+ return "unknown"
525
+
526
+
527
+ def _deferred_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope,
528
+ reason: str) -> VerificationEvidence:
529
+ """任务级 Done Gate 延后执行(范围外/预算/环境);需求级 verify-e2e 只补 review-stage。"""
530
+ kind = _deferral_kind(reason)
531
+ error_code = {
532
+ "review_stage": "deferred_to_review",
533
+ "budget": "verifier_budget_exhausted",
534
+ "env": "env_blocked",
535
+ }.get(kind, "deferred_to_review")
536
+ details = {"reason": reason, "deferral_kind": kind, "deferred_to_review": kind == "review_stage"}
537
+ return VerificationEvidence(
538
+ f"{metadata.id}#{rule.ref}",
539
+ datetime.now(timezone.utc).isoformat(),
540
+ "unverified",
541
+ rule.text_sha256,
542
+ None,
543
+ scope.diff_sha256,
544
+ _result_hash("unverified", error_code, details),
545
+ error_code,
546
+ details,
547
+ )
548
+
549
+
373
550
  def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
374
- details = {"budget": budget}
551
+ details = {"budget": budget, "deferral_kind": "budget"}
375
552
  return VerificationEvidence(
376
553
  f"{metadata.id}#{rule.ref}",
377
554
  datetime.now(timezone.utc).isoformat(),
@@ -394,15 +571,16 @@ def _missing_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verificatio
394
571
  )
395
572
 
396
573
 
397
- def _run_all_verifiers(
398
- metadata: SpecMetadata,
399
- scope: VerificationScope,
574
+ def _run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
400
575
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
401
576
  skip_command: bool = False,
402
577
  timeout_budget: Optional[float] = None,
578
+ stage: Optional[str] = "code",
579
+ deferred: Optional[Mapping[str, str]] = None,
403
580
  ) -> VerificationResult:
404
581
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
405
582
  confirmation_by_rule = confirmations or {}
583
+ deferred_by_rule = deferred or {}
406
584
  _load_result_cache(scope.root)
407
585
  evidence_by_ref: dict[str, VerificationEvidence] = {}
408
586
  parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
@@ -410,12 +588,17 @@ def _run_all_verifiers(
410
588
  for rule in metadata.rules:
411
589
  if rule.enforcement != "required":
412
590
  continue
591
+ verifier = verifier_by_rule.get(rule.ref)
592
+ if verifier is not None and stage is not None and verifier.stage != stage:
593
+ continue
594
+ if rule.ref in deferred_by_rule:
595
+ evidence_by_ref[rule.ref] = _deferred_evidence(metadata, rule, scope, deferred_by_rule[rule.ref])
596
+ continue
413
597
  if timeout_budget is not None:
414
598
  remaining = timeout_budget - (time.monotonic() - started)
415
599
  if remaining <= 0:
416
600
  evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
417
601
  continue
418
- verifier = verifier_by_rule.get(rule.ref)
419
602
  if verifier is None:
420
603
  evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
421
604
  continue
@@ -427,11 +610,11 @@ def _run_all_verifiers(
427
610
  parallel.append((rule, verifier, confirmation))
428
611
  continue
429
612
  cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
430
- evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
613
+ evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation, stage) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
431
614
  if parallel:
432
615
  with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
433
616
  futures = {
434
- rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
617
+ rule.ref: pool.submit(copy_context().run, _cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
435
618
  for rule, verifier, confirmation in parallel
436
619
  }
437
620
  evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
@@ -442,9 +625,11 @@ def _run_all_verifiers(
442
625
 
443
626
  def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
444
627
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
445
- skip_command: bool = False, timeout_budget: Optional[float] = None) -> VerificationResult:
628
+ skip_command: bool = False, timeout_budget: Optional[float] = None,
629
+ stage: Optional[str] = "code",
630
+ deferred: Optional[Mapping[str, str]] = None) -> VerificationResult:
446
631
  with execution_session():
447
- return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
632
+ return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage, deferred)
448
633
 
449
634
 
450
635
  def evidence_is_fresh(
@@ -459,3 +644,44 @@ def evidence_is_fresh(
459
644
  and evidence.artifact_sha256 == artifact_sha256
460
645
  and evidence.diff_sha256 == diff_sha256
461
646
  )
647
+
648
+ def store_verification_evidence(
649
+ metadata: SpecMetadata,
650
+ scope: VerificationScope,
651
+ result: VerificationResult,
652
+ requirement: str = "",
653
+ ) -> tuple[dict[str, object], ...]:
654
+ """Persist verifier runs in the central sidecar (audit/compaction input)."""
655
+ from codeflow.evidence.cf_evidence_store import EvidenceStore, git_commit
656
+
657
+ store = EvidenceStore(scope.root, requirement)
658
+ commit = git_commit(store.root)
659
+ outcomes: list[dict[str, object]] = []
660
+ for item in result.evidence:
661
+ details = dict(item.details or {})
662
+ identity = "|".join((
663
+ item.verifier_ref, item.status, item.result_sha256,
664
+ item.diff_sha256 or "", item.rule_text_sha256,
665
+ ))
666
+ argv = details.get("argv")
667
+ timeout = details.get("timeout")
668
+ run = {
669
+ "run_id": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32],
670
+ "check_id": item.verifier_ref,
671
+ "command": argv if isinstance(argv, list) and all(isinstance(value, str) for value in argv) else [],
672
+ "cwd": ".",
673
+ "timeout": timeout if isinstance(timeout, (int, float)) else 0,
674
+ "input_hash": item.diff_sha256 or item.result_sha256,
675
+ "exit_code": details.get("exit_code") if isinstance(details.get("exit_code"), int) else (0 if item.status == "verified" else 1),
676
+ "status": "passed" if item.status == "verified" else "failed",
677
+ "executed_at": item.executed_at,
678
+ "source": "local",
679
+ "commit": commit,
680
+ "run_url": "",
681
+ "rule_text_sha256": item.rule_text_sha256,
682
+ "result_sha256": item.result_sha256,
683
+ "error_code": item.error_code,
684
+ "details": details,
685
+ }
686
+ outcomes.append(store.write_run(item.verifier_ref, run))
687
+ return tuple(outcomes)