@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.
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-task/archive.md +5 -5
- package/src/adapters/claude/commands/cf-task/block.md +15 -9
- package/src/adapters/claude/commands/cf-task/graph.md +8 -1
- package/src/adapters/claude/commands/cf-task/note.md +1 -4
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +22 -22
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/claude/commands/cf-validate.md +12 -13
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
- package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
- package/src/adapters/costrict/commands/cf-task/block.md +15 -9
- package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
- package/src/adapters/costrict/commands/cf-task/note.md +1 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +22 -22
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/costrict/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
- package/src/adapters/opencode/commands/cf-task/block.md +15 -9
- package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
- package/src/adapters/opencode/commands/cf-task/note.md +1 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +22 -22
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/opencode/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
- package/src/cli.js +177 -63
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +26 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
- 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 +85 -9
- package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
- package/src/core/code-flow/scripts/cf_task_state.py +3 -0
- package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
- package/src/core/code-flow/scripts/cf_validation.py +81 -0
- package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
- 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
|
|