@michaelxwb/code-flow 0.6.2 → 0.6.4
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/README.md +2 -2
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -0
- package/src/adapters/claude/commands/cf-spec.md +7 -3
- 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 +2 -2
- package/src/adapters/claude/commands/cf-task/start.md +96 -25
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/claude/commands/cf-validate.md +15 -13
- package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
- 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-plan/SKILL.md +2 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
- package/src/adapters/costrict/commands/cf-init.md +1 -0
- package/src/adapters/costrict/commands/cf-spec.md +7 -3
- 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 +2 -2
- package/src/adapters/costrict/commands/cf-task/start.md +96 -25
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/costrict/commands/cf-validate.md +15 -13
- package/src/adapters/opencode/commands/cf-init.md +9 -4
- package/src/adapters/opencode/commands/cf-spec.md +6 -2
- 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 +2 -2
- package/src/adapters/opencode/commands/cf-task/start.md +96 -25
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/opencode/commands/cf-validate.md +15 -13
- package/src/adapters/opencode/opencode.json +1 -2
- package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
- package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
- package/src/cli.js +228 -64
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
- 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 +34 -1
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
- package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
- package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
- package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
- 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 +128 -61
- package/src/core/code-flow/scripts/cf_task_index.py +148 -0
- package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
- 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
- package/src/core/code-flow/validation.yml +5 -0
|
@@ -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
|
-
from typing import IO, Mapping, Optional, Sequence
|
|
13
|
+
from typing import IO, Callable, Mapping, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from cf_acceptance_schema import load_manifest, validate_scenarios
|
|
13
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.
|
|
@@ -21,25 +26,70 @@ def _kind(level: str) -> str:
|
|
|
21
26
|
- functional: auto-executed in Done Gate (unit, integration)
|
|
22
27
|
- e2e: deferred until explicit --verify-e2e (needs external dependencies)
|
|
23
28
|
- manual: user confirmation required, never auto-executed
|
|
29
|
+
|
|
30
|
+
层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
|
|
31
|
+
取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
|
|
24
32
|
"""
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
head = level.strip().lower()
|
|
34
|
+
for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
|
|
35
|
+
head = head.split(separator, 1)[0]
|
|
36
|
+
head = head.strip()
|
|
37
|
+
if head in ("manual", "手工", "手动"):
|
|
27
38
|
return "manual"
|
|
28
|
-
if
|
|
29
|
-
return "e2e"
|
|
39
|
+
if head in ("e2e", "端到端", "end"):
|
|
40
|
+
return "e2e"
|
|
30
41
|
return "functional"
|
|
31
42
|
|
|
32
43
|
|
|
33
|
-
def
|
|
34
|
-
|
|
44
|
+
def _parse_command(cell: str, scenario_id: str) -> Optional[list[str]]:
|
|
45
|
+
"""Parse the optional 命令 column: argv JSON array preferred, else shell words.
|
|
46
|
+
|
|
47
|
+
Empty / `-` / `planned` → None (not yet registered, not executable).
|
|
48
|
+
"""
|
|
49
|
+
text = (cell or "").strip()
|
|
50
|
+
if not text or text in {"-", "planned", "pending", "TBD", "tbd"}:
|
|
51
|
+
return None
|
|
52
|
+
if text.startswith("["):
|
|
53
|
+
try:
|
|
54
|
+
value = json.loads(text)
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
raise ValueError(f"{scenario_id} 命令列不是合法 argv JSON: {exc}") from exc
|
|
57
|
+
if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
|
|
58
|
+
raise ValueError(f"{scenario_id} 命令列必须是 argv 字符串数组")
|
|
59
|
+
return list(value)
|
|
60
|
+
return shlex.split(text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_float(cell: str, default: float) -> float:
|
|
64
|
+
try:
|
|
65
|
+
return float((cell or "").strip() or default)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _manifest_hash(rows: list[dict[str, object]]) -> str:
|
|
71
|
+
immutable = [
|
|
72
|
+
{key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on")}
|
|
73
|
+
for item in rows
|
|
74
|
+
]
|
|
35
75
|
payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
36
76
|
return hashlib.sha256(payload).hexdigest()
|
|
37
77
|
|
|
38
78
|
|
|
39
|
-
def
|
|
79
|
+
def _legacy_kind(level: str) -> str:
|
|
80
|
+
"""v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
|
|
81
|
+
normalized = level.strip().lower()
|
|
82
|
+
if normalized == "manual":
|
|
83
|
+
return "manual"
|
|
84
|
+
if normalized == "e2e":
|
|
85
|
+
return "e2e"
|
|
86
|
+
return "functional"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_rows(task_file: str, kind_fn: Callable[[str], str]) -> dict[str, object]:
|
|
40
90
|
path = Path(task_file)
|
|
41
91
|
text = path.read_text(encoding="utf-8")
|
|
42
|
-
rows: list[dict[str,
|
|
92
|
+
rows: list[dict[str, object]] = []
|
|
43
93
|
in_table = False
|
|
44
94
|
seen: set[str] = set()
|
|
45
95
|
for line in text.splitlines():
|
|
@@ -54,13 +104,33 @@ def extract_manifest(task_file: str) -> dict[str, object]:
|
|
|
54
104
|
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
55
105
|
continue
|
|
56
106
|
fields = [item.strip() for item in match.group(2).split("|")]
|
|
57
|
-
if
|
|
58
|
-
|
|
107
|
+
if match.group(1) in seen:
|
|
108
|
+
raise ValueError(f"duplicate scenario id: {match.group(1)}")
|
|
109
|
+
if len(fields) < 5:
|
|
110
|
+
raise ValueError(f"Acceptance Coverage missing columns: {match.group(1)}")
|
|
59
111
|
seen.add(match.group(1))
|
|
60
|
-
|
|
112
|
+
depends = [item.strip() for item in (fields[8] if len(fields) > 8 else "").split(",") if item.strip()]
|
|
113
|
+
rows.append({
|
|
114
|
+
"id": match.group(1),
|
|
115
|
+
"source": fields[0],
|
|
116
|
+
"level": fields[1],
|
|
117
|
+
"kind": kind_fn(fields[1]),
|
|
118
|
+
"boundary": fields[2],
|
|
119
|
+
"owner": fields[3],
|
|
120
|
+
"status": fields[4],
|
|
121
|
+
"command": _parse_command(fields[5], match.group(1)) if len(fields) > 5 else None,
|
|
122
|
+
"cwd": (fields[6] if len(fields) > 6 else "").strip() or ".",
|
|
123
|
+
"timeout": _parse_float(fields[7], 60.0) if len(fields) > 7 else 60.0,
|
|
124
|
+
"depends_on": depends,
|
|
125
|
+
})
|
|
61
126
|
if not rows:
|
|
62
127
|
raise ValueError("Acceptance Coverage 缺失或为空")
|
|
63
|
-
|
|
128
|
+
validate_scenarios(rows)
|
|
129
|
+
return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = _kind) -> dict[str, object]:
|
|
133
|
+
return _extract_rows(task_file, kind_fn)
|
|
64
134
|
|
|
65
135
|
|
|
66
136
|
def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
@@ -75,24 +145,69 @@ def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
|
75
145
|
return manifest
|
|
76
146
|
|
|
77
147
|
|
|
148
|
+
_IMMUTABLE_SCENARIO_FIELDS = (
|
|
149
|
+
"id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _heal_legacy_kinds(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
|
|
154
|
+
"""旧版 `_kind` 精确匹配把带注解的 E2E 锁成 functional;任务内容未变时
|
|
155
|
+
原地升级 kind 并重锁哈希,保留 execution evidence 等运行态字段。"""
|
|
156
|
+
by_id: dict[str, dict[str, object]] = {}
|
|
157
|
+
scenarios = expected.get("scenarios")
|
|
158
|
+
if isinstance(scenarios, list):
|
|
159
|
+
for item in scenarios:
|
|
160
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
161
|
+
by_id[item["id"]] = item
|
|
162
|
+
current = manifest.get("scenarios")
|
|
163
|
+
if isinstance(current, list):
|
|
164
|
+
for item in current:
|
|
165
|
+
if not isinstance(item, dict):
|
|
166
|
+
continue
|
|
167
|
+
target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
|
|
168
|
+
if target is not None:
|
|
169
|
+
item["kind"] = target.get("kind")
|
|
170
|
+
manifest["task_sha256"] = expected["task_sha256"]
|
|
171
|
+
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
|
|
78
174
|
def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
|
|
79
175
|
try:
|
|
80
|
-
manifest =
|
|
176
|
+
manifest = load_manifest(Path(manifest_file))
|
|
81
177
|
expected = extract_manifest(task_file)
|
|
82
178
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
83
179
|
return False, f"acceptance_manifest_invalid: {exc}"
|
|
84
|
-
if manifest.get("schema") !=
|
|
180
|
+
if manifest.get("schema") != _MANIFEST_SCHEMA:
|
|
85
181
|
return False, "acceptance_manifest_drift"
|
|
86
|
-
|
|
182
|
+
if manifest.get("task_sha256") != expected["task_sha256"]:
|
|
183
|
+
legacy = extract_manifest(task_file, _legacy_kind)
|
|
184
|
+
if manifest.get("task_sha256") != legacy["task_sha256"]:
|
|
185
|
+
return False, "acceptance_manifest_drift"
|
|
186
|
+
_heal_legacy_kinds(Path(manifest_file), manifest, expected)
|
|
187
|
+
stored_task = manifest.get("task_file", "")
|
|
188
|
+
if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
|
|
189
|
+
return False, "acceptance_manifest_task_mismatch"
|
|
190
|
+
if not isinstance(manifest.get("scenarios"), list):
|
|
191
|
+
return False, "acceptance_manifest_scenarios_changed"
|
|
192
|
+
stored = {
|
|
193
|
+
item.get("id"): item
|
|
194
|
+
for item in manifest["scenarios"]
|
|
195
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
196
|
+
}
|
|
87
197
|
required = {item["id"] for item in expected["scenarios"]}
|
|
88
|
-
if
|
|
198
|
+
if set(stored) != required:
|
|
89
199
|
return False, "acceptance_manifest_scenarios_changed"
|
|
200
|
+
for item in expected["scenarios"]:
|
|
201
|
+
current = stored[item["id"]]
|
|
202
|
+
for field in _IMMUTABLE_SCENARIO_FIELDS:
|
|
203
|
+
if current.get(field) != item.get(field):
|
|
204
|
+
return False, f"acceptance_manifest_field_changed:{item['id']}:{field}"
|
|
90
205
|
text = Path(task_file).read_text(encoding="utf-8")
|
|
91
206
|
for scenario in expected["scenarios"]:
|
|
92
207
|
owner = scenario["owner"]
|
|
93
208
|
section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
94
|
-
refs =
|
|
95
|
-
if scenario["id"] not in refs:
|
|
209
|
+
refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)) if section else None
|
|
210
|
+
if not refs or scenario["id"] not in re.findall(r"\b[SEB]-\d+\b", refs.group(1)):
|
|
96
211
|
return False, "acceptance_manifest_owner_missing"
|
|
97
212
|
return True, ""
|
|
98
213
|
|
|
@@ -101,7 +216,7 @@ def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: s
|
|
|
101
216
|
if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
|
|
102
217
|
raise ValueError("manual evidence must be confirmed by a user")
|
|
103
218
|
path = Path(manifest_file)
|
|
104
|
-
data =
|
|
219
|
+
data = load_manifest(path)
|
|
105
220
|
scenarios = data.get("scenarios")
|
|
106
221
|
if not isinstance(scenarios, list):
|
|
107
222
|
raise ValueError("invalid manifest scenarios")
|
|
@@ -127,28 +242,8 @@ def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: s
|
|
|
127
242
|
|
|
128
243
|
|
|
129
244
|
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")
|
|
245
|
+
from cf_acceptance_evidence import sync_task_evidence
|
|
246
|
+
sync_task_evidence(task_file, scenario_id, confirmed_by, evidence, owner)
|
|
152
247
|
|
|
153
248
|
|
|
154
249
|
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):
|
|
@@ -49,6 +55,31 @@ def normalize_path(path: str) -> str:
|
|
|
49
55
|
return path.replace("\\", "/")
|
|
50
56
|
|
|
51
57
|
|
|
58
|
+
def effective_project_root(project_root: str, absolute_path: str) -> str:
|
|
59
|
+
"""Resolve the project root that owns an edited file.
|
|
60
|
+
|
|
61
|
+
Parallel subagents edit `.code-flow/worktrees/<run>/<TASK>/...`; the nearest
|
|
62
|
+
`.code-flow` ancestor is that worktree's root, not the session root. Files
|
|
63
|
+
outside the session root and unresolvable paths fall back to `project_root`.
|
|
64
|
+
Lexical `abspath` (not `realpath`) keeps caller/returned paths in the same
|
|
65
|
+
coordinates (macOS /var ↔ /private/var symlink).
|
|
66
|
+
"""
|
|
67
|
+
root = os.path.abspath(project_root)
|
|
68
|
+
target = os.path.abspath(absolute_path) if absolute_path else root
|
|
69
|
+
if target != root and not target.startswith(root + os.sep):
|
|
70
|
+
return root
|
|
71
|
+
current = target
|
|
72
|
+
while True:
|
|
73
|
+
if os.path.isdir(os.path.join(current, ".code-flow")):
|
|
74
|
+
return current
|
|
75
|
+
if current == root:
|
|
76
|
+
return root
|
|
77
|
+
parent = os.path.dirname(current)
|
|
78
|
+
if parent == current:
|
|
79
|
+
return root
|
|
80
|
+
current = parent
|
|
81
|
+
|
|
82
|
+
|
|
52
83
|
def _spec_path_from_entry(entry) -> str:
|
|
53
84
|
cfg = normalize_spec_entry(entry)
|
|
54
85
|
return normalize_path(cfg.get("path", ""))
|
|
@@ -468,7 +499,8 @@ def resolve_quality_loop(config: dict) -> dict:
|
|
|
468
499
|
"""
|
|
469
500
|
if not isinstance(config, dict):
|
|
470
501
|
return {"enabled": False, "post_check": False,
|
|
471
|
-
"stop_check": False, "
|
|
502
|
+
"stop_check": False, "finish_check": False,
|
|
503
|
+
"correction_capture": False,
|
|
472
504
|
"compress_reminder": False}
|
|
473
505
|
cfg = config.get("quality_loop")
|
|
474
506
|
cfg = cfg if isinstance(cfg, dict) else {}
|
|
@@ -481,6 +513,7 @@ def resolve_quality_loop(config: dict) -> dict:
|
|
|
481
513
|
"enabled": enabled,
|
|
482
514
|
"post_check": _sub("post_check"),
|
|
483
515
|
"stop_check": _sub("stop_check"),
|
|
516
|
+
"finish_check": _sub("finish_check"),
|
|
484
517
|
"correction_capture": _sub("correction_capture"),
|
|
485
518
|
"compress_reminder": _sub("compress_reminder"),
|
|
486
519
|
}
|