@michaelxwb/code-flow 0.6.1 → 0.6.3

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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  3. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  4. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  5. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  6. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  7. package/src/adapters/claude/commands/cf-task/start.md +22 -22
  8. package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
  9. package/src/adapters/claude/commands/cf-validate.md +12 -13
  10. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  11. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  12. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  13. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  14. package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
  15. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  16. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  17. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  18. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  19. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  20. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  21. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  22. package/src/adapters/costrict/commands/cf-task/start.md +22 -22
  23. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
  24. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  25. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  27. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  29. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  30. package/src/adapters/opencode/commands/cf-task/start.md +22 -22
  31. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
  32. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  33. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  34. package/src/cli.js +177 -63
  35. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  36. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
  37. package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
  38. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  39. package/src/core/code-flow/scripts/cf_core.py +26 -0
  40. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  41. package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
  42. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
  43. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  44. package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
  45. package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
  46. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  47. package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
  48. package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
  49. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  50. package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
  51. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  52. package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
  53. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  54. package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
  55. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  56. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
  58. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  59. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  60. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
@@ -0,0 +1,248 @@
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
+ import shlex
10
+ from pathlib import Path
11
+ import re
12
+ import sys
13
+ from typing import IO, Mapping, Optional, Sequence
14
+
15
+ from cf_acceptance_schema import load_manifest, validate_scenarios
16
+
17
+
18
+ _ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
19
+
20
+ _MANIFEST_SCHEMA = 2
21
+
22
+
23
+ def _kind(level: str) -> str:
24
+ """Map test level to execution kind.
25
+
26
+ - functional: auto-executed in Done Gate (unit, integration)
27
+ - e2e: deferred until explicit --verify-e2e (needs external dependencies)
28
+ - manual: user confirmation required, never auto-executed
29
+ """
30
+ normalized = level.strip().lower()
31
+ if normalized == "manual":
32
+ return "manual"
33
+ if normalized == "e2e":
34
+ return "e2e" # Keep as separate kind, not functional
35
+ return "functional"
36
+
37
+
38
+ def _parse_command(cell: str, scenario_id: str) -> Optional[list[str]]:
39
+ """Parse the optional 命令 column: argv JSON array preferred, else shell words.
40
+
41
+ Empty / `-` / `planned` → None (not yet registered, not executable).
42
+ """
43
+ text = (cell or "").strip()
44
+ if not text or text in {"-", "planned", "pending", "TBD", "tbd"}:
45
+ return None
46
+ if text.startswith("["):
47
+ try:
48
+ value = json.loads(text)
49
+ except json.JSONDecodeError as exc:
50
+ raise ValueError(f"{scenario_id} 命令列不是合法 argv JSON: {exc}") from exc
51
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
52
+ raise ValueError(f"{scenario_id} 命令列必须是 argv 字符串数组")
53
+ return list(value)
54
+ return shlex.split(text)
55
+
56
+
57
+ def _parse_float(cell: str, default: float) -> float:
58
+ try:
59
+ return float((cell or "").strip() or default)
60
+ except (TypeError, ValueError):
61
+ return default
62
+
63
+
64
+ def _manifest_hash(rows: list[dict[str, object]]) -> str:
65
+ immutable = [
66
+ {key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on")}
67
+ for item in rows
68
+ ]
69
+ payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
70
+ return hashlib.sha256(payload).hexdigest()
71
+
72
+
73
+ def extract_manifest(task_file: str) -> dict[str, object]:
74
+ path = Path(task_file)
75
+ text = path.read_text(encoding="utf-8")
76
+ rows: list[dict[str, object]] = []
77
+ in_table = False
78
+ seen: set[str] = set()
79
+ for line in text.splitlines():
80
+ if line.startswith("## Acceptance Coverage"):
81
+ in_table = True
82
+ continue
83
+ if in_table and line.startswith("## "):
84
+ break
85
+ if not in_table:
86
+ continue
87
+ match = _ROW_RE.match(line)
88
+ if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
89
+ continue
90
+ fields = [item.strip() for item in match.group(2).split("|")]
91
+ if match.group(1) in seen:
92
+ raise ValueError(f"duplicate scenario id: {match.group(1)}")
93
+ if len(fields) < 5:
94
+ raise ValueError(f"Acceptance Coverage missing columns: {match.group(1)}")
95
+ seen.add(match.group(1))
96
+ depends = [item.strip() for item in (fields[8] if len(fields) > 8 else "").split(",") if item.strip()]
97
+ rows.append({
98
+ "id": match.group(1),
99
+ "source": fields[0],
100
+ "level": fields[1],
101
+ "kind": _kind(fields[1]),
102
+ "boundary": fields[2],
103
+ "owner": fields[3],
104
+ "status": fields[4],
105
+ "command": _parse_command(fields[5], match.group(1)) if len(fields) > 5 else None,
106
+ "cwd": (fields[6] if len(fields) > 6 else "").strip() or ".",
107
+ "timeout": _parse_float(fields[7], 60.0) if len(fields) > 7 else 60.0,
108
+ "depends_on": depends,
109
+ })
110
+ if not rows:
111
+ raise ValueError("Acceptance Coverage 缺失或为空")
112
+ validate_scenarios(rows)
113
+ return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
114
+
115
+
116
+ def write_manifest(task_file: str, output: str) -> dict[str, object]:
117
+ manifest = extract_manifest(task_file)
118
+ target = Path(output)
119
+ try:
120
+ manifest["task_file"] = str(Path(task_file).resolve().relative_to(target.parent.resolve()))
121
+ except ValueError:
122
+ manifest["task_file"] = str(Path(task_file).resolve())
123
+ target.parent.mkdir(parents=True, exist_ok=True)
124
+ target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
125
+ return manifest
126
+
127
+
128
+ _IMMUTABLE_SCENARIO_FIELDS = (
129
+ "id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on",
130
+ )
131
+
132
+
133
+ def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
134
+ try:
135
+ manifest = load_manifest(Path(manifest_file))
136
+ expected = extract_manifest(task_file)
137
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
138
+ return False, f"acceptance_manifest_invalid: {exc}"
139
+ if manifest.get("schema") != _MANIFEST_SCHEMA or manifest.get("task_sha256") != expected["task_sha256"]:
140
+ return False, "acceptance_manifest_drift"
141
+ stored_task = manifest.get("task_file", "")
142
+ if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
143
+ return False, "acceptance_manifest_task_mismatch"
144
+ if not isinstance(manifest.get("scenarios"), list):
145
+ return False, "acceptance_manifest_scenarios_changed"
146
+ stored = {
147
+ item.get("id"): item
148
+ for item in manifest["scenarios"]
149
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
150
+ }
151
+ required = {item["id"] for item in expected["scenarios"]}
152
+ if set(stored) != required:
153
+ return False, "acceptance_manifest_scenarios_changed"
154
+ for item in expected["scenarios"]:
155
+ current = stored[item["id"]]
156
+ for field in _IMMUTABLE_SCENARIO_FIELDS:
157
+ if current.get(field) != item.get(field):
158
+ return False, f"acceptance_manifest_field_changed:{item['id']}:{field}"
159
+ text = Path(task_file).read_text(encoding="utf-8")
160
+ for scenario in expected["scenarios"]:
161
+ owner = scenario["owner"]
162
+ section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
163
+ refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)) if section else None
164
+ if not refs or scenario["id"] not in re.findall(r"\b[SEB]-\d+\b", refs.group(1)):
165
+ return False, "acceptance_manifest_owner_missing"
166
+ return True, ""
167
+
168
+
169
+ def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: str, evidence: str) -> None:
170
+ if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
171
+ raise ValueError("manual evidence must be confirmed by a user")
172
+ path = Path(manifest_file)
173
+ data = load_manifest(path)
174
+ scenarios = data.get("scenarios")
175
+ if not isinstance(scenarios, list):
176
+ raise ValueError("invalid manifest scenarios")
177
+ found = False
178
+ owner = ""
179
+ task_file = data.get("task_file")
180
+ for item in scenarios:
181
+ if isinstance(item, dict) and item.get("id") == scenario_id:
182
+ if item.get("kind") != "manual":
183
+ raise ValueError("scenario is not manual")
184
+ item["status"] = "verified"
185
+ item["evidence"] = {"confirmed_by": confirmed_by, "evidence": evidence}
186
+ found = True
187
+ owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
188
+ if not found:
189
+ raise ValueError("scenario not found")
190
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
191
+ if isinstance(task_file, str) and task_file:
192
+ task_path = Path(task_file)
193
+ if not task_path.is_absolute():
194
+ task_path = path.parent / task_path
195
+ _sync_task_evidence(task_path, scenario_id, confirmed_by, evidence, owner)
196
+
197
+
198
+ def _sync_task_evidence(task_file: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "") -> None:
199
+ from cf_acceptance_evidence import sync_task_evidence
200
+ sync_task_evidence(task_file, scenario_id, confirmed_by, evidence, owner)
201
+
202
+
203
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
204
+ parser = argparse.ArgumentParser()
205
+ parser.add_argument("--task-file", required=True)
206
+ parser.add_argument("--output", default="")
207
+ parser.add_argument("--verify-plan", action="store_true")
208
+ parser.add_argument("--task-dir", default="")
209
+ parser.add_argument("--record-manual", action="store_true")
210
+ parser.add_argument("--scenario-id", default="")
211
+ parser.add_argument("--confirmed-by", default="")
212
+ parser.add_argument("--evidence", default="")
213
+ args = parser.parse_args(argv)
214
+ try:
215
+ if args.record_manual:
216
+ if not args.output or not args.scenario_id or not args.evidence:
217
+ raise ValueError("--record-manual requires --output, --scenario-id and --evidence")
218
+ record_manual_evidence(args.output, args.scenario_id, args.confirmed_by, args.evidence)
219
+ stdout.write(json.dumps({"ok": True, "scenario": args.scenario_id}, ensure_ascii=False))
220
+ return 0
221
+ if args.verify_plan:
222
+ if not args.task_dir:
223
+ raise ValueError("--verify-plan requires --task-dir")
224
+ from cf_spec_context import load_context
225
+ from cf_spec_gate import result_to_data, validate_plan_coverage, validate_stage
226
+
227
+ context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
228
+ gate = validate_stage(context, "plan")
229
+ coverage = validate_plan_coverage(context, args.task_file)
230
+ result = {
231
+ "decision": "block" if gate.decision == "block" or coverage.decision == "block" else "pass",
232
+ "gate": result_to_data(gate),
233
+ "coverage": result_to_data(coverage),
234
+ }
235
+ stdout.write(json.dumps(result, ensure_ascii=False))
236
+ return 0 if result["decision"] == "pass" else 3
237
+ if not args.output:
238
+ raise ValueError("--output is required unless --verify-plan is used")
239
+ manifest = write_manifest(args.task_file, args.output)
240
+ stdout.write(json.dumps({"ok": True, "scenarios": len(manifest["scenarios"])}, ensure_ascii=False))
241
+ return 0
242
+ except (OSError, ValueError) as exc:
243
+ stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
244
+ return 2
245
+
246
+
247
+ if __name__ == "__main__":
248
+ raise SystemExit(main())
@@ -0,0 +1,192 @@
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 sys
10
+ from typing import IO, Mapping, Optional, Sequence
11
+
12
+ from cf_exec_base import execution_key, run_command, invalidate_executions
13
+ from cf_acceptance_schema import load_manifest, validate_execution_baseline
14
+ from cf_acceptance_evidence import persist_results
15
+
16
+
17
+ def _run(
18
+ item: Mapping[str, object], root: Path, include_e2e: bool = False, deadline: Optional[float] = None,
19
+ only_e2e: bool = False,
20
+ ) -> dict[str, object]:
21
+ scenario_id = str(item.get("id", "unknown"))
22
+ kind = str(item.get("kind", "functional"))
23
+ if only_e2e and kind != "e2e":
24
+ return {"id": scenario_id, "kind": kind, "status": "not_included"}
25
+ if kind == "manual":
26
+ return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
27
+ if kind == "e2e" and not include_e2e and not only_e2e:
28
+ return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
29
+ command = item.get("command")
30
+ if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
31
+ return {"id": scenario_id, "kind": kind, "status": "not_configured"}
32
+ timeout = item.get("timeout", 60)
33
+ timeout_value = float(timeout) if isinstance(timeout, (int, float)) else 60.0
34
+ cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
35
+ outcome = run_command(command, str(root / cwd), timeout_value, deadline)
36
+ if outcome["status"] == "deadline_exceeded":
37
+ return {"id": scenario_id, "kind": kind, "status": "incomplete", "error": "deadline_exceeded"}
38
+ if outcome["status"] == "timeout":
39
+ return {"id": scenario_id, "kind": kind, "status": "failed", "error": "timeout",
40
+ "output": str(outcome.get("stdout", ""))}
41
+ if outcome["status"] == "spawn_error":
42
+ return {"id": scenario_id, "kind": kind, "status": "failed", "error": str(outcome.get("stderr", ""))}
43
+ if outcome["returncode"] == 0:
44
+ return {"id": scenario_id, "kind": kind, "status": "passed", "exit_code": 0}
45
+ output = (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-4000:]
46
+ return {"id": scenario_id, "kind": kind, "status": "failed", "exit_code": outcome["returncode"], "output": output}
47
+
48
+
49
+ def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
50
+ by_id = {str(item.get("id")): item for item in items}
51
+ pending = set(by_id)
52
+ ordered: list[Mapping[str, object]] = []
53
+ while pending:
54
+ ready: list[str] = []
55
+ for item_id in by_id:
56
+ if item_id not in pending:
57
+ continue
58
+ deps = by_id[item_id].get("depends_on", [])
59
+ dep_ids = [str(dep) for dep in deps] if isinstance(deps, list) else []
60
+ unknown = [dep for dep in dep_ids if dep not in by_id]
61
+ if unknown:
62
+ raise ValueError(f"acceptance scenario dependency missing: {', '.join(unknown)}")
63
+ if all(dep not in pending for dep in dep_ids):
64
+ ready.append(item_id)
65
+ if not ready:
66
+ raise ValueError("acceptance scenario dependency cycle")
67
+ ordered.extend(by_id[item_id] for item_id in ready)
68
+ pending.difference_update(ready)
69
+ return ordered
70
+
71
+
72
+ def _executable(items: list[Mapping[str, object]]) -> bool:
73
+ """Draft completeness vs execution gate: at least one scenario must be
74
+ executable — a functional scenario with a registered command, or a
75
+ manual / e2e scenario with its own confirmation/deferral path. Zero
76
+ executable scenarios must block execution acceptance, never pass it."""
77
+ for item in items:
78
+ command = item.get("command")
79
+ if isinstance(command, list) and command and all(isinstance(value, str) for value in command):
80
+ return True
81
+ if str(item.get("kind", "")) in ("manual", "e2e"):
82
+ return True
83
+ return False
84
+
85
+
86
+ def _owned(items: list[Mapping[str, object]], owner: str) -> list[Mapping[str, object]]:
87
+ """Keep scenarios owned by the active TASK; ownerless legacy scenarios stay
88
+ included so old manifests keep working. Cross-TASK depends_on edges are
89
+ pruned: single-TASK ordering is enforced here, whole-demand ordering at
90
+ the final (archive/E2E) gate."""
91
+ if not owner:
92
+ return items
93
+ kept = [
94
+ item for item in items
95
+ if not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") == owner
96
+ ]
97
+ kept_ids = {str(item.get("id")) for item in kept}
98
+ pruned: list[Mapping[str, object]] = []
99
+ for item in kept:
100
+ if not isinstance(item, dict):
101
+ pruned.append(item)
102
+ continue
103
+ deps = item.get("depends_on")
104
+ if isinstance(deps, list):
105
+ item = {**item, "depends_on": [dep for dep in deps if str(dep) in kept_ids]}
106
+ pruned.append(item)
107
+ return pruned
108
+
109
+
110
+ def _run_unique(
111
+ ordered: list[Mapping[str, object]], root: Path, include_e2e: bool, deadline: Optional[float] = None,
112
+ only_e2e: bool = False,
113
+ ) -> list[dict[str, object]]:
114
+ """Reuse identical execution semantics within a dependency-free batch."""
115
+ cache: dict[str, Mapping[str, object]] = {}
116
+ results: list[dict[str, object]] = []
117
+ for item in ordered:
118
+ scenario_id = str(item.get("id", "unknown"))
119
+ command = item.get("command")
120
+ key: Optional[str] = None
121
+ if isinstance(command, list) and command and all(isinstance(value, str) for value in command):
122
+ key = execution_key(command, str(root / str(item.get("cwd", "."))), float(item.get("timeout", 60)))
123
+ key += json.dumps([item.get("kind", "functional"), include_e2e, only_e2e])
124
+ if item.get("depends_on"):
125
+ cache.clear()
126
+ invalidate_executions()
127
+ if key is not None and key in cache:
128
+ shared = dict(cache[key])
129
+ shared["id"] = scenario_id
130
+ results.append(shared)
131
+ continue
132
+ result = _run(item, root, include_e2e, deadline, only_e2e)
133
+ if key is not None:
134
+ cache[key] = result
135
+ results.append(result)
136
+ return results
137
+
138
+
139
+ def run_manifest(
140
+ manifest_file: str,
141
+ root: str,
142
+ write_evidence: bool = False,
143
+ include_e2e: bool = False,
144
+ owner: str = "",
145
+ deadline: Optional[float] = None,
146
+ only_e2e: bool = False,
147
+ ) -> dict[str, object]:
148
+ data = load_manifest(Path(manifest_file))
149
+ validate_execution_baseline(Path(manifest_file), data)
150
+ scenarios = data["scenarios"]
151
+ _ordered(scenarios) # Validate the complete DAG before owner filtering.
152
+ items = _owned(scenarios, owner)
153
+ ordered = _ordered(items)
154
+ results = _run_unique(ordered, Path(root), include_e2e or only_e2e, deadline, only_e2e)
155
+ if write_evidence:
156
+ persist_results(Path(manifest_file), data, results)
157
+ if not _executable(items):
158
+ return {"decision": "block", "results": results, "error": "no_executable_scenarios"}
159
+ allowed = ("passed", "manual_pending", "e2e_deferred", "not_included")
160
+ decision = "pass" if all(item["status"] in allowed for item in results) else "block"
161
+ error = ""
162
+ if decision == "block":
163
+ pending = sorted({str(item["id"]) for item in results if item["status"] == "incomplete"})
164
+ failed = sorted({str(item["id"]) for item in results if item["status"] not in (*allowed, "incomplete")})
165
+ error = ",".join((["incomplete:" + ",".join(pending)] if pending else []) + (["failed:" + ",".join(failed)] if failed else [])) or "acceptance scenario failed"
166
+ outcome: dict[str, object] = {"decision": decision, "results": results}
167
+ if error:
168
+ outcome["error"] = error
169
+ return outcome
170
+
171
+
172
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
173
+ parser = argparse.ArgumentParser()
174
+ parser.add_argument("--manifest", required=True)
175
+ parser.add_argument("--root", required=True)
176
+ parser.add_argument("--write-evidence", action="store_true")
177
+ parser.add_argument("--include-e2e", action="store_true", help="Execute E2E scenarios (deferred by default)")
178
+ parser.add_argument("--only-e2e", action="store_true", help="Execute only E2E scenarios; functional/manual are reported not_included")
179
+ parser.add_argument("--owner", default="", help="Only execute scenarios owned by this TASK (plus ownerless legacy ones)")
180
+ parser.add_argument("--deadline", type=float, default=0.0, help="Absolute monotonic deadline propagated from the entry gate (0 = unbounded)")
181
+ args = parser.parse_args(argv)
182
+ try:
183
+ result = run_manifest(args.manifest, args.root, args.write_evidence, args.include_e2e, args.owner, args.deadline or None, args.only_e2e)
184
+ stdout.write(json.dumps(result, ensure_ascii=False))
185
+ return 0 if result["decision"] == "pass" else 3
186
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
187
+ stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
188
+ return 2
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main())
@@ -0,0 +1,83 @@
1
+ """Validated acceptance records shared by execution and lifecycle gates."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Mapping
8
+
9
+
10
+ def verified_evidence(row: Mapping[str, object]) -> bool:
11
+ evidence = row.get("evidence")
12
+ if row.get("status") != "verified" or not isinstance(evidence, dict):
13
+ return False
14
+ if row.get("kind") != "manual":
15
+ return evidence.get("status") == "passed" and evidence.get("exit_code") == 0
16
+ author, detail = evidence.get("confirmed_by"), evidence.get("evidence")
17
+ agents = {"agent", "assistant", "codex", "claude", "opencode", "costrict"}
18
+ return (isinstance(author, str) and bool(author.strip())
19
+ and author.strip().lower().split(":", 1)[0] not in agents
20
+ and isinstance(detail, str) and bool(detail.strip()))
21
+
22
+
23
+ def validate_scenarios(value: object) -> list[dict[str, object]]:
24
+ if not isinstance(value, list):
25
+ raise ValueError("invalid manifest scenarios: expected list")
26
+ seen: set[str] = set()
27
+ for item in value:
28
+ if not isinstance(item, dict):
29
+ raise ValueError("invalid scenario: expected object")
30
+ scenario_id = item.get("id")
31
+ if not isinstance(scenario_id, str) or not scenario_id.strip():
32
+ raise ValueError("scenario id missing")
33
+ if scenario_id in seen:
34
+ raise ValueError(f"duplicate scenario id: {scenario_id}")
35
+ seen.add(scenario_id)
36
+ for field in ("kind", "owner", "cwd"):
37
+ if field in item and not isinstance(item[field], str):
38
+ raise ValueError(f"invalid scenario {scenario_id}:{field}")
39
+ command = item.get("command")
40
+ if command is not None and (not isinstance(command, list) or not all(isinstance(x, str) for x in command)):
41
+ raise ValueError(f"invalid scenario {scenario_id}:command")
42
+ timeout = item.get("timeout", 60)
43
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
44
+ raise ValueError(f"invalid scenario {scenario_id}:timeout")
45
+ deps = item.get("depends_on", [])
46
+ if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
47
+ raise ValueError(f"invalid scenario {scenario_id}:depends_on")
48
+ if "runs" in item and not isinstance(item["runs"], list):
49
+ raise ValueError(f"invalid scenario {scenario_id}:runs")
50
+ revision = item.get("revision", 0)
51
+ if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
52
+ raise ValueError(f"invalid scenario {scenario_id}:revision")
53
+ for item in value:
54
+ unknown = set(item.get("depends_on", [])) - seen
55
+ if unknown:
56
+ raise ValueError(f"acceptance scenario dependency missing: {', '.join(sorted(unknown))}")
57
+ return value
58
+
59
+
60
+ def load_manifest(path: Path) -> dict[str, object]:
61
+ data = json.loads(path.read_text(encoding="utf-8"))
62
+ if not isinstance(data, dict):
63
+ raise ValueError("invalid acceptance manifest: expected object")
64
+ validate_scenarios(data.get("scenarios"))
65
+ if "task_file" in data and not isinstance(data["task_file"], str):
66
+ raise ValueError("invalid acceptance manifest: task_file")
67
+ return data
68
+
69
+
70
+ def validate_execution_baseline(path: Path, data: Mapping[str, object]) -> None:
71
+ """Legacy standalone manifests remain usable; locked plans must match."""
72
+ task_name = data.get("task_file")
73
+ if not isinstance(task_name, str) or not task_name:
74
+ return
75
+ task = Path(task_name)
76
+ task = task if task.is_absolute() else path.parent / task
77
+ if not task.is_file():
78
+ raise ValueError(f"acceptance task file missing: {task}")
79
+ if "## Acceptance Coverage" in task.read_text(encoding="utf-8"):
80
+ from cf_acceptance_manifest import validate_manifest
81
+ valid, reason = validate_manifest(str(task), str(path))
82
+ if not valid:
83
+ raise ValueError(reason)
@@ -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
 
@@ -12,6 +13,12 @@ _effective_mapping_cache: dict = {}
12
13
  _ext_set_cache: dict = {}
13
14
 
14
15
 
16
+ def project_instruction_file(project_root: str, platform: str = "") -> str:
17
+ """Select the installed platform's instruction file, with legacy fallback."""
18
+ names = ("CLAUDE.md", "AGENTS.md") if platform in ("claude", "costrict") else ("AGENTS.md", "CLAUDE.md")
19
+ return next((name for name in names if os.path.isfile(os.path.join(project_root, name))), names[0])
20
+
21
+
15
22
  def load_config(project_root: str) -> dict:
