@michaelxwb/code-flow 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +6 -7
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +6 -7
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +6 -7
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
- package/src/core/code-flow/scripts/cf_core.py +20 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_spec_context.py +131 -16
- package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
- package/src/core/code-flow/scripts/cf_spec_router.py +102 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +118 -8
- package/src/core/code-flow/scripts/cf_stop_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +48 -2
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +2 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lock and validate plan Acceptance Coverage entries."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _kind(level: str) -> str:
|
|
19
|
+
"""Map test level to execution kind.
|
|
20
|
+
|
|
21
|
+
- functional: auto-executed in Done Gate (unit, integration)
|
|
22
|
+
- e2e: deferred until explicit --verify-e2e (needs external dependencies)
|
|
23
|
+
- manual: user confirmation required, never auto-executed
|
|
24
|
+
"""
|
|
25
|
+
normalized = level.strip().lower()
|
|
26
|
+
if normalized == "manual":
|
|
27
|
+
return "manual"
|
|
28
|
+
if normalized == "e2e":
|
|
29
|
+
return "e2e" # Keep as separate kind, not functional
|
|
30
|
+
return "functional"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _manifest_hash(rows: list[dict[str, str]]) -> str:
|
|
34
|
+
immutable = [{key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner")} for item in rows]
|
|
35
|
+
payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
36
|
+
return hashlib.sha256(payload).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_manifest(task_file: str) -> dict[str, object]:
|
|
40
|
+
path = Path(task_file)
|
|
41
|
+
text = path.read_text(encoding="utf-8")
|
|
42
|
+
rows: list[dict[str, str]] = []
|
|
43
|
+
in_table = False
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
if line.startswith("## Acceptance Coverage"):
|
|
47
|
+
in_table = True
|
|
48
|
+
continue
|
|
49
|
+
if in_table and line.startswith("## "):
|
|
50
|
+
break
|
|
51
|
+
if not in_table:
|
|
52
|
+
continue
|
|
53
|
+
match = _ROW_RE.match(line)
|
|
54
|
+
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
55
|
+
continue
|
|
56
|
+
fields = [item.strip() for item in match.group(2).split("|")]
|
|
57
|
+
if len(fields) < 5 or match.group(1) in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(match.group(1))
|
|
60
|
+
rows.append({"id": match.group(1), "source": fields[0], "level": fields[1], "kind": _kind(fields[1]), "boundary": fields[2], "owner": fields[3], "status": fields[4]})
|
|
61
|
+
if not rows:
|
|
62
|
+
raise ValueError("Acceptance Coverage 缺失或为空")
|
|
63
|
+
return {"schema": 1, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
67
|
+
manifest = extract_manifest(task_file)
|
|
68
|
+
target = Path(output)
|
|
69
|
+
try:
|
|
70
|
+
manifest["task_file"] = str(Path(task_file).resolve().relative_to(target.parent.resolve()))
|
|
71
|
+
except ValueError:
|
|
72
|
+
manifest["task_file"] = str(Path(task_file).resolve())
|
|
73
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
75
|
+
return manifest
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
|
|
79
|
+
try:
|
|
80
|
+
manifest = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
|
|
81
|
+
expected = extract_manifest(task_file)
|
|
82
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
83
|
+
return False, f"acceptance_manifest_invalid: {exc}"
|
|
84
|
+
if manifest.get("schema") != 1 or manifest.get("task_sha256") != expected["task_sha256"]:
|
|
85
|
+
return False, "acceptance_manifest_drift"
|
|
86
|
+
actual = {item.get("id") for item in manifest.get("scenarios", []) if isinstance(item, Mapping)}
|
|
87
|
+
required = {item["id"] for item in expected["scenarios"]}
|
|
88
|
+
if actual != required:
|
|
89
|
+
return False, "acceptance_manifest_scenarios_changed"
|
|
90
|
+
text = Path(task_file).read_text(encoding="utf-8")
|
|
91
|
+
for scenario in expected["scenarios"]:
|
|
92
|
+
owner = scenario["owner"]
|
|
93
|
+
section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
94
|
+
refs = section.group(0).partition("Acceptance-Refs")[2] if section else ""
|
|
95
|
+
if scenario["id"] not in refs:
|
|
96
|
+
return False, "acceptance_manifest_owner_missing"
|
|
97
|
+
return True, ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: str, evidence: str) -> None:
|
|
101
|
+
if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
|
|
102
|
+
raise ValueError("manual evidence must be confirmed by a user")
|
|
103
|
+
path = Path(manifest_file)
|
|
104
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
105
|
+
scenarios = data.get("scenarios")
|
|
106
|
+
if not isinstance(scenarios, list):
|
|
107
|
+
raise ValueError("invalid manifest scenarios")
|
|
108
|
+
found = False
|
|
109
|
+
owner = ""
|
|
110
|
+
task_file = data.get("task_file")
|
|
111
|
+
for item in scenarios:
|
|
112
|
+
if isinstance(item, dict) and item.get("id") == scenario_id:
|
|
113
|
+
if item.get("kind") != "manual":
|
|
114
|
+
raise ValueError("scenario is not manual")
|
|
115
|
+
item["status"] = "verified"
|
|
116
|
+
item["evidence"] = {"confirmed_by": confirmed_by, "evidence": evidence}
|
|
117
|
+
found = True
|
|
118
|
+
owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
|
|
119
|
+
if not found:
|
|
120
|
+
raise ValueError("scenario not found")
|
|
121
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
122
|
+
if isinstance(task_file, str) and task_file:
|
|
123
|
+
task_path = Path(task_file)
|
|
124
|
+
if not task_path.is_absolute():
|
|
125
|
+
task_path = path.parent / task_path
|
|
126
|
+
_sync_task_evidence(task_path, scenario_id, confirmed_by, evidence, owner)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sync_task_evidence(task_file: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "") -> None:
|
|
130
|
+
text = task_file.read_text(encoding="utf-8")
|
|
131
|
+
if owner:
|
|
132
|
+
section_match = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
133
|
+
else:
|
|
134
|
+
section_match = re.search(r"(?ms)^##\s+TASK-\d+:.*?(?=^##\s+TASK-|\Z)", text)
|
|
135
|
+
if section_match is None:
|
|
136
|
+
return
|
|
137
|
+
section = section_match.group(0)
|
|
138
|
+
line = f"- {scenario_id}: verified — {evidence} (confirmed_by: {confirmed_by})"
|
|
139
|
+
contract_match = re.search(r"(?ms)^### Acceptance Contract\s*$\n(.*?)(?=^### |^## |\Z)", section)
|
|
140
|
+
if contract_match and scenario_id in contract_match.group(1) and "verified" not in contract_match.group(1).lower():
|
|
141
|
+
body = contract_match.group(1)
|
|
142
|
+
body = "\n".join((f"{item} — verified" if scenario_id in item else item) for item in body.splitlines())
|
|
143
|
+
section = section[:contract_match.start(1)] + body + section[contract_match.end(1):]
|
|
144
|
+
evidence_match = re.search(r"(?ms)^### Acceptance Evidence\s*$\n(.*?)(?=^### |^## |\Z)", section)
|
|
145
|
+
if evidence_match:
|
|
146
|
+
body = evidence_match.group(1)
|
|
147
|
+
replacement = body.rstrip() + "\n" + line + "\n"
|
|
148
|
+
section = section[:evidence_match.start(1)] + replacement + section[evidence_match.end(1):]
|
|
149
|
+
else:
|
|
150
|
+
section = section.rstrip() + "\n\n### Acceptance Evidence\n" + line + "\n"
|
|
151
|
+
task_file.write_text(text[:section_match.start()] + section + text[section_match.end():], encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
155
|
+
parser = argparse.ArgumentParser()
|
|
156
|
+
parser.add_argument("--task-file", required=True)
|
|
157
|
+
parser.add_argument("--output", default="")
|
|
158
|
+
parser.add_argument("--verify-plan", action="store_true")
|
|
159
|
+
parser.add_argument("--task-dir", default="")
|
|
160
|
+
parser.add_argument("--record-manual", action="store_true")
|
|
161
|
+
parser.add_argument("--scenario-id", default="")
|
|
162
|
+
parser.add_argument("--confirmed-by", default="")
|
|
163
|
+
parser.add_argument("--evidence", default="")
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
try:
|
|
166
|
+
if args.record_manual:
|
|
167
|
+
if not args.output or not args.scenario_id or not args.evidence:
|
|
168
|
+
raise ValueError("--record-manual requires --output, --scenario-id and --evidence")
|
|
169
|
+
record_manual_evidence(args.output, args.scenario_id, args.confirmed_by, args.evidence)
|
|
170
|
+
stdout.write(json.dumps({"ok": True, "scenario": args.scenario_id}, ensure_ascii=False))
|
|
171
|
+
return 0
|
|
172
|
+
if args.verify_plan:
|
|
173
|
+
if not args.task_dir:
|
|
174
|
+
raise ValueError("--verify-plan requires --task-dir")
|
|
175
|
+
from cf_spec_context import load_context
|
|
176
|
+
from cf_spec_gate import result_to_data, validate_plan_coverage, validate_stage
|
|
177
|
+
|
|
178
|
+
context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
|
|
179
|
+
gate = validate_stage(context, "plan")
|
|
180
|
+
coverage = validate_plan_coverage(context, args.task_file)
|
|
181
|
+
result = {
|
|
182
|
+
"decision": "block" if gate.decision == "block" or coverage.decision == "block" else "pass",
|
|
183
|
+
"gate": result_to_data(gate),
|
|
184
|
+
"coverage": result_to_data(coverage),
|
|
185
|
+
}
|
|
186
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
187
|
+
return 0 if result["decision"] == "pass" else 3
|
|
188
|
+
if not args.output:
|
|
189
|
+
raise ValueError("--output is required unless --verify-plan is used")
|
|
190
|
+
manifest = write_manifest(args.task_file, args.output)
|
|
191
|
+
stdout.write(json.dumps({"ok": True, "scenarios": len(manifest["scenarios"])}, ensure_ascii=False))
|
|
192
|
+
return 0
|
|
193
|
+
except (OSError, ValueError) as exc:
|
|
194
|
+
stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
195
|
+
return 2
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Execute manifest scenarios with deterministic ordering and evidence."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(item: Mapping[str, object], root: Path, include_e2e: bool = False) -> dict[str, object]:
|
|
15
|
+
scenario_id = str(item.get("id", "unknown"))
|
|
16
|
+
kind = str(item.get("kind", "functional"))
|
|
17
|
+
if kind == "manual":
|
|
18
|
+
return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
|
|
19
|
+
if kind == "e2e" and not include_e2e:
|
|
20
|
+
return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
|
|
21
|
+
command = item.get("command")
|
|
22
|
+
if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
|
|
23
|
+
return {"id": scenario_id, "kind": kind, "status": "not_configured"}
|
|
24
|
+
timeout = item.get("timeout", 60)
|
|
25
|
+
timeout_value = float(timeout) if isinstance(timeout, (int, float)) else 60.0
|
|
26
|
+
cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(command, cwd=str(root / cwd), capture_output=True, timeout=timeout_value, check=False)
|
|
29
|
+
except subprocess.TimeoutExpired:
|
|
30
|
+
return {"id": scenario_id, "kind": kind, "status": "failed", "error": "timeout"}
|
|
31
|
+
except OSError as exc:
|
|
32
|
+
return {"id": scenario_id, "kind": kind, "status": "failed", "error": str(exc)}
|
|
33
|
+
return {"id": scenario_id, "kind": kind, "status": "passed" if result.returncode == 0 else "failed", "exit_code": result.returncode}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
|
|
37
|
+
by_id = {str(item.get("id")): item for item in items}
|
|
38
|
+
pending = set(by_id)
|
|
39
|
+
ordered: list[Mapping[str, object]] = []
|
|
40
|
+
while pending:
|
|
41
|
+
ready: list[str] = []
|
|
42
|
+
for item_id in by_id:
|
|
43
|
+
if item_id not in pending:
|
|
44
|
+
continue
|
|
45
|
+
deps = by_id[item_id].get("depends_on", [])
|
|
46
|
+
dep_ids = [str(dep) for dep in deps] if isinstance(deps, list) else []
|
|
47
|
+
unknown = [dep for dep in dep_ids if dep not in by_id]
|
|
48
|
+
if unknown:
|
|
49
|
+
raise ValueError(f"acceptance scenario dependency missing: {', '.join(unknown)}")
|
|
50
|
+
if all(dep not in pending for dep in dep_ids):
|
|
51
|
+
ready.append(item_id)
|
|
52
|
+
if not ready:
|
|
53
|
+
raise ValueError("acceptance scenario dependency cycle")
|
|
54
|
+
ordered.extend(by_id[item_id] for item_id in ready)
|
|
55
|
+
pending.difference_update(ready)
|
|
56
|
+
return ordered
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_manifest(manifest_file: str, root: str, write_evidence: bool = False, include_e2e: bool = False) -> dict[str, object]:
|
|
60
|
+
data = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
|
|
61
|
+
scenarios = data.get("scenarios", [])
|
|
62
|
+
if not isinstance(scenarios, list):
|
|
63
|
+
raise ValueError("invalid scenarios")
|
|
64
|
+
items = [item for item in scenarios if isinstance(item, Mapping)]
|
|
65
|
+
ordered = _ordered(items)
|
|
66
|
+
results = [_run(item, Path(root), include_e2e) for item in ordered]
|
|
67
|
+
configured = any(isinstance(item.get("command"), list) for item in items)
|
|
68
|
+
allowed = ("passed", "manual_pending", "e2e_deferred", "not_configured") if not configured else ("passed", "manual_pending", "e2e_deferred")
|
|
69
|
+
decision = "pass" if all(item["status"] in allowed for item in results) else "block"
|
|
70
|
+
if write_evidence:
|
|
71
|
+
for item, result in zip(ordered, results):
|
|
72
|
+
if isinstance(item, dict) and result["status"] == "passed":
|
|
73
|
+
item["status"] = "verified"
|
|
74
|
+
item["evidence"] = result
|
|
75
|
+
Path(manifest_file).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
76
|
+
task_file = data.get("task_file")
|
|
77
|
+
if isinstance(task_file, str) and task_file:
|
|
78
|
+
from cf_acceptance_manifest import _sync_task_evidence
|
|
79
|
+
|
|
80
|
+
task_path = Path(task_file)
|
|
81
|
+
if not task_path.is_absolute():
|
|
82
|
+
task_path = Path(manifest_file).parent / task_path
|
|
83
|
+
for item, result in zip(ordered, results):
|
|
84
|
+
if result["status"] == "passed":
|
|
85
|
+
owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
|
|
86
|
+
_sync_task_evidence(task_path, str(result["id"]), "runner", "automated command passed", owner)
|
|
87
|
+
return {"decision": decision, "results": results}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
91
|
+
parser = argparse.ArgumentParser()
|
|
92
|
+
parser.add_argument("--manifest", required=True)
|
|
93
|
+
parser.add_argument("--root", required=True)
|
|
94
|
+
parser.add_argument("--write-evidence", action="store_true")
|
|
95
|
+
parser.add_argument("--include-e2e", action="store_true", help="Execute E2E scenarios (deferred by default)")
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
try:
|
|
98
|
+
result = run_manifest(args.manifest, args.root, args.write_evidence, args.include_e2e)
|
|
99
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
100
|
+
return 0 if result["decision"] == "pass" else 3
|
|
101
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
102
|
+
stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -3,6 +3,7 @@ import fnmatch
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
|
+
import time
|
|
6
7
|
|
|
7
8
|
# --- Config cache (fix #3: avoid re-parsing YAML on every hook call) ---
|
|
8
9
|
|
|
@@ -665,6 +666,25 @@ def _log(msg: str) -> None:
|
|
|
665
666
|
print(msg, file=sys.stderr)
|
|
666
667
|
|
|
667
668
|
|
|
669
|
+
_PROCESS_START = time.monotonic()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def timing_log(name: str) -> None:
|
|
673
|
+
"""Emit process-elapsed ms to stderr only when CF_DEBUG=1 (diagnostics).
|
|
674
|
+
|
|
675
|
+
Never touches stdout, so the hook JSON protocol is unaffected; a default
|
|
676
|
+
run pays one env lookup and no IO.
|
|
677
|
+
"""
|
|
678
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
679
|
+
_log(f"{name}: {int((time.monotonic() - _PROCESS_START) * 1000)}ms")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def phase_timing(name: str, started: float) -> None:
|
|
683
|
+
"""Emit a named phase duration to stderr when CF_DEBUG=1."""
|
|
684
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
685
|
+
_log(f"cf_phase {name}: {int((time.monotonic() - started) * 1000)}ms")
|
|
686
|
+
|
|
687
|
+
|
|
668
688
|
def ensure_utf8_io() -> None:
|
|
669
689
|
"""Force stdin/stdout/stderr to UTF-8 so Windows hooks don't mojibake.
|
|
670
690
|
|
|
@@ -40,6 +40,7 @@ from cf_core import (
|
|
|
40
40
|
resolve_enforcement,
|
|
41
41
|
resolve_quality_loop,
|
|
42
42
|
resolve_session_id,
|
|
43
|
+
timing_log,
|
|
43
44
|
)
|
|
44
45
|
|
|
45
46
|
FEEDBACK_HINT = '如认为误报,直接告诉我"这是误报",我会标记忽略。'
|
|
@@ -191,3 +192,4 @@ def main() -> None:
|
|
|
191
192
|
|
|
192
193
|
if __name__ == "__main__":
|
|
193
194
|
main()
|
|
195
|
+
timing_log("cf_post_hook")
|
|
@@ -11,10 +11,10 @@ from pathlib import Path
|
|
|
11
11
|
import sys
|
|
12
12
|
|
|
13
13
|
import cf_log
|
|
14
|
-
from cf_core import _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id
|
|
14
|
+
from cf_core import _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id, timing_log
|
|
15
15
|
from cf_session_state import load_session_state, save_session_state
|
|
16
16
|
from cf_spec_context import load_active_task, load_context
|
|
17
|
-
from cf_spec_resolver import
|
|
17
|
+
from cf_spec_resolver import resolve_candidate_headers
|
|
18
18
|
from cf_spec_router import RouterError, route_prompt
|
|
19
19
|
|
|
20
20
|
_WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
|
|
@@ -28,8 +28,8 @@ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
|
|
|
28
28
|
context = load_context(str(Path(root) / active.task_dir / "spec-context.yml"))
|
|
29
29
|
bound = {item.spec_id for item in context.bindings}
|
|
30
30
|
return tuple(
|
|
31
|
-
item.spec_id for item in
|
|
32
|
-
if item.spec_id not in bound and item.
|
|
31
|
+
item.spec_id for item in resolve_candidate_headers(root, "code", (relative,))
|
|
32
|
+
if item.spec_id not in bound and item.enforcement == "required"
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
|
|
@@ -86,3 +86,4 @@ def main() -> None:
|
|
|
86
86
|
|
|
87
87
|
if __name__ == "__main__":
|
|
88
88
|
main()
|
|
89
|
+
timing_log("cf_pre_tool_hook")
|
|
@@ -14,12 +14,12 @@ from pathlib import Path
|
|
|
14
14
|
import subprocess
|
|
15
15
|
import sys
|
|
16
16
|
import tempfile
|
|
17
|
+
import time
|
|
17
18
|
from typing import IO, Mapping, Optional, Sequence
|
|
18
19
|
|
|
19
|
-
import yaml
|
|
20
|
-
|
|
21
20
|
from cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
|
|
22
21
|
from cf_spec_resolver import SpecCandidate, resolve_candidates
|
|
22
|
+
from cf_core import phase_timing
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
CONTEXT_VERSION = 1
|
|
@@ -28,12 +28,17 @@ STAGE_STATUSES = frozenset(
|
|
|
28
28
|
)
|
|
29
29
|
DECISION_KINDS = frozenset(("not_applicable", "waived", "manual_verification"))
|
|
30
30
|
_AGENT_IDENTITIES = frozenset(("agent", "assistant", "codex", "claude", "opencode", "costrict"))
|
|
31
|
+
_FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
|
|
32
|
+
_PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
|
|
33
|
+
_PERSISTENT_HASH_PATHS: set[str] = set()
|
|
34
|
+
_MAX_HASH_CACHE_ENTRIES = 4096
|
|
31
35
|
ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
|
|
32
36
|
DEFAULT_ACTIVE_EXCLUDES = (
|
|
33
37
|
".code-flow/tasks/*",
|
|
34
38
|
".code-flow/specs/_session/*",
|
|
35
39
|
".code-flow/migrations/*",
|
|
36
40
|
".code-flow/.*state*",
|
|
41
|
+
".code-flow/.*cache*",
|
|
37
42
|
".code-flow/.session-log.jsonl",
|
|
38
43
|
".code-flow/.active-task.json",
|
|
39
44
|
".code-flow/.active-task.lock",
|
|
@@ -208,7 +213,7 @@ def _active_paths(root: str) -> tuple[Path, Path]:
|
|
|
208
213
|
def _run_git(root: str, arguments: Sequence[str]) -> str:
|
|
209
214
|
try:
|
|
210
215
|
result = subprocess.run(
|
|
211
|
-
("git", *arguments), cwd=root, text=True, capture_output=True, check=False
|
|
216
|
+
("git", *arguments), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
|
|
212
217
|
)
|
|
213
218
|
except OSError as exc:
|
|
214
219
|
raise ContextError("git_unavailable", "git", str(exc), root) from exc
|
|
@@ -267,16 +272,6 @@ def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
|
|
|
267
272
|
return any(fnmatchcase(path, pattern) for pattern in patterns)
|
|
268
273
|
|
|
269
274
|
|
|
270
|
-
def _path_hash(root: str, path: str) -> str:
|
|
271
|
-
target = Path(root) / path
|
|
272
|
-
digest = hashlib.sha256()
|
|
273
|
-
if target.is_file():
|
|
274
|
-
with target.open("rb") as handle:
|
|
275
|
-
for chunk in iter(lambda: handle.read(65536), b""):
|
|
276
|
-
digest.update(chunk)
|
|
277
|
-
return digest.hexdigest()
|
|
278
|
-
|
|
279
|
-
|
|
280
275
|
def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
281
276
|
changes = {
|
|
282
277
|
path: {"status": item.status, "content_sha256": item.content_sha256}
|
|
@@ -300,9 +295,10 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
|
300
295
|
|
|
301
296
|
def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
|
|
302
297
|
data = _mapping(value, "preexisting_changes[]", path)
|
|
298
|
+
content_hash = data.get("content_sha256")
|
|
303
299
|
return PathSnapshot(
|
|
304
300
|
_string(data.get("status"), "status", path),
|
|
305
|
-
|
|
301
|
+
content_hash if isinstance(content_hash, str) else "",
|
|
306
302
|
)
|
|
307
303
|
|
|
308
304
|
|
|
@@ -460,8 +456,11 @@ def _new_active(
|
|
|
460
456
|
raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
|
|
461
457
|
if unowned:
|
|
462
458
|
raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
|
|
459
|
+
# content_sha256 was a per-file hash but is never consumed by any gate (only
|
|
460
|
+
# baseline.head is compared); keep the schema field empty so start never hashes
|
|
461
|
+
# every uncommitted file.
|
|
463
462
|
snapshots = {
|
|
464
|
-
path: PathSnapshot(status,
|
|
463
|
+
path: PathSnapshot(status, "") for path, status in changes.items()
|
|
465
464
|
}
|
|
466
465
|
baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
|
|
467
466
|
return ActiveTask(
|
|
@@ -822,6 +821,7 @@ def context_sha256(context: SpecContext) -> str:
|
|
|
822
821
|
Computed from the stable identity projection (never volatile runtime state),
|
|
823
822
|
so evidence timestamps and stage statuses cannot drift the marker.
|
|
824
823
|
"""
|
|
824
|
+
import yaml
|
|
825
825
|
data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
|
|
826
826
|
return hashlib.sha256(data).hexdigest()
|
|
827
827
|
|
|
@@ -834,6 +834,7 @@ def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
|
|
|
834
834
|
|
|
835
835
|
|
|
836
836
|
def load_context(path: str) -> SpecContext:
|
|
837
|
+
import yaml
|
|
837
838
|
try:
|
|
838
839
|
loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
839
840
|
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
@@ -862,6 +863,7 @@ def load_context(path: str) -> SpecContext:
|
|
|
862
863
|
|
|
863
864
|
|
|
864
865
|
def save_context(path: str, context: SpecContext) -> None:
|
|
866
|
+
import yaml
|
|
865
867
|
target = Path(path)
|
|
866
868
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
867
869
|
text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
|
|
@@ -965,7 +967,57 @@ def _removed_rule(rule: RuleBinding) -> RuleBinding:
|
|
|
965
967
|
|
|
966
968
|
def _file_sha256(path: Path) -> str:
|
|
967
969
|
try:
|
|
968
|
-
|
|
970
|
+
stat = path.stat()
|
|
971
|
+
key = str(path)
|
|
972
|
+
cached = _FILE_HASH_CACHE.get(key)
|
|
973
|
+
signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
|
974
|
+
if cached is not None and cached[:3] == signature:
|
|
975
|
+
return cached[3]
|
|
976
|
+
cache_path = next(
|
|
977
|
+
(parent / ".artifact-hash-cache.json" for parent in path.parents if parent.name == ".code-flow"),
|
|
978
|
+
None,
|
|
979
|
+
)
|
|
980
|
+
cache_key = str(cache_path) if cache_path is not None else ""
|
|
981
|
+
if cache_key and cache_key not in _PERSISTENT_HASH_PATHS:
|
|
982
|
+
try:
|
|
983
|
+
loaded = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
984
|
+
if isinstance(loaded, dict):
|
|
985
|
+
_PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
|
|
986
|
+
except (OSError, ValueError):
|
|
987
|
+
pass
|
|
988
|
+
_PERSISTENT_HASH_PATHS.add(cache_key)
|
|
989
|
+
persistent = _PERSISTENT_HASH_CACHE.get(key)
|
|
990
|
+
if (
|
|
991
|
+
isinstance(persistent, dict)
|
|
992
|
+
and persistent.get("mtime_ns") == stat.st_mtime_ns
|
|
993
|
+
and persistent.get("size") == stat.st_size
|
|
994
|
+
and persistent.get("ino") == stat.st_ino
|
|
995
|
+
):
|
|
996
|
+
digest = persistent.get("sha256")
|
|
997
|
+
if isinstance(digest, str) and digest:
|
|
998
|
+
_FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
|
|
999
|
+
return digest
|
|
1000
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
1001
|
+
_FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
|
|
1002
|
+
if cache_key:
|
|
1003
|
+
_PERSISTENT_HASH_CACHE[key] = {
|
|
1004
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
1005
|
+
"size": stat.st_size,
|
|
1006
|
+
"ino": stat.st_ino,
|
|
1007
|
+
"sha256": digest,
|
|
1008
|
+
}
|
|
1009
|
+
while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
|
|
1010
|
+
_PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
|
|
1011
|
+
try:
|
|
1012
|
+
temporary = cache_path.with_suffix(".json.tmp")
|
|
1013
|
+
temporary.write_text(
|
|
1014
|
+
json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True),
|
|
1015
|
+
encoding="utf-8",
|
|
1016
|
+
)
|
|
1017
|
+
temporary.replace(cache_path)
|
|
1018
|
+
except OSError:
|
|
1019
|
+
pass
|
|
1020
|
+
return digest
|
|
969
1021
|
except OSError as exc:
|
|
970
1022
|
raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
|
|
971
1023
|
|
|
@@ -1057,15 +1109,19 @@ def _refresh_binding(
|
|
|
1057
1109
|
|
|
1058
1110
|
|
|
1059
1111
|
def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
|
|
1112
|
+
started = time.monotonic()
|
|
1060
1113
|
specs = Path(root) / ".code-flow" / "specs"
|
|
1061
1114
|
artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
|
|
1062
1115
|
bindings: list[SpecBinding] = []
|
|
1063
1116
|
changes: list[DriftChange] = []
|
|
1064
1117
|
for binding in context.bindings:
|
|
1118
|
+
phase_started = time.monotonic()
|
|
1065
1119
|
updated, binding_changes = _refresh_binding(binding, specs, artifacts)
|
|
1120
|
+
phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
|
|
1066
1121
|
bindings.append(updated)
|
|
1067
1122
|
changes.extend(binding_changes)
|
|
1068
1123
|
refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1124
|
+
phase_timing("context.refresh.total", started)
|
|
1069
1125
|
if refreshed.bindings == context.bindings:
|
|
1070
1126
|
return DriftResult(context, tuple(changes))
|
|
1071
1127
|
return DriftResult(refreshed, tuple(changes))
|
|
@@ -1309,6 +1365,56 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
|
|
|
1309
1365
|
return {"ok": True, "active": _active_data(active)}
|
|
1310
1366
|
|
|
1311
1367
|
|
|
1368
|
+
def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1369
|
+
"""Run refresh, activation and TASK projection in one guarded process."""
|
|
1370
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1371
|
+
refreshed = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
|
|
1372
|
+
save_context(str(context_path), refreshed.context)
|
|
1373
|
+
_resync_after_save(args, context_path)
|
|
1374
|
+
current_hash = context_sha256(refreshed.context)
|
|
1375
|
+
owned = tuple(
|
|
1376
|
+
_string(item, "owned_paths[]", "")
|
|
1377
|
+
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1378
|
+
)
|
|
1379
|
+
task_file = Path(args.task_file).resolve()
|
|
1380
|
+
task_dir = Path(args.task_dir).resolve()
|
|
1381
|
+
if task_file.parent != task_dir or not task_file.is_file():
|
|
1382
|
+
raise ContextError("invalid_task_file", "task_file", str(task_file), args.task_dir)
|
|
1383
|
+
manifest_file = task_dir / ".acceptance-manifest.json"
|
|
1384
|
+
if "## Acceptance Coverage" in task_file.read_text(encoding="utf-8") and not manifest_file.is_file():
|
|
1385
|
+
raise ContextError(
|
|
1386
|
+
"acceptance_manifest_missing",
|
|
1387
|
+
"acceptance_manifest",
|
|
1388
|
+
"run plan verification and lock the manifest",
|
|
1389
|
+
str(manifest_file),
|
|
1390
|
+
)
|
|
1391
|
+
if manifest_file.is_file():
|
|
1392
|
+
from cf_acceptance_manifest import validate_manifest
|
|
1393
|
+
|
|
1394
|
+
valid, reason = validate_manifest(str(task_file), str(manifest_file))
|
|
1395
|
+
if not valid:
|
|
1396
|
+
raise ContextError(reason, "acceptance_manifest", "run plan verification before coding", str(manifest_file))
|
|
1397
|
+
from cf_spec_session import project_task_session
|
|
1398
|
+
|
|
1399
|
+
projection = project_task_session(refreshed.context, str(task_file), args.task)
|
|
1400
|
+
if projection.truncated:
|
|
1401
|
+
raise ContextError("task_projection_truncated", "task_file", "split the TASK before coding")
|
|
1402
|
+
output = Path(args.session_output) if args.session_output else (
|
|
1403
|
+
Path(args.root) / ".code-flow/specs/_session" / f"task-{task_file.stem}.md"
|
|
1404
|
+
)
|
|
1405
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
1406
|
+
_atomic_text(output, projection.text)
|
|
1407
|
+
active = start_active_task(args.root, args.task_dir, args.task, current_hash, owned)
|
|
1408
|
+
return {
|
|
1409
|
+
"ok": True,
|
|
1410
|
+
"context_sha256": current_hash,
|
|
1411
|
+
"refresh_changes": [item.__dict__ for item in refreshed.changes],
|
|
1412
|
+
"active": _active_data(active),
|
|
1413
|
+
"session_output": str(output),
|
|
1414
|
+
"acceptance_manifest": str(manifest_file) if manifest_file.is_file() else None,
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
|
|
1312
1418
|
def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
1313
1419
|
"""Human-readable Spec Context status: task, marker health, gate, rules."""
|
|
1314
1420
|
from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
|
|
@@ -1413,6 +1519,13 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1413
1519
|
command.add_argument("--task", required=True)
|
|
1414
1520
|
command.add_argument("--context-sha256", required=True)
|
|
1415
1521
|
command.add_argument("--json", action="store_true")
|
|
1522
|
+
start = commands.add_parser("start")
|
|
1523
|
+
start.add_argument("--task-dir", required=True)
|
|
1524
|
+
start.add_argument("--root", required=True)
|
|
1525
|
+
start.add_argument("--task", required=True)
|
|
1526
|
+
start.add_argument("--task-file", required=True)
|
|
1527
|
+
start.add_argument("--session-output", default="")
|
|
1528
|
+
start.add_argument("--json", action="store_true")
|
|
1416
1529
|
return parser
|
|
1417
1530
|
|
|
1418
1531
|
|
|
@@ -1421,6 +1534,8 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
|
|
|
1421
1534
|
return _catalog_command(args)
|
|
1422
1535
|
if args.command == "active":
|
|
1423
1536
|
return _active_command(args, _json_payload(stdin))
|
|
1537
|
+
if args.command == "start":
|
|
1538
|
+
return _start_command(args, _json_payload(stdin))
|
|
1424
1539
|
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1425
1540
|
if args.command == "decision":
|
|
1426
1541
|
return _decision_command(args, _json_payload(stdin))
|