@michaelxwb/code-flow 0.6.2 → 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/start.md +18 -17
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
- 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 +18 -17
- 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/start.md +18 -17
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
- 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/start.md +18 -17
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
- 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 +85 -36
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +6 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +8 -4
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +178 -54
- package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
- package/src/core/code-flow/scripts/cf_spec_router.py +2 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +98 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +62 -27
- 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 +9 -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,100 @@
|
|
|
1
|
+
"""Append execution history without erasing the user's acceptance contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Mapping, Sequence
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
STATES = frozenset(("planned", "pending", "tbd", "verified", "unverified", "failed", "incomplete", "not_configured", "e2e_deferred", "manual_pending"))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def scenario_line(line: str, scenario_id: str) -> bool:
|
|
15
|
+
return bool(re.match(rf"^\s*(?:\|\s*|[-*]\s+){re.escape(scenario_id)}(?=\s*[:|])", line))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def line_status(line: str, scenario_id: str) -> str:
|
|
19
|
+
if not scenario_line(line, scenario_id):
|
|
20
|
+
return ""
|
|
21
|
+
if line.lstrip().startswith("|"):
|
|
22
|
+
cells = [cell.strip().lower() for cell in line.strip().strip("|").split("|")]
|
|
23
|
+
return next((cell for cell in reversed(cells) if cell in STATES), "")
|
|
24
|
+
match = re.match(r"^\s*[-*]\s+[^:]+:\s*([a-z0-9_]+)\b", line, re.I)
|
|
25
|
+
return match.group(1).lower() if match and match.group(1).lower() in STATES else ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _status_line(line: str, scenario_id: str, status: str) -> str:
|
|
29
|
+
if not scenario_line(line, scenario_id):
|
|
30
|
+
return line
|
|
31
|
+
if line.lstrip().startswith("|"):
|
|
32
|
+
cells = line.split("|")
|
|
33
|
+
for index in range(len(cells) - 1, 0, -1):
|
|
34
|
+
if cells[index].strip().lower() in STATES:
|
|
35
|
+
cells[index] = f" {status} "
|
|
36
|
+
return "|".join(cells)
|
|
37
|
+
return line.rstrip().rstrip("|") + f"| {status} |"
|
|
38
|
+
prefix = r"(^\s*[-*]\s+[^:]+:\s*)"
|
|
39
|
+
if line_status(line, scenario_id):
|
|
40
|
+
return re.sub(prefix + r"(?:" + "|".join(sorted(STATES)) + r")\b", rf"\g<1>{status}", line, count=1, flags=re.I)
|
|
41
|
+
return re.sub(prefix, rf"\g<1>{status} — ", line, count=1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _patch_section(section: str, scenario_id: str, status: str, record: str) -> str:
|
|
45
|
+
contract = re.search(r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
|
|
46
|
+
if contract:
|
|
47
|
+
body = "\n".join(_status_line(line, scenario_id, status) for line in contract.group(1).splitlines()) + "\n"
|
|
48
|
+
section = section[:contract.start(1)] + body + section[contract.end(1):]
|
|
49
|
+
evidence = re.search(r"(?ms)^### Acceptance Evidence[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
|
|
50
|
+
if evidence:
|
|
51
|
+
body = evidence.group(1)
|
|
52
|
+
if record not in body.splitlines():
|
|
53
|
+
body = body.rstrip() + "\n" + record + "\n\n"
|
|
54
|
+
section = section[:evidence.start(1)] + body + section[evidence.end(1):]
|
|
55
|
+
else:
|
|
56
|
+
section = section.rstrip() + "\n\n### Acceptance Evidence\n" + record + "\n"
|
|
57
|
+
return section
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sync_task_evidence(task: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "", status: str = "verified") -> None:
|
|
61
|
+
text = task.read_text(encoding="utf-8")
|
|
62
|
+
task_pattern = re.escape(owner) if owner else r"TASK-\d+"
|
|
63
|
+
match = re.search(rf"(?ms)^##\s+{task_pattern}:.*?(?=^## |\Z)", text)
|
|
64
|
+
if match is None:
|
|
65
|
+
raise ValueError(f"acceptance owner missing: {owner}")
|
|
66
|
+
record = f"- {scenario_id}: {status} — {evidence} (confirmed_by: {confirmed_by})"
|
|
67
|
+
section = _patch_section(match.group(0), scenario_id, status, record)
|
|
68
|
+
text = text[:match.start()] + section + text[match.end():]
|
|
69
|
+
coverage = re.search(r"(?ms)^## Acceptance Coverage[^\n]*\n(.*?)(?=^## |\Z)", text)
|
|
70
|
+
if coverage:
|
|
71
|
+
body = "\n".join(_status_line(line, scenario_id, status) for line in coverage.group(1).splitlines()) + "\n"
|
|
72
|
+
text = text[:coverage.start(1)] + body + text[coverage.end(1):]
|
|
73
|
+
from cf_spec_context import _atomic_text
|
|
74
|
+
_atomic_text(task, text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def persist_results(path: Path, data: dict[str, object], results: Sequence[Mapping[str, object]]) -> None:
|
|
78
|
+
rows = {row["id"]: row for row in data["scenarios"]}
|
|
79
|
+
run_id, executed_at = uuid4().hex, datetime.now(timezone.utc).isoformat()
|
|
80
|
+
updates = []
|
|
81
|
+
for result in results:
|
|
82
|
+
state = str(result["status"])
|
|
83
|
+
if state in ("not_included", "manual_pending"):
|
|
84
|
+
continue
|
|
85
|
+
row = rows[result["id"]]
|
|
86
|
+
row["revision"] = int(row.get("revision", 0)) + 1
|
|
87
|
+
status = "verified" if state == "passed" else state
|
|
88
|
+
row["status"] = status
|
|
89
|
+
record = {**result, "revision": row["revision"], "run_id": run_id, "executed_at": executed_at}
|
|
90
|
+
row["evidence"] = record
|
|
91
|
+
row.setdefault("runs", []).append(record)
|
|
92
|
+
updates.append((row, record, status))
|
|
93
|
+
from cf_spec_context import _atomic_text
|
|
94
|
+
_atomic_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
95
|
+
task_name = data.get("task_file")
|
|
96
|
+
if isinstance(task_name, str) and task_name:
|
|
97
|
+
task = Path(task_name) if Path(task_name).is_absolute() else path.parent / task_name
|
|
98
|
+
for row, record, status in updates:
|
|
99
|
+
detail = f"automated command {record['status']}; run_id={run_id}"
|
|
100
|
+
sync_task_evidence(task, str(row["id"]), "runner", detail, str(row.get("owner", "")), status)
|
|
@@ -6,14 +6,19 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
|
+
import shlex
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
import re
|
|
11
12
|
import sys
|
|
12
13
|
from typing import IO, Mapping, Optional, Sequence
|
|
13
14
|
|
|
15
|
+
from cf_acceptance_schema import load_manifest, validate_scenarios
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
_ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
|
|
16
19
|
|
|
20
|
+
_MANIFEST_SCHEMA = 2
|
|
21
|
+
|
|
17
22
|
|
|
18
23
|
def _kind(level: str) -> str:
|
|
19
24
|
"""Map test level to execution kind.
|
|
@@ -30,8 +35,37 @@ def _kind(level: str) -> str:
|
|
|
30
35
|
return "functional"
|
|
31
36
|
|
|
32
37
|
|
|
33
|
-
def
|
|
34
|
-
|
|
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
|
+
]
|
|
35
69
|
payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
36
70
|
return hashlib.sha256(payload).hexdigest()
|
|
37
71
|
|
|
@@ -39,7 +73,7 @@ def _manifest_hash(rows: list[dict[str, str]]) -> str:
|
|
|
39
73
|
def extract_manifest(task_file: str) -> dict[str, object]:
|
|
40
74
|
path = Path(task_file)
|
|
41
75
|
text = path.read_text(encoding="utf-8")
|
|
42
|
-
rows: list[dict[str,
|
|
76
|
+
rows: list[dict[str, object]] = []
|
|
43
77
|
in_table = False
|
|
44
78
|
seen: set[str] = set()
|
|
45
79
|
for line in text.splitlines():
|
|
@@ -54,13 +88,29 @@ def extract_manifest(task_file: str) -> dict[str, object]:
|
|
|
54
88
|
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
55
89
|
continue
|
|
56
90
|
fields = [item.strip() for item in match.group(2).split("|")]
|
|
57
|
-
if
|
|
58
|
-
|
|
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)}")
|
|
59
95
|
seen.add(match.group(1))
|
|
60
|
-
|
|
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
|
+
})
|
|
61
110
|
if not rows:
|
|
62
111
|
raise ValueError("Acceptance Coverage 缺失或为空")
|
|
63
|
-
|
|
112
|
+
validate_scenarios(rows)
|
|
113
|
+
return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
|
|
64
114
|
|
|
65
115
|
|
|
66
116
|
def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
@@ -75,24 +125,43 @@ def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
|
75
125
|
return manifest
|
|
76
126
|
|
|
77
127
|
|
|
128
|
+
_IMMUTABLE_SCENARIO_FIELDS = (
|
|
129
|
+
"id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
78
133
|
def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
|
|
79
134
|
try:
|
|
80
|
-
manifest =
|
|
135
|
+
manifest = load_manifest(Path(manifest_file))
|
|
81
136
|
expected = extract_manifest(task_file)
|
|
82
137
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
83
138
|
return False, f"acceptance_manifest_invalid: {exc}"
|
|
84
|
-
if manifest.get("schema") !=
|
|
139
|
+
if manifest.get("schema") != _MANIFEST_SCHEMA or manifest.get("task_sha256") != expected["task_sha256"]:
|
|
85
140
|
return False, "acceptance_manifest_drift"
|
|
86
|
-
|
|
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
|
+
}
|
|
87
151
|
required = {item["id"] for item in expected["scenarios"]}
|
|
88
|
-
if
|
|
152
|
+
if set(stored) != required:
|
|
89
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}"
|
|
90
159
|
text = Path(task_file).read_text(encoding="utf-8")
|
|
91
160
|
for scenario in expected["scenarios"]:
|
|
92
161
|
owner = scenario["owner"]
|
|
93
162
|
section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
94
|
-
refs =
|
|
95
|
-
if scenario["id"] not in refs:
|
|
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)):
|
|
96
165
|
return False, "acceptance_manifest_owner_missing"
|
|
97
166
|
return True, ""
|
|
98
167
|
|
|
@@ -101,7 +170,7 @@ def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: s
|
|
|
101
170
|
if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
|
|
102
171
|
raise ValueError("manual evidence must be confirmed by a user")
|
|
103
172
|
path = Path(manifest_file)
|
|
104
|
-
data =
|
|
173
|
+
data = load_manifest(path)
|
|
105
174
|
scenarios = data.get("scenarios")
|
|
106
175
|
if not isinstance(scenarios, list):
|
|
107
176
|
raise ValueError("invalid manifest scenarios")
|
|
@@ -127,28 +196,8 @@ def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: s
|
|
|
127
196
|
|
|
128
197
|
|
|
129
198
|
def _sync_task_evidence(task_file: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "") -> None:
|
|
130
|
-
|
|
131
|
-
|
|
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")
|
|
199
|
+
from cf_acceptance_evidence import sync_task_evidence
|
|
200
|
+
sync_task_evidence(task_file, scenario_id, confirmed_by, evidence, owner)
|
|
152
201
|
|
|
153
202
|
|
|
154
203
|
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
@@ -6,17 +6,25 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
import subprocess
|
|
10
9
|
import sys
|
|
11
10
|
from typing import IO, Mapping, Optional, Sequence
|
|
12
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
|
|
13
15
|
|
|
14
|
-
|
|
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]:
|
|
15
21
|
scenario_id = str(item.get("id", "unknown"))
|
|
16
22
|
kind = str(item.get("kind", "functional"))
|
|
23
|
+
if only_e2e and kind != "e2e":
|
|
24
|
+
return {"id": scenario_id, "kind": kind, "status": "not_included"}
|
|
17
25
|
if kind == "manual":
|
|
18
26
|
return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
|
|
19
|
-
if kind == "e2e" and not include_e2e:
|
|
27
|
+
if kind == "e2e" and not include_e2e and not only_e2e:
|
|
20
28
|
return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
|
|
21
29
|
command = item.get("command")
|
|
22
30
|
if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
|
|
@@ -24,13 +32,18 @@ def _run(item: Mapping[str, object], root: Path, include_e2e: bool = False) -> d
|
|
|
24
32
|
timeout = item.get("timeout", 60)
|
|
25
33
|
timeout_value = float(timeout) if isinstance(timeout, (int, float)) else 60.0
|
|
26
34
|
cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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}
|
|
34
47
|
|
|
35
48
|
|
|
36
49
|
def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
|
|
@@ -56,35 +69,104 @@ def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
|
|
|
56
69
|
return ordered
|
|
57
70
|
|
|
58
71
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
65
153
|
ordered = _ordered(items)
|
|
66
|
-
results =
|
|
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"
|
|
154
|
+
results = _run_unique(ordered, Path(root), include_e2e or only_e2e, deadline, only_e2e)
|
|
70
155
|
if write_evidence:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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}
|
|
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
|
|
88
170
|
|
|
89
171
|
|
|
90
172
|
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
@@ -93,9 +175,12 @@ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) ->
|
|
|
93
175
|
parser.add_argument("--root", required=True)
|
|
94
176
|
parser.add_argument("--write-evidence", action="store_true")
|
|
95
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)")
|
|
96
181
|
args = parser.parse_args(argv)
|
|
97
182
|
try:
|
|
98
|
-
result = run_manifest(args.manifest, args.root, args.write_evidence, args.include_e2e)
|
|
183
|
+
result = run_manifest(args.manifest, args.root, args.write_evidence, args.include_e2e, args.owner, args.deadline or None, args.only_e2e)
|
|
99
184
|
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
100
185
|
return 0 if result["decision"] == "pass" else 3
|
|
101
186
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
@@ -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)
|
|
@@ -13,6 +13,12 @@ _effective_mapping_cache: dict = {}
|
|
|
13
13
|
_ext_set_cache: dict = {}
|
|
14
14
|
|
|
15
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
|
+
|
|
16
22
|
def load_config(project_root: str) -> dict:
|
|
17
23
|
config_path = os.path.join(project_root, ".code-flow", "config.yml")
|
|
18
24
|
if not os.path.exists(config_path):
|