16
23
  config_path = os.path.join(project_root, ".code-flow", "config.yml")
17
24
  if not os.path.exists(config_path):
@@ -665,6 +672,25 @@ def _log(msg: str) -> None:
665
672
  print(msg, file=sys.stderr)
666
673
 
667
674
 
675
+ _PROCESS_START = time.monotonic()
676
+
677
+
678
+ def timing_log(name: str) -> None:
679
+ """Emit process-elapsed ms to stderr only when CF_DEBUG=1 (diagnostics).
680
+
681
+ Never touches stdout, so the hook JSON protocol is unaffected; a default
682
+ run pays one env lookup and no IO.
683
+ """
684
+ if os.environ.get("CF_DEBUG") == "1":
685
+ _log(f"{name}: {int((time.monotonic() - _PROCESS_START) * 1000)}ms")
686
+
687
+
688
+ def phase_timing(name: str, started: float) -> None:
689
+ """Emit a named phase duration to stderr when CF_DEBUG=1."""
690
+ if os.environ.get("CF_DEBUG") == "1":
691
+ _log(f"cf_phase {name}: {int((time.monotonic() - started) * 1000)}ms")
692
+
693
+
668
694
  def ensure_utf8_io() -> None:
669
695
  """Force stdin/stdout/stderr to UTF-8 so Windows hooks don't mojibake.
670
696