@michaelxwb/code-flow 0.6.5 → 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 -42
  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 +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  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 -42
  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 +52 -39
  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 +69 -35
  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 -42
  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 +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  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 -42
  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 +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  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 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  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 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  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} +5 -2
  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} +163 -35
  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 -304
  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", "", ())
@@ -9,16 +9,18 @@ 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]] = {}
@@ -87,18 +89,6 @@ def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
87
89
  return False
88
90
 
89
91
 
90
- def _git_tracked_files(root: str) -> tuple[str, ...]:
91
- try:
92
- outcome = subprocess.run(
93
- ("git", "ls-files"), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
94
- )
95
- except OSError:
96
- return ()
97
- if outcome.returncode != 0:
98
- return ()
99
- return tuple(_normalize_scope_path(line) for line in outcome.stdout.splitlines() if line.strip())
100
-
101
-
102
92
  def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
103
93
  """作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
104
94
 
@@ -107,7 +97,7 @@ def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str,
107
97
  if not verifier.files:
108
98
  return ()
109
99
  candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
110
- candidates.update(_git_tracked_files(scope.root))
100
+ candidates.update(cf_git.tracked_files(scope.root))
111
101
  return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
112
102
 
113
103
 
@@ -220,6 +210,10 @@ def _cached_or_run(
220
210
  confirmation: Optional[Mapping[str, object]],
221
211
  stage: str = "code",
222
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)
223
217
  cacheable = verifier.type in _PURE_VERIFIER_TYPES
224
218
  scoped_key: Optional[str] = None
225
219
  if cacheable:
@@ -233,6 +227,8 @@ def _cached_or_run(
233
227
  if cacheable:
234
228
  cached = _cached_evidence(key)
235
229
  if cached is not None:
230
+ record_report({"kind": "spec_check", "check_id": f"{metadata.id}#{rule.ref}",
231
+ "reused": 1, "execution_ms": 0.0})
236
232
  if scoped_key is None:
237
233
  return cached
238
234
  details = dict(cached.details or {})
@@ -256,6 +252,8 @@ class _Outcome:
256
252
  error_code: Optional[str]
257
253
  artifact_sha256: Optional[str]
258
254
  details: Mapping[str, object]
255
+ execution_ms: Optional[float] = None
256
+ execution_reused: bool = False
259
257
 
260
258
 
261
259
  def _hash_bytes(data: bytes) -> str:
@@ -298,10 +296,31 @@ def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcom
298
296
  except (OSError, UnicodeError) as exc:
299
297
  return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
300
298
  expected = _config_string(config, "artifact_sha256")
301
- 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)
302
302
  hash_matches = expected is None or expected == artifact_hash
303
- details = {"artifact": relative, "section_present": section in content, "item_present": item in content, "hash_matches": hash_matches}
304
- 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())
305
324
 
306
325
 
307
326
  def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
@@ -316,11 +335,16 @@ def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: Verifica
316
335
  files = check.get("files") if isinstance(check.get("files"), str) else "*"
317
336
  if pattern is None:
318
337
  return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
319
- selected = tuple(
320
- relative
321
- for relative in scope.files
322
- if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file()
323
- )
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})
324
348
  contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
325
349
  if error is not None:
326
350
  return error
@@ -378,6 +402,13 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
378
402
  allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
379
403
  deadline = None if timeout_cap is None else time.monotonic() + timeout_cap
380
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:
381
412
  if completed["status"] in ("timeout", "deadline_exceeded"):
382
413
  return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
383
414
  if completed["status"] == "spawn_error":
@@ -387,7 +418,7 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
387
418
  "exit_code": completed["returncode"],
388
419
  "stdout_sha256": _hash_bytes(str(completed["stdout"]).encode()),
389
420
  "stderr_sha256": _hash_bytes(str(completed["stderr"]).encode()),
390
- "output": (str(completed["stdout"]) + str(completed["stderr"]))[-4000:],
421
+ "output": redact_sensitive_text((str(completed["stdout"]) + str(completed["stderr"]))[-4000:]),
391
422
  }
392
423
  passed = completed["returncode"] in allowed
393
424
  return _Outcome(passed, None if passed else "command_failed", None, details)
@@ -433,7 +464,20 @@ def _evidence(
433
464
  confirmation: Optional[Mapping[str, object]],
434
465
  timeout_cap: Optional[float] = None,
435
466
  ) -> VerificationEvidence:
467
+ started = time.monotonic()
436
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)
437
481
  status = "verified" if outcome.passed else "unverified"
438
482
  diff_hash = None if verifier.type == "document" else scope.diff_sha256
439
483
  return VerificationEvidence(
@@ -464,8 +508,47 @@ def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verificatio
464
508
  )
465
509
 
466
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
+
467
550
  def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
468
- details = {"budget": budget}
551
+ details = {"budget": budget, "deferral_kind": "budget"}
469
552
  return VerificationEvidence(
470
553
  f"{metadata.id}#{rule.ref}",
471
554
  datetime.now(timezone.utc).isoformat(),
@@ -488,16 +571,16 @@ def _missing_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verificatio
488
571
  )
489
572
 
490
573
 
491
- def _run_all_verifiers(
492
- metadata: SpecMetadata,
493
- scope: VerificationScope,
574
+ def _run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
494
575
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
495
576
  skip_command: bool = False,
496
577
  timeout_budget: Optional[float] = None,
497
- stage: str = "code",
578
+ stage: Optional[str] = "code",
579
+ deferred: Optional[Mapping[str, str]] = None,
498
580
  ) -> VerificationResult:
499
581
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
500
582
  confirmation_by_rule = confirmations or {}
583
+ deferred_by_rule = deferred or {}
501
584
  _load_result_cache(scope.root)
502
585
  evidence_by_ref: dict[str, VerificationEvidence] = {}
503
586
  parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
@@ -506,7 +589,10 @@ def _run_all_verifiers(
506
589
  if rule.enforcement != "required":
507
590
  continue
508
591
  verifier = verifier_by_rule.get(rule.ref)
509
- if verifier is not None and verifier.stage != stage:
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])
510
596
  continue
511
597
  if timeout_budget is not None:
512
598
  remaining = timeout_budget - (time.monotonic() - started)
@@ -528,7 +614,7 @@ def _run_all_verifiers(
528
614
  if parallel:
529
615
  with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
530
616
  futures = {
531
- rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
617
+ rule.ref: pool.submit(copy_context().run, _cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
532
618
  for rule, verifier, confirmation in parallel
533
619
  }
534
620
  evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
@@ -540,9 +626,10 @@ def _run_all_verifiers(
540
626
  def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
541
627
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
542
628
  skip_command: bool = False, timeout_budget: Optional[float] = None,
543
- stage: str = "code") -> VerificationResult:
629
+ stage: Optional[str] = "code",
630
+ deferred: Optional[Mapping[str, str]] = None) -> VerificationResult:
544
631
  with execution_session():
545
- return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage)
632
+ return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage, deferred)
546
633
 
547
634
 
548
635
  def evidence_is_fresh(
@@ -557,3 +644,44 @@ def evidence_is_fresh(
557
644
  and evidence.artifact_sha256 == artifact_sha256
558
645
  and evidence.diff_sha256 == diff_sha256
559
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)