@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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/adapters/claude/commands/cf-init.md +1 -0
  4. package/src/adapters/claude/commands/cf-spec.md +7 -3
  5. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  6. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  7. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  8. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  9. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  10. package/src/adapters/claude/commands/cf-task/start.md +96 -25
  11. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  12. package/src/adapters/claude/commands/cf-validate.md +15 -13
  13. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  14. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  15. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  16. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  17. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  18. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  19. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
  21. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  22. package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
  23. package/src/adapters/costrict/commands/cf-init.md +1 -0
  24. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  25. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  27. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  29. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  30. package/src/adapters/costrict/commands/cf-task/start.md +96 -25
  31. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  32. package/src/adapters/costrict/commands/cf-validate.md +15 -13
  33. package/src/adapters/opencode/commands/cf-init.md +9 -4
  34. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  35. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  36. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  37. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  38. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  39. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  40. package/src/adapters/opencode/commands/cf-task/start.md +96 -25
  41. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  42. package/src/adapters/opencode/commands/cf-validate.md +15 -13
  43. package/src/adapters/opencode/opencode.json +1 -2
  44. package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
  45. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  46. package/src/cli.js +228 -64
  47. package/src/core/code-flow/config.yml +2 -1
  48. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  49. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
  50. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  51. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  52. package/src/core/code-flow/scripts/cf_core.py +34 -1
  53. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  54. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  55. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
  56. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  57. package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
  58. package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
  59. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  60. package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
  61. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  62. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  63. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  64. package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
  65. package/src/core/code-flow/scripts/cf_task_index.py +148 -0
  66. package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
  67. package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
  68. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  69. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  70. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  71. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  72. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  73. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
  74. 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
- normalized = level.strip().lower()
26
- if normalized == "manual":
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 normalized == "e2e":
29
- return "e2e" # Keep as separate kind, not functional
39
+ if head in ("e2e", "端到端", "end"):
40
+ return "e2e"
30
41
  return "functional"
31
42
 
32
43
 
33
- def _manifest_hash(rows: list[dict[str, str]]) -> str:
34
- immutable = [{key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner")} for item in rows]
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 extract_manifest(task_file: str) -> dict[str, object]:
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, 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 len(fields) < 5 or match.group(1) in seen:
58
- continue
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
- rows.append({"id": match.group(1), "source": fields[0], "level": fields[1], "kind": _kind(fields[1]), "boundary": fields[2], "owner": fields[3], "status": fields[4]})
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
- return {"schema": 1, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
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 = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
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") != 1 or manifest.get("task_sha256") != expected["task_sha256"]:
180
+ if manifest.get("schema") != _MANIFEST_SCHEMA:
85
181
  return False, "acceptance_manifest_drift"
86
- actual = {item.get("id") for item in manifest.get("scenarios", []) if isinstance(item, Mapping)}
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 actual != required:
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 = section.group(0).partition("Acceptance-Refs")[2] if section else ""
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 = json.loads(path.read_text(encoding="utf-8"))
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
- text = task_file.read_text(encoding="utf-8")
131
- if owner:
132
- section_match = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
133
- else:
134
- section_match = re.search(r"(?ms)^##\s+TASK-\d+:.*?(?=^##\s+TASK-|\Z)", text)
135
- if section_match is None:
136
- return
137
- section = section_match.group(0)
138
- line = f"- {scenario_id}: verified — {evidence} (confirmed_by: {confirmed_by})"
139
- contract_match = re.search(r"(?ms)^### Acceptance Contract\s*$\n(.*?)(?=^### |^## |\Z)", section)
140
- if contract_match and scenario_id in contract_match.group(1) and "verified" not in contract_match.group(1).lower():
141
- body = contract_match.group(1)
142
- body = "\n".join((f"{item} — verified" if scenario_id in item else item) for item in body.splitlines())
143
- section = section[:contract_match.start(1)] + body + section[contract_match.end(1):]
144
- evidence_match = re.search(r"(?ms)^### Acceptance Evidence\s*$\n(.*?)(?=^### |^## |\Z)", section)
145
- if evidence_match:
146
- body = evidence_match.group(1)
147
- replacement = body.rstrip() + "\n" + line + "\n"
148
- section = section[:evidence_match.start(1)] + replacement + section[evidence_match.end(1):]
149
- else:
150
- section = section.rstrip() + "\n\n### Acceptance Evidence\n" + line + "\n"
151
- task_file.write_text(text[:section_match.start()] + section + text[section_match.end():], encoding="utf-8")
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
- def _run(item: Mapping[str, object], root: Path, include_e2e: bool = False) -> dict[str, object]:
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
- try:
28
- result = subprocess.run(command, cwd=str(root / cwd), capture_output=True, timeout=timeout_value, check=False)
29
- except subprocess.TimeoutExpired:
30
- return {"id": scenario_id, "kind": kind, "status": "failed", "error": "timeout"}
31
- except OSError as exc:
32
- return {"id": scenario_id, "kind": kind, "status": "failed", "error": str(exc)}
33
- return {"id": scenario_id, "kind": kind, "status": "passed" if result.returncode == 0 else "failed", "exit_code": result.returncode}
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 run_manifest(manifest_file: str, root: str, write_evidence: bool = False, include_e2e: bool = False) -> dict[str, object]:
60
- data = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
61
- scenarios = data.get("scenarios", [])
62
- if not isinstance(scenarios, list):
63
- raise ValueError("invalid scenarios")
64
- items = [item for item in scenarios if isinstance(item, Mapping)]
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 = [_run(item, Path(root), include_e2e) for item in ordered]
67
- configured = any(isinstance(item.get("command"), list) for item in items)
68
- allowed = ("passed", "manual_pending", "e2e_deferred", "not_configured") if not configured else ("passed", "manual_pending", "e2e_deferred")
69
- decision = "pass" if all(item["status"] in allowed for item in results) else "block"
154
+ results = _run_unique(ordered, Path(root), include_e2e or only_e2e, deadline, only_e2e)
70
155
  if write_evidence:
71
- for item, result in zip(ordered, results):
72
- if isinstance(item, dict) and result["status"] == "passed":
73
- item["status"] = "verified"
74
- item["evidence"] = result
75
- Path(manifest_file).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
76
- task_file = data.get("task_file")
77
- if isinstance(task_file, str) and task_file:
78
- from cf_acceptance_manifest import _sync_task_evidence
79
-
80
- task_path = Path(task_file)
81
- if not task_path.is_absolute():
82
- task_path = Path(manifest_file).parent / task_path
83
- for item, result in zip(ordered, results):
84
- if result["status"] == "passed":
85
- owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
86
- _sync_task_evidence(task_path, str(result["id"]), "runner", "automated command passed", owner)
87
- return {"decision": decision, "results": results}
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, "correction_capture": 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
  }