@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
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env python3
2
+ """Central evidence sidecar: per-check runs, latest pointers and compaction.
3
+
4
+ Evidence lives outside the worktree so parallel worktrees and plan re-locks
5
+ cannot lose or conflict on execution history:
6
+
7
+ $(git rev-parse --git-common-dir)/code-flow/evidence/<requirement>/
8
+ <check_id>/<run_id>.json
9
+ <check_id>/latest.json
10
+ _context/<verifier_ref>/<stage>.json # compacted history sidecar
11
+
12
+ Non-git projects fall back to ``<project>/.code-flow/evidence/``. All writes
13
+ are atomic via ``cf_fs``; runs are deduplicated by ``run_id``
14
+ and ``latest.json`` only moves forward. ``compact_context`` keeps the latest
15
+ evidence entry per rule/stage and archives older entries with an upper bound.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import replace
21
+ from datetime import datetime, timezone
22
+ import json
23
+ from pathlib import Path
24
+ import re
25
+ import shutil
26
+ from typing import Iterable, Mapping, Optional, Sequence
27
+
28
+ from codeflow.core import cf_fs
29
+ from codeflow.core import cf_git
30
+ from urllib.parse import urlparse
31
+
32
+ SCHEMA_VERSION = 1
33
+ DEFAULT_HISTORY_LIMIT = 200
34
+ LATEST_NAME = "latest.json"
35
+ HISTORY_DIR = "_context"
36
+ CI_STATUSES = frozenset(("passed", "failed", "incomplete"))
37
+ _DEFAULT_REQUIREMENT = "_default"
38
+ _UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
39
+ _COMMIT = re.compile(r"^[0-9a-f]{7,40}$")
40
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
41
+
42
+
43
+ class EvidenceStoreError(ValueError):
44
+ """Stable, explicit store/compaction failure."""
45
+
46
+ def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
47
+ super().__init__(message)
48
+ self.code = code
49
+ self.field = field
50
+ self.message = message
51
+ self.path = path
52
+
53
+
54
+ def _safe_component(value: object, field: str) -> str:
55
+ text = _UNSAFE.sub("_", str(value).strip())
56
+ if not text or text in (".", ".."):
57
+ raise EvidenceStoreError("invalid_component", field, "evidence path component must be non-empty", str(value))
58
+ return text
59
+
60
+
61
+ def _run_git(root: Path, arguments: Sequence[str]) -> Optional[str]:
62
+ """Tolerant git read: non-git projects read as missing (None)."""
63
+ try:
64
+ return cf_git.run_git(str(root), arguments)
65
+ except (cf_git.GitError, UnicodeError):
66
+ return None
67
+
68
+
69
+ def discover_project_root(start: object) -> Path:
70
+ path = Path(start).expanduser()
71
+ if not path.exists():
72
+ path = path.parent
73
+ path = path.resolve()
74
+ if path.is_file():
75
+ path = path.parent
76
+ top = _run_git(path, ("rev-parse", "--show-toplevel"))
77
+ if top and top.strip():
78
+ return Path(top.strip()).resolve()
79
+ for candidate in (path, *path.parents):
80
+ if (candidate / ".code-flow").is_dir():
81
+ return candidate
82
+ return path
83
+
84
+
85
+ def _git_common_dir(project_root: Path) -> Optional[Path]:
86
+ common = _run_git(project_root, ("rev-parse", "--git-common-dir"))
87
+ if not common or not common.strip():
88
+ return None
89
+ resolved = Path(common.strip())
90
+ if not resolved.is_absolute():
91
+ resolved = project_root / resolved
92
+ return resolved.resolve()
93
+
94
+
95
+ def git_commit(start: object) -> str:
96
+ root = discover_project_root(start)
97
+ return (_run_git(root, ("rev-parse", "HEAD")) or "").strip()
98
+
99
+
100
+ def _active_requirement(root: Path) -> str:
101
+ marker = root / ".code-flow" / ".active-task.json"
102
+ try:
103
+ data = json.loads(marker.read_text(encoding="utf-8"))
104
+ except (OSError, UnicodeError, ValueError):
105
+ return ""
106
+ task_dir = data.get("task_dir") if isinstance(data, Mapping) else None
107
+ return requirement_key(task_dir) if isinstance(task_dir, str) and task_dir.strip() else ""
108
+
109
+
110
+ def requirement_key(task_dir: object) -> str:
111
+ """Collision-safe requirement key: date + name when available."""
112
+ path = Path(task_dir)
113
+ name = _safe_component(path.name or _DEFAULT_REQUIREMENT, "requirement")
114
+ parent = path.parent.name
115
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", parent or ""):
116
+ return f"{parent}--{name}"
117
+ return name
118
+
119
+
120
+ def evidence_root(root: object, requirement: str = "") -> Path:
121
+ project = discover_project_root(root)
122
+ common = _git_common_dir(project)
123
+ base = common / "code-flow" / "evidence" if common is not None else project / ".code-flow" / "evidence"
124
+ name = requirement or _active_requirement(project) or _DEFAULT_REQUIREMENT
125
+ return base / _safe_component(name, "requirement")
126
+
127
+
128
+ def _atomic_text(target: Path, text: str) -> None:
129
+ target.parent.mkdir(parents=True, exist_ok=True)
130
+ try:
131
+ cf_fs.atomic_write_text(target, text)
132
+ except OSError as exc:
133
+ raise EvidenceStoreError("atomic_write_error", "file", str(exc), str(target)) from exc
134
+
135
+
136
+ def _atomic_json(target: Path, payload: Mapping[str, object]) -> None:
137
+ _atomic_text(target, json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False) + "\n")
138
+
139
+
140
+ def _read_mapping(path: Path) -> Optional[dict[str, object]]:
141
+ try:
142
+ loaded = json.loads(path.read_text(encoding="utf-8"))
143
+ except (OSError, UnicodeError, ValueError):
144
+ return None
145
+ return dict(loaded) if isinstance(loaded, Mapping) else None
146
+
147
+
148
+ def _ci_text(run: Mapping[str, object], field: str) -> str:
149
+ value = run.get(field)
150
+ if not isinstance(value, str) or not value.strip():
151
+ raise EvidenceStoreError("invalid_ci_run", field, f"CI evidence requires a non-empty {field}")
152
+ return value.strip()
153
+
154
+
155
+ def validate_ci_run(run: Mapping[str, object]) -> None:
156
+ """Reject CI evidence without commit/run_url/exit_code/input_hash provenance."""
157
+ if run.get("source") != "ci":
158
+ raise EvidenceStoreError("invalid_ci_run", "source", "CI evidence requires source=ci")
159
+ run_url = _ci_text(run, "run_url")
160
+ parsed = urlparse(run_url)
161
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
162
+ raise EvidenceStoreError("invalid_ci_run", "run_url", "run_url must be an http(s) CI run URL")
163
+ commit = _ci_text(run, "commit")
164
+ if not _COMMIT.match(commit):
165
+ raise EvidenceStoreError("invalid_ci_run", "commit", "commit must be a 7-40 char git sha")
166
+ input_hash = _ci_text(run, "input_hash")
167
+ if not _SHA256.match(input_hash):
168
+ raise EvidenceStoreError("invalid_ci_run", "input_hash", "input_hash must be a sha256 hex digest")
169
+ exit_code = run.get("exit_code")
170
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
171
+ raise EvidenceStoreError("invalid_ci_run", "exit_code", "exit_code must be an integer")
172
+ status = _ci_text(run, "status")
173
+ if status not in CI_STATUSES:
174
+ raise EvidenceStoreError("invalid_ci_run", "status", "status must be passed/failed/incomplete")
175
+ _ci_text(run, "run_id")
176
+
177
+
178
+ def _timestamp(value: object) -> float:
179
+ if not isinstance(value, str) or not value.strip():
180
+ return 0.0
181
+ try:
182
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
183
+ except ValueError:
184
+ return 0.0
185
+ if parsed.tzinfo is None:
186
+ parsed = parsed.replace(tzinfo=timezone.utc)
187
+ return parsed.timestamp()
188
+
189
+
190
+ def _run_sort_key(run: Mapping[str, object]) -> tuple[float, str]:
191
+ return (_timestamp(run.get("executed_at")), str(run.get("run_id") or run.get("result_sha256") or ""))
192
+
193
+
194
+ def _entry_key(item: Mapping[str, object]) -> tuple[str, str, Optional[str]]:
195
+ identity = item.get("verifier_ref") or item.get("check_id") or item.get("id") or ""
196
+ result = item.get("result_sha256") or item.get("run_id") or ""
197
+ diff = None if item.get("error_code") == "skipped_in_cheap_gate" else item.get("diff_sha256")
198
+ return (str(identity), str(result), diff if isinstance(diff, str) else None)
199
+
200
+
201
+ def _dedupe(entries: Iterable[Mapping[str, object]]) -> list[dict[str, object]]:
202
+ order: list[tuple[str, str, Optional[str]]] = []
203
+ by_key: dict[tuple[str, str, Optional[str]], dict[str, object]] = {}
204
+ for entry in entries:
205
+ item = dict(entry)
206
+ key = _entry_key(item)
207
+ if key in by_key:
208
+ order.remove(key)
209
+ order.append(key)
210
+ by_key[key] = item
211
+ return [by_key[key] for key in order]
212
+
213
+
214
+ def history_file_path(store: "EvidenceStore", check_id: str, stage: str = "code") -> Path:
215
+ return store.directory / HISTORY_DIR / _safe_component(check_id, "check_id") / f"{_safe_component(stage, 'stage')}.json"
216
+
217
+
218
+ def append_history(path: Path, entries: Iterable[Mapping[str, object]], max_history: int = DEFAULT_HISTORY_LIMIT) -> int:
219
+ existing: list[dict[str, object]] = []
220
+ if path.is_file():
221
+ loaded = _read_mapping(path)
222
+ if loaded is not None and isinstance(loaded.get("entries"), list):
223
+ existing = [dict(item) for item in loaded["entries"] if isinstance(item, Mapping)]
224
+ else:
225
+ raise EvidenceStoreError("history_corrupt", "entries", "history sidecar is not a valid entry list", str(path))
226
+ merged = _dedupe([*existing, *entries])
227
+ merged.sort(key=_run_sort_key)
228
+ limit = max(0, int(max_history))
229
+ kept = merged[-limit:] if limit else []
230
+ _atomic_json(path, {"schema": SCHEMA_VERSION, "updated_at": datetime.now(timezone.utc).isoformat(), "entries": kept})
231
+ return len(kept)
232
+
233
+
234
+ class EvidenceStore:
235
+ """One requirement's evidence directory, rooted in the git common dir."""
236
+
237
+ def __init__(self, root: object, requirement: str = "") -> None:
238
+ self.root = discover_project_root(root)
239
+ self.requirement = requirement or _active_requirement(self.root) or _DEFAULT_REQUIREMENT
240
+ self.migrated_runs = self.migrate_legacy()
241
+
242
+ def _legacy_directories(self) -> list[Path]:
243
+ """Pre-date--name evidence lived under the requirement basename."""
244
+ match = re.fullmatch(r"\d{4}-\d{2}-\d{2}--(.+)", self.requirement)
245
+ name = match.group(1) if match else self.requirement
246
+ if name == self.requirement:
247
+ return []
248
+ project = self.root
249
+ common = _git_common_dir(project)
250
+ central = ((common / "code-flow" / "evidence") if common is not None
251
+ else (project / ".code-flow" / "evidence"))
252
+ return [path for path in (central / name, project / ".code-flow" / "evidence" / name)
253
+ if path.is_dir() and path.resolve() != self.directory.resolve()]
254
+
255
+ def migrate_legacy(self) -> int:
256
+ """Copy old basename evidence into the date--name store without overwriting."""
257
+ copied = 0
258
+ marker = self.directory / ".legacy-migrated"
259
+ if marker.is_file():
260
+ return 0
261
+ for legacy in self._legacy_directories():
262
+ for source in legacy.rglob("*"):
263
+ if not source.is_file():
264
+ continue
265
+ target = self.directory / source.relative_to(legacy)
266
+ target.parent.mkdir(parents=True, exist_ok=True)
267
+ if not target.exists():
268
+ shutil.copy2(source, target)
269
+ copied += 1
270
+ if copied:
271
+ _atomic_text(marker, json.dumps({"copied": copied}) + "\n")
272
+ return copied
273
+
274
+ @property
275
+ def directory(self) -> Path:
276
+ return evidence_root(self.root, self.requirement)
277
+
278
+ def check_directory(self, check_id: str) -> Path:
279
+ return self.directory / _safe_component(check_id, "check_id")
280
+
281
+ def run_path(self, check_id: str, run_id: str) -> Path:
282
+ return self.check_directory(check_id) / f"{_safe_component(run_id, 'run_id')}.json"
283
+
284
+ def latest_path(self, check_id: str) -> Path:
285
+ return self.check_directory(check_id) / LATEST_NAME
286
+
287
+ def history_path(self, check_id: str, stage: str = "code") -> Path:
288
+ return history_file_path(self, check_id, stage)
289
+
290
+ def _update_latest(self, check_id: str, run: Mapping[str, object]) -> None:
291
+ target = self.latest_path(check_id)
292
+ current = _read_mapping(target)
293
+ if current is not None and _run_sort_key(current) > _run_sort_key(run):
294
+ return
295
+ _atomic_json(target, run)
296
+
297
+ def write_run(self, check_id: str, run: Mapping[str, object]) -> dict[str, object]:
298
+ if not isinstance(run, Mapping):
299
+ raise EvidenceStoreError("invalid_run", "run", "run must be a mapping")
300
+ data = dict(run)
301
+ identity = str(check_id or data.get("check_id") or "").strip()
302
+ if not identity:
303
+ raise EvidenceStoreError("invalid_run", "check_id", "check_id must be non-empty")
304
+ run_id = data.get("run_id")
305
+ if not isinstance(run_id, str) or not run_id.strip():
306
+ raise EvidenceStoreError("invalid_run", "run_id", "run_id must be non-empty")
307
+ if data.get("source") == "ci":
308
+ validate_ci_run(data)
309
+ data["check_id"] = identity
310
+ target = self.run_path(identity, run_id)
311
+ if target.exists():
312
+ existing = _read_mapping(target)
313
+ self._update_latest(identity, existing if existing is not None else data)
314
+ return {"status": "duplicate", "check_id": identity, "run_id": run_id, "path": str(target)}
315
+ _atomic_json(target, data)
316
+ self._update_latest(identity, data)
317
+ return {"status": "created", "check_id": identity, "run_id": run_id, "path": str(target)}
318
+
319
+ def read_run(self, check_id: str, run_id: str) -> Optional[dict[str, object]]:
320
+ return _read_mapping(self.run_path(check_id, run_id))
321
+
322
+ def list_runs(self, check_id: str) -> list[dict[str, object]]:
323
+ directory = self.check_directory(check_id)
324
+ if not directory.is_dir():
325
+ return []
326
+ runs: dict[str, dict[str, object]] = {}
327
+ for path in sorted(directory.glob("*.json")):
328
+ item = _read_mapping(path)
329
+ if item is None:
330
+ continue
331
+ run_id = item.get("run_id")
332
+ runs[str(run_id) if isinstance(run_id, str) else path.stem] = item
333
+ return sorted(runs.values(), key=_run_sort_key)
334
+
335
+ def read_latest(self, check_id: str) -> Optional[dict[str, object]]:
336
+ # latest.json is only a hint: concurrent worktrees may write it out of
337
+ # order. The run files themselves are immutable and authoritative.
338
+ runs = self.list_runs(check_id)
339
+ return runs[-1] if runs else None
340
+
341
+ def store_run(self, check_id: str, run: Mapping[str, object]) -> dict[str, object]:
342
+ return self.write_run(check_id, run)
343
+
344
+ def latest(self, check_id: str) -> Optional[dict[str, object]]:
345
+ return self.read_latest(check_id)
346
+
347
+ def runs(self, check_id: str) -> list[dict[str, object]]:
348
+ return self.list_runs(check_id)
349
+
350
+ def export(self, target: Optional[object] = None) -> dict[str, object]:
351
+ checks: dict[str, list[dict[str, object]]] = {}
352
+ if self.directory.is_dir():
353
+ for directory in sorted(self.directory.iterdir()):
354
+ if directory.is_dir() and directory.name != HISTORY_DIR:
355
+ runs = self.list_runs(directory.name)
356
+ if runs:
357
+ checks[directory.name] = runs
358
+ payload = {
359
+ "schema": SCHEMA_VERSION,
360
+ "requirement": self.requirement,
361
+ "exported_at": datetime.now(timezone.utc).isoformat(),
362
+ "checks": checks,
363
+ }
364
+ if target is None:
365
+ return payload
366
+ destination = Path(target)
367
+ _atomic_json(destination, payload)
368
+ return {"status": "exported", "path": str(destination), "checks": len(checks), "runs": sum(len(items) for items in checks.values())}
369
+
370
+ def import_file(self, source: object) -> dict[str, object]:
371
+ path = Path(source)
372
+ try:
373
+ loaded = json.loads(path.read_text(encoding="utf-8"))
374
+ except (OSError, UnicodeError, ValueError) as exc:
375
+ raise EvidenceStoreError("import_read_error", "file", str(exc), str(path)) from exc
376
+ if not isinstance(loaded, Mapping):
377
+ raise EvidenceStoreError("invalid_import", "file", "import payload must be a mapping", str(path))
378
+ checks = loaded.get("checks")
379
+ imported = duplicates = 0
380
+ if isinstance(checks, Mapping):
381
+ for check_id, runs in checks.items():
382
+ if not isinstance(runs, list):
383
+ raise EvidenceStoreError("invalid_import", "checks", "check runs must be a list", str(path))
384
+ for run in runs:
385
+ if not isinstance(run, Mapping):
386
+ raise EvidenceStoreError("invalid_import", "run", "run must be a mapping", str(path))
387
+ outcome = self.write_run(str(check_id), run)
388
+ imported += outcome["status"] == "created"
389
+ duplicates += outcome["status"] == "duplicate"
390
+ elif isinstance(loaded.get("runs"), list):
391
+ for run in loaded["runs"]:
392
+ if not isinstance(run, Mapping):
393
+ raise EvidenceStoreError("invalid_import", "run", "run must be a mapping", str(path))
394
+ check_id = str(run.get("check_id") or run.get("scenario_id") or "unknown")
395
+ outcome = self.write_run(check_id, run)
396
+ imported += outcome["status"] == "created"
397
+ duplicates += outcome["status"] == "duplicate"
398
+ else:
399
+ raise EvidenceStoreError("invalid_import", "file", "import payload needs checks or runs", str(path))
400
+ return {"status": "imported", "imported": imported, "duplicates": duplicates}
401
+
402
+ def import_evidence(self, source: object) -> dict[str, object]:
403
+ return self.import_file(source)
404
+
405
+
406
+ def store_run(root: object, requirement: str, check_id: str, run: Mapping[str, object]) -> dict[str, object]:
407
+ return EvidenceStore(root, requirement).write_run(check_id, run)
408
+
409
+
410
+ def read_latest(root: object, requirement: str, check_id: str) -> Optional[dict[str, object]]:
411
+ return EvidenceStore(root, requirement).read_latest(check_id)
412
+
413
+
414
+ def list_runs(root: object, requirement: str, check_id: str) -> list[dict[str, object]]:
415
+ return EvidenceStore(root, requirement).list_runs(check_id)
416
+
417
+
418
+ def export_evidence(root: object, requirement: str, target: object) -> dict[str, object]:
419
+ return EvidenceStore(root, requirement).export(target)
420
+
421
+
422
+ def import_evidence(root: object, requirement: str, source: object) -> dict[str, object]:
423
+ return EvidenceStore(root, requirement).import_file(source)
424
+
425
+
426
+ def compact_evidence(
427
+ evidence: Iterable[Mapping[str, object]] | Mapping[str, object],
428
+ root: Optional[object] = None,
429
+ requirement: str = "",
430
+ check_id: str = "",
431
+ history_path: Optional[object] = None,
432
+ max_history: int = DEFAULT_HISTORY_LIMIT,
433
+ archive: bool = True,
434
+ store: Optional[EvidenceStore] = None,
435
+ stage: str = "code",
436
+ ) -> tuple[dict[str, object], ...]:
437
+ """Keep only the latest evidence entry; archive older entries to a sidecar."""
438
+ items = [evidence] if isinstance(evidence, Mapping) else list(evidence)
439
+ deduped = _dedupe(item for item in items if isinstance(item, Mapping))
440
+ if not deduped:
441
+ return ()
442
+ latest, older = deduped[-1], deduped[:-1]
443
+ if archive and older:
444
+ target = Path(history_path) if history_path is not None else None
445
+ if target is None:
446
+ active_store = store
447
+ if active_store is None and root is not None:
448
+ active_store = EvidenceStore(root, requirement)
449
+ if active_store is not None:
450
+ identity = check_id or str(latest.get("verifier_ref") or latest.get("check_id") or "context")
451
+ target = active_store.history_path(identity, stage)
452
+ if target is not None:
453
+ append_history(target, older, max_history)
454
+ return (latest,)
455
+
456
+
457
+ def compact_context(
458
+ context: object,
459
+ root: Optional[object] = None,
460
+ requirement: str = "",
461
+ store: Optional[EvidenceStore] = None,
462
+ max_history: int = DEFAULT_HISTORY_LIMIT,
463
+ archive: bool = True,
464
+ ) -> object:
465
+ """Return a SpecContext whose rule evidence contains only the latest entry."""
466
+ active_store = store
467
+ if active_store is None and root is not None and archive:
468
+ task = getattr(context, "task", "") or requirement
469
+ active_store = EvidenceStore(root, requirement or task)
470
+ bindings = []
471
+ for binding in getattr(context, "bindings", ()):
472
+ rules = []
473
+ for rule in binding.rules:
474
+ statuses = {}
475
+ for stage_name, status in rule.stage_status.items():
476
+ kept = compact_evidence(
477
+ status.evidence,
478
+ check_id=rule.verifier_ref or f"{binding.spec_id}#{rule.ref}",
479
+ store=active_store,
480
+ max_history=max_history,
481
+ archive=archive,
482
+ stage=stage_name,
483
+ )
484
+ statuses[stage_name] = replace(status, evidence=kept)
485
+ rules.append(replace(rule, stage_status=statuses))
486
+ bindings.append(replace(binding, rules=tuple(rules)))
487
+ return replace(context, bindings=tuple(bindings))
488
+
489
+
490
+ def compact_context_file(
491
+ path: object,
492
+ root: Optional[object] = None,
493
+ requirement: str = "",
494
+ max_history: int = DEFAULT_HISTORY_LIMIT,
495
+ archive: bool = True,
496
+ ) -> dict[str, object]:
497
+ """Compact a persisted spec-context.yml in place and report what changed."""
498
+ from codeflow.spec.cf_spec_context_model import load_context, save_context
499
+
500
+ context_path = Path(path)
501
+ context = load_context(str(context_path))
502
+ project = root if root is not None else discover_project_root(context_path)
503
+ name = requirement or getattr(context, "task", "") or context_path.parent.name
504
+ store = EvidenceStore(project, name)
505
+ before = sum(
506
+ len(status.evidence)
507
+ for binding in context.bindings
508
+ for rule in binding.rules
509
+ for status in rule.stage_status.values()
510
+ )
511
+ compacted = compact_context(context, store=store, max_history=max_history, archive=archive)
512
+ after = sum(
513
+ len(status.evidence)
514
+ for binding in compacted.bindings
515
+ for rule in binding.rules
516
+ for status in rule.stage_status.values()
517
+ )
518
+ save_context(str(context_path), compacted)
519
+ return {
520
+ "status": "compacted",
521
+ "path": str(context_path),
522
+ "requirement": name,
523
+ "before": before,
524
+ "after": after,
525
+ "archived": max(0, before - after),
526
+ "history_directory": str(store.directory / HISTORY_DIR),
527
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """Codex native wire protocol. Never translate patches into Claude tool calls."""
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ from codeflow.core.cf_core import ensure_utf8_io
8
+ from codeflow.spec.cf_edit_service import after_edit, prepare_edits
9
+ from codeflow.spec.cf_injection import injection_output, pre_tool_payload
10
+ from codeflow.hooks.cf_stop_hook import process_stop
11
+ from codeflow.hooks.cf_user_prompt_hook import process_prompt
12
+ from codeflow.core.cf_runtime_install import verify_install
13
+ from codeflow.core.cf_exec_base import execution_session, redact_sensitive_text
14
+ from codeflow.core import cf_log
15
+ from codeflow.core.cf_patch import parse_patch
16
+ from codeflow.core.cf_native_paths import project_root, validated_paths
17
+
18
+
19
+
20
+
21
+ def patch_succeeded(response: object) -> bool:
22
+ if isinstance(response, dict):
23
+ if response.get("error") or response.get("isError") or response.get("exit_code", 0) != 0:
24
+ return False
25
+ response = response.get("output", "")
26
+ return isinstance(response, str) and response.startswith("Success. Updated the following files:")
27
+
28
+
29
+ def dispatch(data: dict[str, object]) -> None:
30
+ cwd, sid, event = data.get("cwd"), data.get("session_id"), data.get("hook_event_name")
31
+ if not isinstance(cwd, str) or not os.path.isabs(cwd) or not isinstance(sid, str) or not sid:
32
+ raise ValueError("Codex hook requires absolute cwd and nonempty session_id")
33
+ root = project_root(cwd)
34
+ if not root:
35
+ return
36
+ verify_install(root)
37
+ if event == "UserPromptSubmit":
38
+ process_prompt(data, root)
39
+ return
40
+ if event == "Stop":
41
+ with execution_session():
42
+ process_stop(data, root)
43
+ return
44
+ if event not in {"PreToolUse", "PostToolUse"}:
45
+ raise ValueError(f"unsupported Codex hook event: {event}")
46
+ if data.get("tool_name") != "apply_patch":
47
+ return
48
+ if event == "PostToolUse" and not patch_succeeded(data.get("tool_response")):
49
+ cf_log.degrade(root, "codex_post_tool", "patch success not confirmed; no edit evidence recorded", sid)
50
+ return
51
+ tool_input = data.get("tool_input")
52
+ if not isinstance(tool_input, dict) or not isinstance(tool_input.get("command"), str):
53
+ raise ValueError("apply_patch requires tool_input.command")
54
+ paths = validated_paths(root, cwd, parse_patch(tool_input["command"]))
55
+ if event == "PreToolUse":
56
+ text, blocked = prepare_edits(root, tuple(path for path, _ in paths), sid, "apply_patch")
57
+ if text:
58
+ sys.stdout.write(json.dumps(pre_tool_payload(text, blocked), ensure_ascii=False))
59
+ return
60
+ feedback = []
61
+ for path, deleted in paths:
62
+ text = after_edit(root, path, sid, "apply_patch", deleted)
63
+ if text and text not in feedback:
64
+ feedback.append(text)
65
+ if feedback:
66
+ sys.stdout.write(json.dumps({"hookSpecificOutput": {"hookEventName": event,
67
+ "additionalContext": "\n\n".join(feedback)}}, ensure_ascii=False))
68
+
69
+
70
+ def main() -> None:
71
+ data: dict[str, object] = {}
72
+ try:
73
+ ensure_utf8_io()
74
+ if sys.stdin.isatty():
75
+ cf_log.warn("cf_codex_hook: missing stdin event")
76
+ return
77
+ raw = sys.stdin.read()
78
+ if raw.strip():
79
+ data = json.loads(raw)
80
+ if not isinstance(data, dict):
81
+ raise ValueError("Codex hook event must be an object")
82
+ with injection_output():
83
+ dispatch(data)
84
+ sys.stdout.flush()
85
+ except Exception as exc:
86
+ detail = redact_sensitive_text(str(exc))
87
+ cf_log.warn(f"cf_codex_hook error: {detail}")
88
+ message = f"code-flow Codex hook failed: {detail}. Run code-flow migrate --runtime --dry-run, then --apply."
89
+ payload = ({"decision": "block", "reason": message} if isinstance(data, dict) and data.get("hook_event_name") == "Stop"
90
+ else {"systemMessage": message})
91
+ if isinstance(data, dict) and data.get("hook_event_name") == "PreToolUse":
92
+ payload.update(pre_tool_payload(message, True))
93
+ if isinstance(data, dict) and data.get("hook_event_name") == "UserPromptSubmit":
94
+ payload.update(decision="block", reason=message)
95
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))