@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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  3. package/src/adapters/claude/commands/cf-task/start.md +6 -7
  4. package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
  5. package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
  6. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  7. package/src/adapters/costrict/commands/cf-task/start.md +6 -7
  8. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
  9. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  10. package/src/adapters/opencode/commands/cf-task/start.md +6 -7
  11. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
  12. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
  13. package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
  14. package/src/core/code-flow/scripts/cf_core.py +20 -0
  15. package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
  16. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +5 -4
  17. package/src/core/code-flow/scripts/cf_spec_context.py +131 -16
  18. package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
  19. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  20. package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
  21. package/src/core/code-flow/scripts/cf_spec_router.py +102 -9
  22. package/src/core/code-flow/scripts/cf_spec_verify.py +118 -8
  23. package/src/core/code-flow/scripts/cf_stop_hook.py +2 -0
  24. package/src/core/code-flow/scripts/cf_task_runtime.py +48 -2
  25. 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 resolve_candidates
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 resolve_candidates(root, "code", (relative,))
32
- if item.spec_id not in bound and item.metadata.enforcement == "required"
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
- _string(data.get("content_sha256"), "content_sha256", path),
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, _path_hash(root, path)) for path, status in changes.items()
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
- return hashlib.sha256(path.read_bytes()).hexdigest()
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))