@michaelxwb/code-flow 0.6.5 → 0.8.1

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 (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -42
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,867 @@
1
+ """Shared requirement verification for CLI and parallel batch orchestration."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import re
6
+ import sys
7
+ import time
8
+ from typing import Mapping, Optional, Sequence
9
+
10
+ from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
11
+ from codeflow.acceptance.cf_acceptance_risk import accepted_risk
12
+ from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids, verified_evidence
13
+ from codeflow.acceptance.cf_acceptance_selection import select_owned, select_profile
14
+ from codeflow.cli.cf_acceptance_runner import run_manifest
15
+ from codeflow.cli.cf_task_index import project_task_nodes
16
+ from codeflow.core import cf_git
17
+ from codeflow.core.cf_exec_base import record_report, report_session, worktree_fingerprint
18
+ from codeflow.spec.cf_active_task import load_active_task
19
+ from codeflow.spec.cf_spec_context_model import ContextError, SpecContext, context_sha256, load_context, save_context
20
+ from codeflow.spec.cf_spec_metadata import SpecMetadata, load_spec_metadata
21
+ from codeflow.spec.cf_spec_verify import (VerificationEvidence, VerificationResult, VerificationScope, run_all_verifiers,
22
+ store_verification_evidence)
23
+ from codeflow.spec.cf_stage_gate import validate_stage
24
+ from codeflow.spec.cf_workflow_service import FINISHED_STATUSES, complete_requirement_verification
25
+ from codeflow.spec.cf_workflow_transaction import recover_transition
26
+ from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
27
+ from codeflow.task.cf_task_runtime import integrate_verification_evidence
28
+
29
+
30
+ def demand_tasks(directory: Path) -> list[tuple[Path, str, str]]:
31
+ tasks = []
32
+ seen = set()
33
+ for task_file in sorted(directory.glob("*.md")):
34
+ if task_file.name.endswith((".design.md", ".prd.md", ".scenarios.md")):
35
+ continue
36
+ try:
37
+ text = task_file.read_text(encoding="utf-8")
38
+ except (OSError, UnicodeError) as exc:
39
+ raise ValueError(f"task_read_error: {exc}") from exc
40
+ try:
41
+ document = parse_task_document(text, source=str(task_file))
42
+ except TaskDocumentError as exc:
43
+ if exc.code == "task_document_empty":
44
+ continue
45
+ raise ValueError(str(exc)) from exc
46
+ for node in project_task_nodes(document):
47
+ if node.task_id in seen:
48
+ raise ValueError(f"duplicate task id: {node.task_id}")
49
+ seen.add(node.task_id)
50
+ tasks.append((task_file, node.task_id, node.status))
51
+ if not tasks:
52
+ raise ValueError("no TASK sections found")
53
+ return tasks
54
+
55
+
56
+ def _head_token(root: str) -> str:
57
+ try:
58
+ return "head:" + cf_git.run_git(root, ("rev-parse", "HEAD")).strip()
59
+ except cf_git.GitError:
60
+ return "head:none"
61
+
62
+
63
+ def _review_scope(root: str) -> VerificationScope:
64
+ files = set(cf_git.tracked_files(root))
65
+ try:
66
+ files.update(cf_git.changed_files(root))
67
+ except cf_git.GitError:
68
+ pass
69
+ return VerificationScope(root, tuple(sorted(files)), _head_token(root))
70
+
71
+
72
+ def collect_requirement_bindings(directory: Path,
73
+ stages: Sequence[str] = ("review",)) -> dict[str, dict[str, object]]:
74
+ """收集需求目录下全部 task context 中指定阶段的绑定。
75
+
76
+ 返回 spec_id -> {"path": spec 相对路径, "rules": {rule_ref: [(context_path, context), ...]}}。
77
+ 读取失败 fail-closed(报告文件与原因),不允许静默跳过。
78
+ """
79
+ allowed = set(stages)
80
+ targets: dict[str, dict[str, object]] = {}
81
+ for ctx_path in sorted(directory.rglob("spec-context.yml")):
82
+ try:
83
+ context = load_context(str(ctx_path))
84
+ except (ContextError, OSError, ValueError) as exc:
85
+ raise ValueError(f"review_context_unreadable: {ctx_path}: {exc}") from exc
86
+ for binding in context.bindings:
87
+ for rule in binding.rules:
88
+ if rule.verifier_stage not in allowed:
89
+ continue
90
+ entry = targets.setdefault(binding.spec_id, {"path": binding.path, "rules": {}})
91
+ entry["rules"].setdefault(rule.ref, []).append((ctx_path, context))
92
+ return targets
93
+
94
+
95
+ def collect_review_bindings(directory: Path) -> dict[str, dict[str, object]]:
96
+ """review 层绑定(manual 确认与待确认清单专用)。"""
97
+ return collect_requirement_bindings(directory, ("review",))
98
+
99
+
100
+ def _confirmed_manual(spec_id: str, entries: Sequence[tuple[Path, SpecContext]],
101
+ rule_ref: str) -> Optional[Mapping[str, object]]:
102
+ """从任意绑定该 rule 的 context 读取有效的用户确认记录(按 rule 自身 stage)。"""
103
+ for _, context in entries:
104
+ for binding in context.bindings:
105
+ if binding.spec_id != spec_id:
106
+ continue
107
+ for rule in binding.rules:
108
+ if rule.ref != rule_ref:
109
+ continue
110
+ status = rule.stage_status.get(rule.verifier_stage or "code")
111
+ decision = status.decision if status is not None else None
112
+ if (status is not None and status.status == "verified"
113
+ and decision is not None and decision.kind == "manual_verification"):
114
+ return {
115
+ "reason": decision.reason,
116
+ "confirmed_by": decision.confirmed_by,
117
+ "confirmed_at": decision.confirmed_at,
118
+ "source": decision.source,
119
+ }
120
+ return None
121
+
122
+
123
+ def pending_manual_rules(
124
+ root: str, targets: Mapping[str, Mapping[str, object]]
125
+ ) -> tuple[list[dict[str, object]], dict[str, dict[str, Mapping[str, object]]]]:
126
+ """收集 review 层 manual 规则:待用户确认清单 + 已确认项的 verifier 入参。"""
127
+ pending: list[dict[str, object]] = []
128
+ confirmations: dict[str, dict[str, Mapping[str, object]]] = {}
129
+ for spec_id in sorted(targets):
130
+ info = targets[spec_id]
131
+ metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
132
+ manual = {item.rule: item for item in metadata.verifiers if item.type == "manual"}
133
+ for rule_ref, entries in sorted(info["rules"].items()):
134
+ confirmation = _confirmed_manual(spec_id, entries, rule_ref)
135
+ if confirmation is not None:
136
+ confirmations.setdefault(spec_id, {})[rule_ref] = confirmation
137
+ elif rule_ref in manual:
138
+ verifier = manual[rule_ref]
139
+ pending.append({
140
+ "ref": f"{spec_id}#{rule_ref}",
141
+ "spec_id": spec_id,
142
+ "checklist": verifier.config.get("checklist", ""),
143
+ "owner": verifier.config.get("owner", ""),
144
+ "bound_contexts": len(entries),
145
+ })
146
+ return pending, confirmations
147
+
148
+
149
+ def _manual_confirmations(root: str, targets: Mapping[str, Mapping[str, object]]) -> dict[str, dict[str, Mapping[str, object]]]:
150
+ """收集各 manual rule 自身 stage 上已记录的用户确认(verifier 入参)。"""
151
+ confirmations: dict[str, dict[str, Mapping[str, object]]] = {}
152
+ for spec_id in sorted(targets):
153
+ info = targets[spec_id]
154
+ metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
155
+ manual_refs = {item.rule for item in metadata.verifiers if item.type == "manual"}
156
+ for rule_ref, entries in sorted(info["rules"].items()):
157
+ if rule_ref not in manual_refs:
158
+ continue
159
+ confirmation = _confirmed_manual(spec_id, entries, rule_ref)
160
+ if confirmation is not None:
161
+ confirmations.setdefault(spec_id, {})[rule_ref] = confirmation
162
+ return confirmations
163
+
164
+
165
+ def _store_requirement_evidence(metadata: SpecMetadata, scope: VerificationScope,
166
+ result: VerificationResult, directory: str) -> None:
167
+ try:
168
+ from codeflow.evidence.cf_evidence_store import requirement_key
169
+ store_verification_evidence(metadata, scope, result, requirement=requirement_key(directory))
170
+ except (OSError, ValueError) as exc:
171
+ sys.stderr.write(f"cf_task_workflow: verifier evidence sidecar failed: {exc}\n")
172
+
173
+
174
+ def _update_requirement_contexts(evidence_by_context: Mapping[Path, list[VerificationEvidence]],
175
+ scope: VerificationScope) -> list[str]:
176
+ # Integrate all specs before writing each context to avoid stale overwrites.
177
+ for ctx_path in sorted(evidence_by_context, key=str):
178
+ context = load_context(str(ctx_path))
179
+ updated, _ = integrate_verification_evidence(context, evidence_by_context[ctx_path])
180
+ if updated != context:
181
+ save_context(str(ctx_path), updated)
182
+ failures = []
183
+ for ctx_path in sorted(evidence_by_context):
184
+ gate = validate_stage(load_context(str(ctx_path)), "review", diff_sha256=scope.diff_sha256)
185
+ failures.extend(f"{issue.spec_id}#{issue.rule_ref}" for issue in gate.errors)
186
+ return failures
187
+
188
+
189
+ def _summarize_verifier(evidence: VerificationEvidence, summary: list[dict[str, object]]) -> str:
190
+ summary.append({"check_id": evidence.verifier_ref, "status": evidence.status, "error_code": evidence.error_code})
191
+ if evidence.status == "verified":
192
+ return "reused" if (evidence.details or {}).get("cache_reused") else "executed"
193
+ if evidence.error_code in _ENV_ERROR_CODES:
194
+ return "env_blocked"
195
+ return "incomplete" if evidence.error_code == "verifier_timeout" else "failed"
196
+
197
+
198
+ def _run_requirement_verifiers(
199
+ root: str,
200
+ directory: str,
201
+ targets: dict[str, dict[str, object]],
202
+ confirmations: Optional[Mapping[str, Mapping[str, Mapping[str, object]]]] = None,
203
+ ) -> dict[str, object]:
204
+ """执行需求级全量 verifier(code+review 按 spec/rule 去重),证据写回全部相关 context。"""
205
+ executed = reused = env_blocked = incomplete = 0
206
+ failures: list[str] = []
207
+ summaries: list[dict[str, object]] = []
208
+ evidence_by_context: dict[Path, list[VerificationEvidence]] = {}
209
+ started = time.monotonic()
210
+ scope = _review_scope(root)
211
+ preflight_ms = (time.monotonic() - started) * 1000
212
+ planning_ms = evidence_ms = 0.0
213
+ for spec_id in sorted(targets):
214
+ info = targets[spec_id]
215
+ started = time.monotonic()
216
+ metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
217
+ planning_ms += (time.monotonic() - started) * 1000
218
+ result = run_all_verifiers(metadata, scope, (confirmations or {}).get(spec_id), stage=None)
219
+ started = time.monotonic()
220
+ _store_requirement_evidence(metadata, scope, result, directory)
221
+ evidence_ms += (time.monotonic() - started) * 1000
222
+ by_rule: dict[str, VerificationEvidence] = {}
223
+ for evidence in result.evidence:
224
+ rule_ref = evidence.verifier_ref.split("#", 1)[-1]
225
+ by_rule[rule_ref] = evidence
226
+ category = _summarize_verifier(evidence, summaries)
227
+ executed += category == "executed"
228
+ reused += category == "reused"
229
+ env_blocked += category == "env_blocked"
230
+ incomplete += category == "incomplete"
231
+ if category == "failed":
232
+ failures.append(evidence.verifier_ref)
233
+ for rule_ref, entries in info["rules"].items():
234
+ evidence = by_rule.get(rule_ref)
235
+ if evidence is None:
236
+ continue
237
+ for ctx_path, _context in entries:
238
+ evidence_by_context.setdefault(ctx_path, []).append(evidence)
239
+ started = time.monotonic()
240
+ failures.extend(_update_requirement_contexts(evidence_by_context, scope))
241
+ evidence_ms += (time.monotonic() - started) * 1000
242
+ record_report({"kind": "spec_phases", "timings": {"planning_ms": planning_ms,
243
+ "preflight_ms": preflight_ms, "evidence_ms": evidence_ms}})
244
+ return {"executed": executed, "reused": reused, "failed": sorted(set(failures)),
245
+ "env_blocked": env_blocked, "incomplete": incomplete, "evidence": summaries}
246
+
247
+
248
+ VERIFY_LAYERS = ("validators", "review", "e2e")
249
+ VERIFY_PROFILES = ("smoke", "changed", "full")
250
+ _ENV_ERROR_CODES = frozenset(("env_blocked", "environment_unavailable", "command_unavailable"))
251
+
252
+
253
+ def parse_verify_layers(value: Optional[str]) -> tuple[str, ...]:
254
+ if value is None or not value.strip():
255
+ return VERIFY_LAYERS
256
+ chosen: set[str] = set()
257
+ for item in value.split(","):
258
+ name = item.strip()
259
+ if not name:
260
+ continue
261
+ if name not in VERIFY_LAYERS:
262
+ raise ValueError(f"unknown verify layer: {name}; allowed: {', '.join(VERIFY_LAYERS)}")
263
+ chosen.add(name)
264
+ if not chosen:
265
+ raise ValueError(f"--layers requires at least one of: {', '.join(VERIFY_LAYERS)}")
266
+ return tuple(name for name in VERIFY_LAYERS if name in chosen)
267
+
268
+
269
+ def parse_verify_profile(value: Optional[str], default: str = "changed") -> str:
270
+ profile = (value or default).strip()
271
+ if profile not in VERIFY_PROFILES:
272
+ raise ValueError(f"unknown verify profile: {profile}; allowed: {', '.join(VERIFY_PROFILES)}")
273
+ return profile
274
+
275
+
276
+ def _layer_result(decision: str, *, reason: str = "", executed: int = 0, reused: int = 0,
277
+ failed: Optional[Sequence[str]] = None, env_blocked: int = 0, incomplete: int = 0,
278
+ evidence: Optional[Sequence[Mapping[str, object]]] = None,
279
+ **extra: object) -> dict[str, object]:
280
+ result: dict[str, object] = {
281
+ "decision": decision,
282
+ "reason": reason,
283
+ "executed": executed,
284
+ "reused": reused,
285
+ "failed": list(failed or ()),
286
+ "env_blocked": env_blocked,
287
+ "incomplete": incomplete,
288
+ "evidence": list(evidence or ()),
289
+ }
290
+ result.update(extra)
291
+ return result
292
+
293
+
294
+ def _nothing_to_verify() -> dict[str, object]:
295
+ return _layer_result("pass", reason="nothing_to_verify")
296
+
297
+
298
+ def _validation_view(details: Mapping[str, object]) -> dict[str, object]:
299
+ return {
300
+ "decision": details.get("decision"),
301
+ "reason": details.get("reason", ""),
302
+ "reused": details.get("reused", 0),
303
+ "failures": details.get("failures", []),
304
+ "files": list(details.get("files") or ()),
305
+ "timings": details.get("timings"),
306
+ "observations": details.get("observations", []),
307
+ }
308
+
309
+
310
+ def _validator_execution_count(root: str, selected: Sequence[str], reused: int) -> int:
311
+ from codeflow.core.cf_validators import resolve_validators, trigger_matches
312
+
313
+ _config, validators = resolve_validators(root)
314
+ applicable = 0
315
+ for validator in validators:
316
+ if not isinstance(validator, Mapping):
317
+ continue
318
+ trigger = str(validator.get("trigger", ""))
319
+ if any(trigger_matches(trigger, path) for path in selected):
320
+ applicable += 1
321
+ return max(0, applicable - reused)
322
+
323
+
324
+ def _validators_layer(root: str, profile: str,
325
+ files: Optional[Sequence[str]] = None) -> dict[str, object]:
326
+ try:
327
+ from codeflow.task.cf_validation_service import validate_files
328
+
329
+ if files is None:
330
+ from codeflow.task.cf_validation_service import validation_scope
331
+ files = (tuple(sorted(cf_git.tracked_files(root))) if profile == "full"
332
+ else validation_scope(root))
333
+ details = validate_files(root, files, include_heavy=profile == "full")
334
+ except (OSError, ValueError) as exc:
335
+ return _layer_result("block", reason="validation_error", detail=str(exc))
336
+ view = _validation_view(details)
337
+ if details.get("reason") in ("no_changes", "no_validators_configured"):
338
+ return _layer_result("pass", reason="nothing_to_verify", validation=view)
339
+ failures = list(details.get("failures") or ())
340
+ failed = [str(item.get("name", "validator")) for item in failures if not item.get("incomplete")]
341
+ incomplete = sum(1 for item in failures if item.get("incomplete"))
342
+ reused = int(details.get("reused") or 0)
343
+ selected = details.get("files") or ()
344
+ executed = _validator_execution_count(root, selected, reused)
345
+ decision = "pass" if details.get("decision") == "pass" else "block"
346
+ return _layer_result(
347
+ decision,
348
+ reason="" if decision == "pass" else "validation_failed",
349
+ executed=executed,
350
+ reused=reused,
351
+ failed=failed,
352
+ incomplete=incomplete,
353
+ validation=view,
354
+ )
355
+
356
+
357
+ def _validation_report(result: dict[str, object], layer: Mapping[str, object],
358
+ layers: Sequence[str]) -> dict[str, object]:
359
+ view = layer.get("validation") or {}
360
+ observations = view.get("observations") or []
361
+ result["scope"] = {"layers": list(layers), "files": view.get("files", [])}
362
+ result["selection_reason"] = ("validator_trigger_matches_selected_files" if observations
363
+ else "no_validator_selected")
364
+ result["reuse_reason"] = "matching_validation_cache" if layer.get("reused") else "no_reused_results"
365
+ result["execution_identities"] = [item["identity"] for item in observations]
366
+ result["input_change_category"] = sorted({item["input_change_category"] for item in observations})
367
+ result["summary"] = (f"validators: executed {int(layer.get('executed') or 0)}, "
368
+ f"reused {int(layer.get('reused') or 0)}")
369
+ if view.get("timings") is not None:
370
+ result["timings"] = dict(view["timings"])
371
+ result["timings_scope"] = ["validators"]
372
+ return result
373
+
374
+
375
+ def _combine_acceptance_timing(result: dict[str, object], layer: Mapping[str, object],
376
+ started: float) -> None:
377
+ acceptance = layer.get("acceptance")
378
+ if not isinstance(acceptance, Mapping) or not isinstance(acceptance.get("timings"), Mapping):
379
+ return
380
+ incoming = acceptance["timings"]
381
+ current = result.get("timings")
382
+ if not isinstance(current, dict):
383
+ current = {key: 0.0 for key in ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms")}
384
+ result["timings"] = current
385
+ result["timings_scope"] = []
386
+ for key in ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms"):
387
+ current[key] = float(current.get(key, 0)) + float(incoming.get(key, 0))
388
+ current["elapsed_ms"] = (time.monotonic() - started) * 1000
389
+ result["timings_scope"].append("e2e")
390
+ reasons = {"e2e": acceptance.get("selection_reason", "unknown")}
391
+ if "selection_reason" in result:
392
+ reasons["validators"] = result["selection_reason"]
393
+ result["selection_reasons"] = reasons
394
+ result["selection_reason"] = "multiple_layer_policies" if len(reasons) > 1 else reasons["e2e"]
395
+ result["selected_check_ids"] = acceptance.get("selected_check_ids", [])
396
+ result.setdefault("execution_identities", []).extend(acceptance.get("execution_identities", []))
397
+ categories = acceptance.get("input_change_category") or {}
398
+ result["input_change_categories"] = {"validators": result.get("input_change_category", []), "e2e": categories}
399
+ result["input_change_category"] = sorted(set(result.get("input_change_category", [])) | set(categories.values()))
400
+ summaries = [str(value) for value in (result.get("summary"), acceptance.get("summary")) if value]
401
+ result["summary"] = "; ".join(summaries)
402
+ result["reuse_reason"] = "evidence_reused" if result.get("reused") else "no_reused_results"
403
+ if "scope" not in result:
404
+ result["scope"] = {"layers": result.get("selected_layers", []), "acceptance": acceptance.get("scope", {})}
405
+
406
+
407
+ def _verify_validators(root: str, directory: str, profile: str) -> dict[str, object]:
408
+ """Development checks use the active baseline without completing the TASK."""
409
+ started = time.monotonic()
410
+ files: Optional[Sequence[str]] = None
411
+ try:
412
+ if (Path(root) / ".code-flow/.active-task.json").exists():
413
+ active = load_active_task(root)
414
+ if (Path(root) / active.task_dir).resolve() != Path(directory).resolve():
415
+ raise ValueError("active_task_directory_mismatch")
416
+ context = load_context(str(Path(directory) / "spec-context.yml"))
417
+ if active.context_sha256 != context_sha256(context):
418
+ raise ValueError("active_context_hash_mismatch: run code-flow spec refresh/doctor")
419
+ from codeflow.task.cf_validation_service import validation_scope
420
+ files = validation_scope(root)
421
+ layer = _validators_layer(root, profile, files)
422
+ except (OSError, ValueError) as exc:
423
+ layer = _layer_result("block", reason="validation_context_invalid", detail=str(exc))
424
+ result = _aggregate_layers({"validators": layer}, profile, ("validators",))
425
+ _validation_report(result, layer, ("validators",))
426
+ if "timings" in result:
427
+ result["timings"]["elapsed_ms"] = (time.monotonic() - started) * 1000
428
+ return result
429
+
430
+
431
+ def _review_layer(root: str, directory: str) -> dict[str, object]:
432
+ started = time.monotonic()
433
+ try:
434
+ review_targets = collect_review_bindings(Path(directory))
435
+ pending_rules, _ = pending_manual_rules(root, review_targets)
436
+ except ValueError as exc:
437
+ return _layer_result("block", reason="manual_review_context_unreadable", detail=str(exc))
438
+ if pending_rules:
439
+ return _layer_result(
440
+ "block",
441
+ reason="manual_confirmation_required",
442
+ manual_rules=pending_rules,
443
+ next=("向用户展示待确认清单并取得明确回复后,运行 confirm-manual 批量写入确认,再重跑 verify;"
444
+ "Agent 不得代确认。"),
445
+ )
446
+ try:
447
+ all_targets = collect_requirement_bindings(Path(directory), ("code", "review"))
448
+ except ValueError as exc:
449
+ return _layer_result("block", reason="review_context_unreadable", detail=str(exc))
450
+ if not all_targets:
451
+ return _nothing_to_verify()
452
+ confirmations = _manual_confirmations(root, all_targets)
453
+ record_report({"kind": "spec_phases", "timings": {"planning_ms": (time.monotonic() - started) * 1000}})
454
+ review = _run_requirement_verifiers(root, directory, all_targets, confirmations)
455
+ blocked = bool(review["failed"] or review["env_blocked"] or review["incomplete"])
456
+ return _layer_result(
457
+ "block" if blocked else "pass",
458
+ reason="review_failed" if blocked else "",
459
+ executed=int(review["executed"]),
460
+ reused=int(review["reused"]),
461
+ failed=review["failed"],
462
+ env_blocked=int(review["env_blocked"]),
463
+ incomplete=int(review["incomplete"]),
464
+ evidence=review["evidence"],
465
+ review={"executed": review["executed"], "reused": review["reused"], "failed": review["failed"]},
466
+ )
467
+
468
+
469
+ def _acceptance_counts(acceptance: Mapping[str, object]) -> dict[str, object]:
470
+ executed = reused = incomplete = env_blocked = 0
471
+ failed: list[str] = []
472
+ evidence: list[dict[str, object]] = []
473
+ for item in acceptance.get("results") or ():
474
+ if not isinstance(item, Mapping):
475
+ continue
476
+ scenario_id = str(item.get("id", ""))
477
+ status = str(item.get("status", ""))
478
+ evidence.append({"check_id": scenario_id, "status": status})
479
+ if status == "incomplete":
480
+ incomplete += 1
481
+ elif status == "env_blocked":
482
+ env_blocked += 1
483
+ elif status in ("failed", "not_configured"):
484
+ failed.append(scenario_id)
485
+ elif status == "passed":
486
+ if item.get("cache_reused"):
487
+ reused += 1
488
+ else:
489
+ executed += 1
490
+ return {"executed": executed, "reused": reused, "failed": sorted(failed),
491
+ "env_blocked": env_blocked, "incomplete": incomplete, "evidence": evidence}
492
+
493
+
494
+ def e2e_preflight(manifest: Path, tasks: Sequence[tuple[Path, str, str]],
495
+ requirement_gate: bool, profile: str = "changed",
496
+ root: str = "") -> tuple[Optional[dict[str, object]], Optional[dict[str, object]]]:
497
+ try:
498
+ data = load_manifest(manifest)
499
+ validate_execution_baseline(manifest, data)
500
+ except (OSError, ValueError) as exc:
501
+ return None, _layer_result("block", reason="acceptance_manifest_invalid", detail=str(exc))
502
+ bound_task = (manifest.parent / str(data.get("task_file", ""))).resolve()
503
+ if {task.resolve() for task, _, _ in tasks} != {bound_task}:
504
+ return None, _layer_result("block", reason="acceptance_manifest_task_mismatch")
505
+ owner = ",".join(task for _, task, _ in tasks)
506
+ selected_ids: Optional[set[str]] = None
507
+ if requirement_gate and root and profile != "full":
508
+ selected = select_profile(data, root, profile)["scenarios"]
509
+ selected_ids = {str(item.get("id")) for item in select_owned(selected, owner)}
510
+ data = {**data, "scenarios": select_owned(data["scenarios"], owner)}
511
+ if not requirement_gate:
512
+ if not any(row.get("kind") == "e2e" or row.get("exec") == "deferred" for row in data["scenarios"]):
513
+ return None, _nothing_to_verify()
514
+ return data, None
515
+ functional_required, manual_scenarios = _pending_scenario_rows(data, selected_ids)
516
+ if functional_required:
517
+ return None, _layer_result("block", reason="functional_or_manual_evidence_missing",
518
+ scenarios=functional_required)
519
+ if manual_scenarios:
520
+ return None, _layer_result(
521
+ "block", reason="manual_confirmation_required", manual_scenarios=manual_scenarios,
522
+ next=("向用户展示待确认清单并取得明确回复后,运行 confirm-manual 批量写入确认,再重跑 verify;"
523
+ "Agent 不得代确认。"))
524
+ return data, None
525
+
526
+
527
+ def _pending_scenario_rows(data: Mapping[str, object], selected_ids: Optional[set[str]]
528
+ ) -> tuple[list[object], list[dict[str, object]]]:
529
+ functional_required: list[object] = []
530
+ manual_scenarios: list[dict[str, object]] = []
531
+ checks = {
532
+ str(item.get("id")): item for item in data.get("checks", [])
533
+ if isinstance(item, Mapping) and item.get("id")
534
+ }
535
+ for row in data["scenarios"]:
536
+ if row.get("exec") == "aspirational":
537
+ continue
538
+ if selected_ids is not None and str(row.get("id")) not in selected_ids:
539
+ continue
540
+ if row.get("status") == "stale":
541
+ # Stale evidence is known-outdated, not missing. The E2E layer
542
+ # re-runs it; preflight must not turn it into a hard archive block.
543
+ continue
544
+ if verified_evidence(row):
545
+ continue
546
+ if row.get("status") == "accepted_risk":
547
+ check_ids = scenario_check_ids(row)
548
+ if check_ids and any(accepted_risk(checks.get(check_id), row) is not None for check_id in check_ids):
549
+ continue
550
+ if row.get("kind") == "manual" or row.get("exec") == "manual":
551
+ manual_scenarios.append({
552
+ "id": row.get("id"),
553
+ "owner": row.get("owner", ""),
554
+ "boundary": row.get("boundary", ""),
555
+ })
556
+ elif (row.get("status") == "verified" and row.get("kind", "functional") != "e2e"
557
+ and row.get("exec") != "deferred"):
558
+ functional_required.append(row["id"])
559
+ return functional_required, manual_scenarios
560
+
561
+
562
+ def stale_functional_rows(data: Mapping[str, object], root: str) -> list[str]:
563
+ """Functional rows claiming verified but recorded against different content."""
564
+ from codeflow.acceptance.cf_acceptance_evidence import stale_scenario_checks
565
+ fingerprint = worktree_fingerprint(root)
566
+ checks = {
567
+ str(item.get("id")): item for item in data.get("checks", [])
568
+ if isinstance(item, Mapping) and item.get("id")
569
+ }
570
+ from codeflow.acceptance.cf_check_inputs import check_input_hashes
571
+ per_check = check_input_hashes(root, checks, fingerprint)
572
+ stale: list[str] = []
573
+ for row in data.get("scenarios", []) or []:
574
+ if not isinstance(row, Mapping):
575
+ continue
576
+ if str(row.get("kind", "functional")) in ("manual", "e2e"):
577
+ continue
578
+ if row.get("status") not in ("verified", "stale"):
579
+ continue
580
+ if stale_scenario_checks(row, per_check, fingerprint):
581
+ stale.append(str(row.get("id")))
582
+ return stale
583
+
584
+
585
+ def _unverified_functional_rows(data: Mapping[str, object]) -> list[str]:
586
+ checks = {
587
+ str(item.get("id")): item for item in data.get("checks", [])
588
+ if isinstance(item, Mapping) and item.get("id")
589
+ }
590
+ rows: list[str] = []
591
+ for row in data.get("scenarios", []) or []:
592
+ if not isinstance(row, Mapping):
593
+ continue
594
+ if str(row.get("kind", "functional")) in ("manual", "e2e") or row.get("exec") in ("manual", "aspirational"):
595
+ continue
596
+ if row.get("status") == "accepted_risk":
597
+ check_ids = scenario_check_ids(row)
598
+ if any(accepted_risk(checks.get(check_id), row) is not None for check_id in check_ids):
599
+ continue
600
+ if not verified_evidence(row):
601
+ rows.append(str(row.get("id")))
602
+ return rows
603
+
604
+
605
+ def _acceptance_required(tasks: Sequence[tuple[Path, str, str]]) -> bool:
606
+ for task_file, task_id, _ in tasks:
607
+ try:
608
+ text = Path(task_file).read_text(encoding="utf-8")
609
+ except (OSError, UnicodeError):
610
+ continue
611
+ section = re.search(rf"(?ms)^##\s+{re.escape(task_id)}:.*?(?=^##\s+TASK-|\Z)", text)
612
+ if section is None:
613
+ continue
614
+ body = section.group(0)
615
+ refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", body)
616
+ if refs and refs.group(1).strip().upper() not in ("", "N/A"):
617
+ return True
618
+ return False
619
+
620
+
621
+ def _e2e_layer(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]],
622
+ requirement_gate: bool, profile: str = "changed", resume: bool = True,
623
+ deadline: Optional[float] = None, max_failures: int = 0,
624
+ fail_fast: bool = False, jobs: int = 0,
625
+ partial_ids: Optional[Sequence[str]] = None) -> dict[str, object]:
626
+ manifest = Path(directory) / ".acceptance-manifest.json"
627
+ if not manifest.exists():
628
+ if _acceptance_required(tasks):
629
+ return _layer_result("block", reason="acceptance_manifest_missing")
630
+ return _nothing_to_verify()
631
+ data, failure = e2e_preflight(manifest, tasks, requirement_gate, profile, root)
632
+ if failure is not None:
633
+ return failure
634
+ stale = stale_functional_rows(data, root) if requirement_gate else []
635
+ unverified = _unverified_functional_rows(data) if requirement_gate else []
636
+ if profile == "smoke":
637
+ # Smoke only needs the E2E subset; functional freshness is handled by
638
+ # the explicit changed/full gate, not by the batch smoke.
639
+ stale = []
640
+ unverified = []
641
+ try:
642
+ acceptance = run_manifest(
643
+ str(manifest), root, write_evidence=True,
644
+ owner=",".join(str(item[1]) for item in tasks),
645
+ include_e2e=bool(stale or unverified) or profile in ("full", "smoke"),
646
+ only_e2e=(partial_ids is not None and profile == "smoke") or (
647
+ not (stale or unverified) and profile not in ("full", "smoke")),
648
+ profile=profile, resume=resume, deadline=deadline,
649
+ max_failures=max_failures, fail_fast=fail_fast, jobs=jobs,
650
+ force_ids=tuple(partial_ids) if partial_ids is not None else tuple(stale or unverified),
651
+ only_scenarios=partial_ids,
652
+ )
653
+ except (OSError, ValueError) as exc:
654
+ return _layer_result("block", reason="acceptance_run_error", detail=str(exc))
655
+ counts = _acceptance_counts(acceptance)
656
+ decision = "pass" if acceptance.get("decision") == "pass" else "block"
657
+ extra: dict[str, object] = {"results": acceptance.get("results", [])}
658
+ for key in ("checks", "error", "env_blocked_details"):
659
+ if key in acceptance:
660
+ extra[key] = acceptance[key]
661
+ return _layer_result(
662
+ decision,
663
+ reason="" if decision == "pass" else "e2e_failed",
664
+ executed=counts["executed"],
665
+ reused=counts["reused"],
666
+ failed=counts["failed"],
667
+ env_blocked=counts["env_blocked"],
668
+ incomplete=counts["incomplete"],
669
+ evidence=counts["evidence"],
670
+ acceptance=acceptance,
671
+ **extra,
672
+ )
673
+
674
+
675
+ def _aggregate_layers(layer_results: Mapping[str, Mapping[str, object]], profile: str,
676
+ selected: Sequence[str]) -> dict[str, object]:
677
+ blocking = [(name, layer) for name, layer in layer_results.items() if layer.get("decision") != "pass"]
678
+ every_layer_empty = bool(layer_results) and all(
679
+ layer.get("reason") == "nothing_to_verify" for layer in layer_results.values()
680
+ )
681
+ if blocking:
682
+ first = blocking[0]
683
+ reason = str(first[1].get("reason") or f"{first[0]}_failed")
684
+ elif every_layer_empty:
685
+ reason = "nothing_to_verify"
686
+ else:
687
+ reason = ""
688
+ evidence = [
689
+ {"layer": name, **item}
690
+ for name, layer in layer_results.items()
691
+ for item in layer.get("evidence", [])
692
+ if isinstance(item, Mapping)
693
+ ]
694
+ result: dict[str, object] = {
695
+ "decision": "block" if blocking else "pass",
696
+ "reason": reason,
697
+ "profile": profile,
698
+ "selected_layers": list(selected),
699
+ "layers": dict(layer_results),
700
+ "executed": sum(int(layer.get("executed", 0)) for layer in layer_results.values()),
701
+ "reused": sum(int(layer.get("reused", 0)) for layer in layer_results.values()),
702
+ "failed": sorted({str(item) for layer in layer_results.values()
703
+ for item in layer.get("failed", [])}),
704
+ "env_blocked": sum(int(layer.get("env_blocked", 0)) for layer in layer_results.values()),
705
+ "incomplete": sum(int(layer.get("incomplete", 0)) for layer in layer_results.values()),
706
+ "evidence": evidence,
707
+ "env_blocked_details": [
708
+ {"layer": name, **item} for name, layer in layer_results.items()
709
+ for item in layer.get("env_blocked_details", []) if isinstance(item, Mapping)
710
+ ],
711
+ }
712
+ for field in ("manual_rules", "manual_scenarios", "scenarios", "next"):
713
+ merged = [layer[field] for layer in layer_results.values() if field in layer]
714
+ if merged:
715
+ result[field] = merged[0] if len(merged) == 1 else merged
716
+ return result
717
+
718
+
719
+ def _batch_scope(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]], wanted: set[str]
720
+ ) -> tuple[list[tuple[Path, str, str]], list[str], list[str]]:
721
+ manifest = Path(directory) / ".acceptance-manifest.json"
722
+ if not manifest.is_file():
723
+ return [task for task in tasks if task[1] in wanted], [], []
724
+ data = load_manifest(manifest)
725
+ rows = {str(row["id"]): row for row in data["scenarios"]}
726
+ from codeflow.acceptance.cf_acceptance_selection import changed_paths
727
+ from codeflow.acceptance.cf_check_inputs import match_path
728
+ changes = changed_paths(root)
729
+ checks = {str(item["id"]): item for item in data.get("checks", [])}
730
+ has_explicit_smoke = any(row.get("kind") == "e2e" and row.get("smoke") for row in rows.values())
731
+ selected = {sid for sid, row in rows.items() if row.get("owner") in wanted
732
+ and (row.get("kind") != "e2e" or row.get("smoke") or not has_explicit_smoke)}
733
+ for sid, row in rows.items():
734
+ patterns = list(row.get("trigger_paths") or [])
735
+ for check_id in scenario_check_ids(row):
736
+ check = checks.get(check_id, {})
737
+ patterns.extend(check.get("inputs") or [])
738
+ patterns.extend(check.get("paths") or [])
739
+ affected = any(match_path(path, str(pattern)) for path in changes for pattern in patterns)
740
+ if (row.get("kind") == "e2e" and row.get("smoke")
741
+ and (set(row.get("depends_on", [])) & selected or affected)):
742
+ selected.add(sid)
743
+ pending = list(selected)
744
+ while pending:
745
+ for dependency in rows[pending.pop()].get("depends_on", []):
746
+ if dependency in rows and dependency not in selected:
747
+ selected.add(dependency)
748
+ pending.append(dependency)
749
+ owners = wanted | {str(rows[sid].get("owner")) for sid in selected if rows[sid].get("owner")}
750
+ scoped = [task for task in tasks if task[1] in owners]
751
+ ready = {task_id for _, task_id, status in scoped if status in FINISHED_STATUSES}
752
+ return scoped, sorted(selected), sorted(owners - ready)
753
+
754
+
755
+ def _verify_layers(root: str, directory: str, layers: Optional[str] = None,
756
+ profile: Optional[str] = None,
757
+ only_tasks: Optional[Sequence[str]] = None,
758
+ resume: bool = True, deadline: Optional[float] = None,
759
+ max_failures: int = 0, fail_fast: bool = False,
760
+ jobs: int = 0) -> dict[str, object]:
761
+ """Run chosen layers; only_tasks limits the batch smoke gate to merged TASKs."""
762
+ started = time.monotonic()
763
+ recover_transition(root)
764
+ selected = parse_verify_layers(layers)
765
+ resolved_profile = parse_verify_profile(profile)
766
+ if selected == ("validators",) and only_tasks is None:
767
+ return _verify_validators(root, directory, resolved_profile)
768
+ tasks = demand_tasks(Path(directory))
769
+ if only_tasks is not None:
770
+ wanted = {str(task_id) for task_id in only_tasks}
771
+ known = {task_id for _, task_id, _ in tasks}
772
+ if wanted - known:
773
+ return {"decision": "block", "reason": "unknown_tasks", "tasks": sorted(wanted - known)}
774
+ tasks, partial_ids, not_ready = _batch_scope(root, directory, tasks, wanted)
775
+ if not_ready:
776
+ return {"decision": "block", "reason": "not_ready", "tasks": not_ready,
777
+ "scope": {"type": "partial", "tasks": sorted(wanted)}, "requirement_verified": False}
778
+ else:
779
+ partial_ids = None
780
+ unfinished = [task_id for _, task_id, status in tasks if status not in FINISHED_STATUSES]
781
+ if unfinished:
782
+ return {"decision": "block", "reason": "unfinished_tasks", "tasks": unfinished}
783
+ if (Path(root) / ".code-flow/.active-task.json").exists():
784
+ return {"decision": "block", "reason": "finish_active_task_first"}
785
+ requirement_gate = only_tasks is None and set(selected) == set(VERIFY_LAYERS)
786
+ layer_results: dict[str, dict[str, object]] = {}
787
+ for name in selected:
788
+ if name == "validators":
789
+ layer_results[name] = _validators_layer(root, resolved_profile)
790
+ elif name == "review":
791
+ layer_results[name] = _review_layer(root, directory)
792
+ else:
793
+ layer_results[name] = _e2e_layer(
794
+ root, directory, tasks, requirement_gate,
795
+ profile=resolved_profile, resume=resume, deadline=deadline,
796
+ max_failures=max_failures, fail_fast=fail_fast, jobs=jobs, partial_ids=partial_ids,
797
+ )
798
+ result = _aggregate_layers(layer_results, resolved_profile, selected)
799
+ if "validators" in layer_results:
800
+ _validation_report(result, layer_results["validators"], selected)
801
+ if "e2e" in layer_results:
802
+ _combine_acceptance_timing(result, layer_results["e2e"], started)
803
+ if only_tasks is not None:
804
+ result["scope"] = {**result.get("scope", {}), "type": "partial", "tasks": sorted(wanted),
805
+ "scenarios": partial_ids}
806
+ result["requirement_verified"] = False
807
+ if (requirement_gate and resolved_profile == "full" and result["decision"] == "pass"
808
+ and (result.get("reason") != "nothing_to_verify" or not _acceptance_required(tasks))):
809
+ return complete_requirement_verification(root, directory, tasks, result)
810
+ return result
811
+
812
+
813
+ def _append_spec_reporting(result: dict[str, object], reports: Sequence[Mapping[str, object]],
814
+ started: float) -> None:
815
+ checks = sorted((dict(item) for item in reports if item.get("kind") == "spec_check"),
816
+ key=lambda item: str(item.get("check_id", "")))
817
+ if not checks:
818
+ return
819
+ result["spec_checks"] = checks
820
+ spec_executed = sum(1 for item in checks if not item.get("reused"))
821
+ spec_reused = len(checks) - spec_executed
822
+ review = result.get("layers", {}).get("review")
823
+ if isinstance(review, dict):
824
+ result["executed"] += spec_executed - int(review.get("executed", 0))
825
+ result["reused"] += spec_reused - int(review.get("reused", 0))
826
+ review["executed"], review["reused"] = spec_executed, spec_reused
827
+ if isinstance(review.get("review"), dict):
828
+ review["review"].update(executed=spec_executed, reused=spec_reused)
829
+ prefix = str(result.get("summary") or "")
830
+ result["summary"] = (prefix + "; " if prefix else "") + f"spec: executed {spec_executed}, reused {spec_reused}"
831
+ result.setdefault("execution_identities", []).extend(item["identity"] for item in checks if item.get("identity"))
832
+ timings = result.setdefault("timings", {key: 0.0 for key in
833
+ ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms")})
834
+ timings["execution_ms"] += sum(float(item.get("execution_ms", 0)) for item in checks)
835
+ for report in reports:
836
+ if report.get("kind") == "spec_phases":
837
+ phases = report.get("timings") or {}
838
+ for key in ("planning_ms", "preflight_ms", "evidence_ms"):
839
+ timings[key] += float(phases.get(key, 0))
840
+ timings["elapsed_ms"] = (time.monotonic() - started) * 1000
841
+ result.setdefault("timings_scope", []).append("spec")
842
+ result.setdefault("scope", {"layers": result.get("selected_layers", [])})
843
+ result.setdefault("input_change_category", ["unchanged" if spec_reused else "unproven"])
844
+ if "selection_reason" in result:
845
+ reasons = result.setdefault("selection_reasons", {})
846
+ if not reasons:
847
+ reasons["validators" if "validators" in result.get("timings_scope", []) else "other"] = result["selection_reason"]
848
+ reasons["spec"] = "required_spec_bindings"
849
+ result["selection_reason"] = "multiple_layer_policies"
850
+ else:
851
+ result["selection_reason"] = "required_spec_bindings"
852
+ if any(item.get("reused") for item in checks):
853
+ result["reuse_reason"] = "evidence_reused"
854
+ else:
855
+ result.setdefault("reuse_reason", "no_reused_results")
856
+
857
+
858
+ def verify_layers(root: str, directory: str, layers: Optional[str] = None,
859
+ profile: Optional[str] = None, only_tasks: Optional[Sequence[str]] = None,
860
+ resume: bool = True, deadline: Optional[float] = None, max_failures: int = 0,
861
+ fail_fast: bool = False, jobs: int = 0) -> dict[str, object]:
862
+ started = time.monotonic()
863
+ with report_session() as reports:
864
+ result = _verify_layers(root, directory, layers, profile, only_tasks, resume,
865
+ deadline, max_failures, fail_fast, jobs)
866
+ _append_spec_reporting(result, reports, started)
867
+ return result