@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.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -42
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +52 -39
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +52 -39
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +53 -45
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /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
|
+
}
|
|
File without changes
|
|
@@ -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))
|