@michaelxwb/code-flow 0.6.4 → 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 -40
  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 +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +67 -35
  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 +73 -34
  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 -40
  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 +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  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 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  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 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  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} +37 -3
  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} +252 -26
  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 -299
  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,742 @@
1
+ """Executable task handoffs used by every platform command."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from datetime import datetime, timezone
6
+ import fnmatch
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ from typing import IO, Mapping, Optional, Sequence
13
+ import shutil
14
+ import sys
15
+ import time
16
+
17
+ from codeflow.cli.cf_acceptance_manifest import record_manual_evidence
18
+ from codeflow.acceptance.cf_acceptance_schema import load_manifest, verified_evidence
19
+ from codeflow.core.cf_exec_base import execution_session, redact_sensitive_payload, report_session, worktree_fingerprint
20
+ from codeflow.core import cf_git
21
+ from codeflow.cli.cf_spec_context import (ContextError, DEFAULT_ACTIVE_EXCLUDES, Decision, SpecContext,
22
+ apply_decision, load_active_task, load_context, save_context,
23
+ start_active_task)
24
+ from codeflow.spec.cf_stage_gate import evidence_deferral_kind
25
+ from codeflow.spec.cf_spec_context_model import context_sha256
26
+ from codeflow.task.cf_task_runtime import is_non_execution_error, run_done_gate
27
+ from codeflow.task.cf_requirement_verification import (
28
+ VERIFY_LAYERS, VERIFY_PROFILES, collect_requirement_bindings, collect_review_bindings,
29
+ demand_tasks as _demand_tasks, e2e_preflight as _e2e_preflight,
30
+ parse_verify_layers, parse_verify_profile, pending_manual_rules as _pending_manual_rules,
31
+ stale_functional_rows as _stale_functional_rows, verify_layers,
32
+ )
33
+ from codeflow.spec.cf_workflow_service import (FINISHED_STATUSES, require_active_task,
34
+ block_task, check_startable, complete_requirement_verification,
35
+ cleanup_session_projections, complete_task,
36
+ locate_task_file, provisional_task, read_task_section,
37
+ remove_session_projection, resume_task, task_truth_issues)
38
+ from codeflow.spec.cf_workflow_transaction import recover_transition
39
+
40
+
41
+ def _finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
42
+ require_active_task(root, task_id, directory, task_file)
43
+ gate = run_done_gate(root, directory, task_id=task_id)
44
+ if gate.decision == "provisional":
45
+ transition = provisional_task(root, directory, task_file, task_id, gate.message)
46
+ return {
47
+ "decision": "provisional",
48
+ "reason": gate.message,
49
+ "missing_checks": list(gate.missing_checks),
50
+ "next": gate.next_action,
51
+ "env_blocked_details": list(gate.env_blocked_details),
52
+ "deferred_review": gate.deferred_review,
53
+ "deferred_requirement": gate.deferred_requirement,
54
+ "deferred_budget": gate.deferred_budget,
55
+ "deferred_heavy": gate.deferred_heavy,
56
+ "evidence": gate.evidence,
57
+ **transition,
58
+ }
59
+ if gate.decision != "pass":
60
+ return {"decision": "block", "reason": gate.message, "evidence": gate.evidence,
61
+ "env_blocked_details": list(gate.env_blocked_details)}
62
+ completed = complete_task(root, directory, task_file, task_id, True)
63
+ # 完成后清理本任务的 Spec Session 投影(瞬时文件,避免往期规则被后续会话读到)
64
+ result = {
65
+ "decision": "pass",
66
+ "deferred_review": gate.deferred_review,
67
+ "deferred_requirement": gate.deferred_requirement,
68
+ "deferred_budget": gate.deferred_budget,
69
+ "deferred_heavy": gate.deferred_heavy,
70
+ "session_projection": remove_session_projection(root, task_file),
71
+ **completed,
72
+ }
73
+ hints = []
74
+ deferred_total = gate.deferred_review + gate.deferred_requirement + gate.deferred_budget
75
+ if deferred_total:
76
+ hints.append(f"{deferred_total} verifier(s) deferred; run cf-task:verify-e2e for the requirement directory")
77
+ if gate.deferred_heavy:
78
+ hints.append(f"{gate.deferred_heavy} heavy validator(s) deferred; archive / cf-validate runs them")
79
+ if hints:
80
+ result["deferred_hint"] = " | ".join(hints)
81
+ return result
82
+
83
+
84
+ def _finish_report(reports: Sequence[Mapping[str, object]], started: float) -> dict[str, object]:
85
+ acceptance = next((item for item in reports if item.get("kind") == "acceptance"), {})
86
+ timings = {key: 0.0 for key in ("planning_ms", "preflight_ms", "execution_ms", "evidence_ms")}
87
+ for report in reports:
88
+ values = report.get("timings") or {}
89
+ for key in timings:
90
+ timings[key] += float(values.get(key, 0))
91
+ if report.get("kind") == "spec_check":
92
+ timings["execution_ms"] += float(report.get("execution_ms", 0))
93
+ timings["elapsed_ms"] = (time.monotonic() - started) * 1000
94
+ scopes = list(dict.fromkeys("spec" if r.get("kind") in ("spec_check", "spec_phases")
95
+ else str(r.get("kind")) for r in reports))
96
+ identities = [str(identity) for report in reports for identity in report.get("execution_identities", [])]
97
+ identities.extend(str(report["identity"]) for report in reports if report.get("kind") == "spec_check" and report.get("identity"))
98
+ identities.sort()
99
+ reused = sum(int(report.get("reused", 0)) for report in reports)
100
+ executed = sum(int(report.get("executed", 0)) for report in reports if report.get("kind") == "acceptance")
101
+ executed += sum(max(0, int(report.get("selected", 0)) - int(report.get("reused", 0)))
102
+ for report in reports if report.get("kind") == "validators")
103
+ executed += sum(1 for report in reports if report.get("kind") == "spec_check" and not report.get("reused"))
104
+ return {"selection_reason": acceptance.get("selection_reason", "validator_triggers"),
105
+ "scope": {"task": acceptance.get("scope", {}), "layers": [r.get("kind") for r in reports]},
106
+ "reuse_reason": "evidence_reused" if reused else "no_reused_results", "executed": executed,
107
+ "reused": reused, "execution_identities": identities,
108
+ "input_change_categories": [{"layer": r.get("kind"), "categories": r["input_change_category"]}
109
+ for r in reports if "input_change_category" in r],
110
+ "summary": f"finish checks: executed {executed}, reused {reused}",
111
+ "timings": timings, "timings_scope": scopes, "layers": list(reports)}
112
+
113
+
114
+ def finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
115
+ started = time.monotonic()
116
+ with report_session() as reports:
117
+ result = _finish_task(root, directory, task_file, task_id)
118
+ if reports:
119
+ result["verification"] = _finish_report(reports, started)
120
+ result.update(_verification_reporting(result["verification"], started))
121
+ return result
122
+
123
+
124
+ _CAPABILITY_LINE = re.compile(r"^- \*\*(?:Capability|Demo)\*\*:[ \t]*(.*)$")
125
+
126
+
127
+ def _normalize_capability(value: str) -> str:
128
+ return " ".join(value.split()).casefold()
129
+
130
+
131
+ def _task_capability(task_file: Path, task_id: str) -> str:
132
+ """Read the user-demonstrable capability declared by one TASK section."""
133
+ try:
134
+ section = read_task_section(str(task_file), task_id)
135
+ except ValueError:
136
+ return ""
137
+ for line in section.splitlines():
138
+ match = _CAPABILITY_LINE.match(line)
139
+ if match:
140
+ return " ".join(match.group(1).split())
141
+ return ""
142
+
143
+
144
+ def _unique_ids(values: Sequence[str]) -> list[str]:
145
+ seen: set[str] = set()
146
+ chosen: list[str] = []
147
+ for value in values:
148
+ task_id = value.strip()
149
+ if task_id and task_id not in seen:
150
+ seen.add(task_id)
151
+ chosen.append(task_id)
152
+ return chosen
153
+
154
+
155
+ def _batch_selection(directory: Path, tasks: Sequence[str],
156
+ capability: str) -> tuple[list[tuple[Path, str, str]], str]:
157
+ """Resolve --batch IDs or a --capability name to TASK file/status tuples."""
158
+ available = _demand_tasks(directory)
159
+ by_id = {task_id: (task_file, status) for task_file, task_id, status in available}
160
+ if capability.strip():
161
+ wanted = _normalize_capability(capability)
162
+ selected = [
163
+ (task_file, task_id, status)
164
+ for task_file, task_id, status in available
165
+ if _normalize_capability(_task_capability(task_file, task_id)) == wanted
166
+ ]
167
+ if not selected:
168
+ known = sorted({_task_capability(f, t) for f, t, _ in available} - {""})
169
+ raise ValueError(
170
+ f"unknown_capability: {capability.strip()}; available: {', '.join(known) or 'none'}"
171
+ )
172
+ return selected, "capability"
173
+ chosen = _unique_ids(tasks)
174
+ if not chosen:
175
+ raise ValueError("batch_empty: pass --batch TASK-... or --capability <name>")
176
+ unknown = [task_id for task_id in chosen if task_id not in by_id]
177
+ if unknown:
178
+ raise ValueError(f"unknown_tasks: {', '.join(unknown)}")
179
+ return [(by_id[task_id][0], task_id, by_id[task_id][1]) for task_id in chosen], "batch"
180
+
181
+
182
+ def _batch_working_changes(root: str) -> tuple[str, ...]:
183
+ """Current business changes, ignoring the paths the active marker excludes."""
184
+ return tuple(sorted(
185
+ path for path in cf_git.changed_files(root)
186
+ if not any(fnmatch.fnmatchcase(path, pattern) for pattern in DEFAULT_ACTIVE_EXCLUDES)
187
+ ))
188
+
189
+
190
+ def _batch_active_task(root: str) -> str:
191
+ if not (Path(root) / ".code-flow/.active-task.json").exists():
192
+ return ""
193
+ return load_active_task(root).task_id
194
+
195
+
196
+ def _ensure_batch_marker(root: str, directory: str, task_id: str) -> bool:
197
+ """Open a transient marker for one batch task; returns True when created.
198
+
199
+ A pre-existing marker is only accepted for the same TASK. Other TASKs are
200
+ rejected: silently switching identity would let a batch finish arbitrary
201
+ work that was never started.
202
+ """
203
+ marker = Path(root) / ".code-flow/.active-task.json"
204
+ if marker.exists():
205
+ active = load_active_task(root)
206
+ if active.task_id != task_id:
207
+ raise ValueError(f"active_mismatch: marker={active.task_id} requested={task_id}")
208
+ if active.status != "active":
209
+ raise ValueError(f"active_not_ready: {active.task_id} is {active.status}")
210
+ return False
211
+ context = load_context(str(Path(directory) / "spec-context.yml"))
212
+ start_active_task(root, directory, task_id, context_sha256(context), _batch_working_changes(root))
213
+ return True
214
+
215
+
216
+ def _abandon_batch_marker(root: str) -> bool:
217
+ """Drop a batch marker without changing the Markdown view.
218
+
219
+ A failed TASK must stay open (draft/in-progress) and must not claim done;
220
+ the marker only holds the single active slot, so it cannot be left behind
221
+ while the rest of the batch continues.
222
+ """
223
+ marker = Path(root) / ".code-flow/.active-task.json"
224
+ if not marker.exists():
225
+ return False
226
+ from codeflow.cli.cf_spec_context import doctor_active_task
227
+
228
+ doctor_active_task(root, "", abandon=True)
229
+ return True
230
+
231
+
232
+ def _task_evidence_counts(evidence: Sequence[Mapping[str, object]]) -> tuple[int, int]:
233
+ executed = reused = 0
234
+ for item in evidence:
235
+ if evidence_deferral_kind(item) in ("review_stage", "heavy"):
236
+ continue
237
+ if is_non_execution_error(item.get("error_code")):
238
+ continue
239
+ details = item.get("details")
240
+ details = details if isinstance(details, Mapping) else {}
241
+ if details.get("cache_reused"):
242
+ reused += 1
243
+ else:
244
+ executed += 1
245
+ return executed, reused
246
+
247
+
248
+ def _acceptance_task_evidence(directory: str, task_id: str) -> list[dict[str, object]]:
249
+ """Project the TASK's persisted manifest rows into per-check evidence."""
250
+ manifest = Path(directory) / ".acceptance-manifest.json"
251
+ if not manifest.is_file():
252
+ return []
253
+ try:
254
+ data = load_manifest(manifest)
255
+ except (OSError, ValueError):
256
+ return []
257
+ items: list[dict[str, object]] = []
258
+ for row in data.get("scenarios") or ():
259
+ if not isinstance(row, Mapping):
260
+ continue
261
+ owner = row.get("owner")
262
+ if isinstance(owner, str) and owner and owner != task_id:
263
+ continue
264
+ record = row.get("evidence")
265
+ if not isinstance(record, Mapping):
266
+ continue
267
+ scenario_id = str(row.get("id", ""))
268
+ check_id = str(row.get("check") or scenario_id)
269
+ verified = row.get("status") == "verified"
270
+ items.append({
271
+ "verifier_ref": check_id,
272
+ "check_id": check_id,
273
+ "status": "verified" if verified else "unverified",
274
+ "error_code": None if verified else "acceptance_failed",
275
+ "details": {"scenario": scenario_id, "cache_reused": bool(record.get("cache_reused"))},
276
+ })
277
+ return items
278
+
279
+
280
+ def _finish_batch_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
281
+ """Run one TASK inside the shared batch execution session."""
282
+ activated = _ensure_batch_marker(root, directory, task_id)
283
+ try:
284
+ gate = run_done_gate(root, directory, task_id=task_id)
285
+ evidence = [*gate.evidence, *_acceptance_task_evidence(directory, task_id)]
286
+ executed, reused = _task_evidence_counts(evidence)
287
+ if gate.decision == "pass":
288
+ completed = complete_task(root, directory, task_file, task_id, True)
289
+ result = {
290
+ **completed,
291
+ "task": task_id,
292
+ "decision": "pass",
293
+ "status": "done",
294
+ "activated": activated,
295
+ "executed": executed,
296
+ "reused": reused,
297
+ "evidence": evidence,
298
+ "session_projection": remove_session_projection(root, task_file),
299
+ }
300
+ return result
301
+ result = {
302
+ "task": task_id,
303
+ "decision": gate.decision,
304
+ "status": "unchanged",
305
+ "reason": gate.message,
306
+ "activated": activated,
307
+ "executed": executed,
308
+ "reused": reused,
309
+ "missing_checks": list(gate.missing_checks),
310
+ "next": gate.next_action,
311
+ "evidence": evidence,
312
+ }
313
+ result["marker_cleared"] = _abandon_batch_marker(root)
314
+ return result
315
+ except Exception:
316
+ _abandon_batch_marker(root)
317
+ raise
318
+
319
+
320
+ def _aggregate_batch_checks(
321
+ results: Sequence[Mapping[str, object]],
322
+ ) -> tuple[list[dict[str, object]], int, int]:
323
+ """Fold per-task evidence into one entry per check (executed once, reused N)."""
324
+ checks: dict[str, dict[str, object]] = {}
325
+ order: list[str] = []
326
+ for result in results:
327
+ task = str(result.get("task", ""))
328
+ evidence = result.get("evidence")
329
+ if not isinstance(evidence, Sequence):
330
+ continue
331
+ for item in evidence:
332
+ if not isinstance(item, Mapping):
333
+ continue
334
+ kind = evidence_deferral_kind(item)
335
+ if kind in ("review_stage", "heavy"):
336
+ continue
337
+ check_id = str(item.get("verifier_ref") or item.get("check_id") or "")
338
+ if not check_id:
339
+ continue
340
+ entry = checks.get(check_id)
341
+ if entry is None:
342
+ entry = {"check_id": check_id, "status": "verified", "tasks": [],
343
+ "executions": 0, "reused": 0}
344
+ checks[check_id] = entry
345
+ order.append(check_id)
346
+ if task and task not in entry["tasks"]:
347
+ entry["tasks"].append(task)
348
+ if item.get("status") != "verified":
349
+ entry["status"] = "failed"
350
+ if is_non_execution_error(item.get("error_code")):
351
+ continue
352
+ if entry["executions"] == 0:
353
+ entry["executions"] = 1
354
+ else:
355
+ entry["reused"] = int(entry["reused"]) + 1
356
+ aggregated = [checks[check_id] for check_id in order]
357
+ executed = sum(1 for item in aggregated if int(item["executions"]) > 0)
358
+ reused = sum(int(item["reused"]) for item in aggregated)
359
+ return aggregated, executed, reused
360
+
361
+
362
+ def _run_batch_tasks(root: str, directory: str,
363
+ selected: Sequence[tuple[Path, str, str]]) -> list[dict[str, object]]:
364
+ results: list[dict[str, object]] = []
365
+ with execution_session():
366
+ for task_file, task_id, status in selected:
367
+ if status in FINISHED_STATUSES:
368
+ results.append({"task": task_id, "decision": "pass", "status": "already_done",
369
+ "reason": "already_done", "activated": False,
370
+ "executed": 0, "reused": 0, "evidence": []})
371
+ continue
372
+ blockers = check_startable(str(task_file), task_id)
373
+ if blockers:
374
+ results.append({"task": task_id, "decision": "block", "status": status,
375
+ "reason": "start_blocked", "blockers": blockers,
376
+ "activated": False, "executed": 0, "reused": 0, "evidence": []})
377
+ continue
378
+ try:
379
+ results.append(_finish_batch_task(root, directory, str(task_file), task_id))
380
+ except (OSError, ValueError) as exc:
381
+ results.append({"task": task_id, "decision": "block", "status": status,
382
+ "reason": "batch_task_error", "error": str(exc),
383
+ "activated": False, "executed": 0, "reused": 0, "evidence": []})
384
+ return results
385
+
386
+
387
+ def _batch_payload(results: Sequence[Mapping[str, object]], selected: Sequence[tuple[Path, str, str]],
388
+ mode: str, capability: str) -> dict[str, object]:
389
+ checks, executed, reused = _aggregate_batch_checks(results)
390
+ decisions = {str(item.get("decision")) for item in results}
391
+ decision = "block" if "block" in decisions else "provisional" if "provisional" in decisions else "pass"
392
+ failed = [str(item["task"]) for item in results if item.get("decision") == "block"]
393
+ first_reason = next(
394
+ (str(item.get("reason", "")) for item in results if item.get("decision") != "pass"), ""
395
+ )
396
+ return {
397
+ "decision": decision,
398
+ "action": "finish-batch",
399
+ "reason": "batch_failed: " + ", ".join(failed) if failed else first_reason,
400
+ "selection": {"mode": mode, "tasks": [item[1] for item in selected],
401
+ "capability": capability.strip() or None},
402
+ "results": list(results),
403
+ "evidence": checks,
404
+ "executed": executed,
405
+ "reused": reused,
406
+ "failed": failed,
407
+ "env_blocked": sum(
408
+ 1 for item in results if str(item.get("reason", "")).startswith("env")
409
+ ),
410
+ "incomplete": sum(1 for item in results if item.get("decision") == "provisional"),
411
+ }
412
+
413
+
414
+ def finish_batch(root: str, directory: str, tasks: Sequence[str] = (),
415
+ capability: str = "") -> dict[str, object]:
416
+ """Batch Done Gate: one shared execution session for a TASK list/capability.
417
+
418
+ Every selected TASK is gated independently, so one failure does not mark
419
+ the others done or block their completion. Code checks shared by the batch
420
+ execute once inside the session; their evidence is folded per check.
421
+ """
422
+ recover_transition(root)
423
+ selected, mode = _batch_selection(Path(directory), tasks, capability)
424
+ active_id = _batch_active_task(root)
425
+ selected_ids = [task_id for _, task_id, _ in selected]
426
+ if active_id and active_id not in selected_ids:
427
+ return {
428
+ "decision": "block",
429
+ "action": "finish-batch",
430
+ "reason": "active_mismatch",
431
+ "detail": f"marker={active_id}; batch={','.join(selected_ids)}",
432
+ "results": [],
433
+ }
434
+ # The active TASK must be consumed before any transient marker is opened.
435
+ selected.sort(key=lambda item: 0 if item[1] == active_id else 1)
436
+ results = _run_batch_tasks(root, directory, selected)
437
+ payload = _batch_payload(results, selected, mode, capability)
438
+ completed = [str(item["task"]) for item in results if item.get("decision") == "pass"]
439
+ if completed and (Path(directory) / ".acceptance-manifest.json").is_file():
440
+ smoke = verify_layers(root, directory, layers="validators,e2e", profile="smoke", only_tasks=completed)
441
+ payload["batch_smoke"] = smoke
442
+ if smoke.get("decision") != "pass":
443
+ payload["decision"] = "block"
444
+ payload["reason"] = "batch_smoke_failed"
445
+ return payload
446
+
447
+
448
+ def _complete_requirement_verification(root: str, directory: str,
449
+ tasks: Sequence[tuple[Path, str, str]],
450
+ result: dict[str, object]) -> dict[str, object]:
451
+ return complete_requirement_verification(root, directory, tasks, result)
452
+
453
+
454
+ def verify_e2e(root: str, directory: str, layers: Optional[str] = None,
455
+ profile: Optional[str] = None, resume: bool = True,
456
+ deadline: Optional[float] = None, max_failures: int = 0,
457
+ fail_fast: bool = False, jobs: int = 0) -> dict[str, object]:
458
+ """Legacy alias: all layers under the explicit full profile."""
459
+ result = verify_layers(root, directory, layers or ",".join(VERIFY_LAYERS), profile or "full",
460
+ resume=resume, deadline=deadline, max_failures=max_failures,
461
+ fail_fast=fail_fast, jobs=jobs)
462
+ layer_map = result.get("layers", {})
463
+ if "review" in layer_map:
464
+ review = layer_map["review"]
465
+ result["review"] = review.get("review", {
466
+ "executed": review.get("executed", 0),
467
+ "reused": review.get("reused", 0),
468
+ "failed": review.get("failed", []),
469
+ })
470
+ result["executed"] = review.get("executed", 0)
471
+ result["reused"] = review.get("reused", 0)
472
+ result["failed"] = review.get("failed", [])
473
+ if "validators" in layer_map:
474
+ result["validation"] = layer_map["validators"].get("validation", {
475
+ "decision": layer_map["validators"].get("decision"),
476
+ "reason": layer_map["validators"].get("reason", ""),
477
+ "reused": layer_map["validators"].get("reused", 0),
478
+ "failures": layer_map["validators"].get("failed", []),
479
+ })
480
+ if "e2e" in layer_map:
481
+ for key in ("results", "checks", "error"):
482
+ if key in layer_map["e2e"]:
483
+ result[key] = layer_map["e2e"][key]
484
+ return result
485
+
486
+
487
+ def _archived_risks(directory: Path) -> list[dict[str, object]]:
488
+ """Accepted risks are archived distinctly from verified evidence."""
489
+ manifest = directory / ".acceptance-manifest.json"
490
+ if not manifest.is_file():
491
+ return []
492
+ try:
493
+ data = json.loads(manifest.read_text(encoding="utf-8"))
494
+ except (OSError, ValueError, json.JSONDecodeError):
495
+ return []
496
+ risks = []
497
+ for check in data.get("checks", []) if isinstance(data.get("checks"), list) else []:
498
+ if isinstance(check, Mapping) and isinstance(check.get("accepted_risk"), Mapping):
499
+ risks.append({"check_id": check.get("id"), **dict(check["accepted_risk"])})
500
+ return risks
501
+
502
+
503
+ def _verification_reporting(verification: Mapping[str, object], started: float) -> dict[str, object]:
504
+ fields = ("selection_reason", "selection_reasons", "reuse_reason", "scope", "summary", "executed", "reused",
505
+ "execution_identities", "input_change_category", "input_change_categories", "timings_scope")
506
+ report = {key: verification[key] for key in fields if key in verification}
507
+ if isinstance(verification.get("timings"), Mapping):
508
+ report["timings"] = {**verification["timings"], "elapsed_ms": (time.monotonic() - started) * 1000}
509
+ return report
510
+
511
+
512
+ def _archive_documents(root: str, directory: Path) -> dict[str, str]:
513
+ """Include workflow inputs that the worktree fingerprint intentionally omits."""
514
+ base = Path(root)
515
+ spec_root = base / ".code-flow/specs"
516
+ candidates = [base / ".code-flow/config.yml", base / ".code-flow/validation.yml"]
517
+ candidates.extend(path for path in spec_root.rglob("*.md") if "_session" not in path.parts)
518
+ candidates.extend(path for path in directory.rglob("*.md")
519
+ if path.name.endswith((".design.md", ".prd.md", ".scenarios.md")))
520
+ return {str(path.relative_to(base)): hashlib.sha256(path.read_bytes()).hexdigest()
521
+ for path in sorted(candidates) if path.is_file()}
522
+
523
+
524
+ def _archive_snapshot(root: str, directory: Path) -> str:
525
+ """Capture immutable plan identity and worktree inputs before moving."""
526
+ from codeflow.acceptance.cf_acceptance_manifest_core import IMMUTABLE_SCENARIO_FIELDS, validate_execution_baseline
527
+ fingerprint = worktree_fingerprint(root)
528
+ if not fingerprint or not directory.is_dir():
529
+ raise ValueError("archive_snapshot_unavailable")
530
+ contexts = {str(path.relative_to(directory)): context_sha256(load_context(str(path)))
531
+ for path in sorted(directory.rglob("spec-context.yml"))}
532
+ manifest = directory / ".acceptance-manifest.json"
533
+ plan: object = None
534
+ if manifest.is_file():
535
+ data = load_manifest(manifest)
536
+ validate_execution_baseline(manifest, data)
537
+ plan = {"task_sha256": data.get("task_sha256"), "checks": data.get("checks"),
538
+ "scenarios": [{key: row.get(key) for key in IMMUTABLE_SCENARIO_FIELDS}
539
+ for row in data["scenarios"]]}
540
+ payload = json.dumps([fingerprint, contexts, plan, _archive_documents(root, directory)],
541
+ ensure_ascii=False, sort_keys=True, default=str)
542
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
543
+
544
+
545
+ def archive_requirement(root: str, directory: str) -> dict[str, object]:
546
+ """Runtime archive gate: full verify first, then move the requirement dir."""
547
+ from datetime import date
548
+ from pathlib import Path as _Path
549
+
550
+ started = time.monotonic()
551
+ try:
552
+ snapshot = _archive_snapshot(root, Path(directory))
553
+ except (OSError, ValueError) as exc:
554
+ return {"decision": "block", "reason": "archive_snapshot_unavailable", "detail": str(exc)}
555
+ result = verify_layers(root, directory, layers=",".join(VERIFY_LAYERS), profile="full")
556
+ if result.get("decision") != "pass":
557
+ return {"decision": "block", "reason": "archive_verify_failed", "verify": result,
558
+ **_verification_reporting(result, started)}
559
+ source = _Path(directory)
560
+ try:
561
+ if snapshot != _archive_snapshot(root, source):
562
+ return {"decision": "block", "reason": "archive_snapshot_changed", "verify": result}
563
+ except (OSError, ValueError) as exc:
564
+ return {"decision": "block", "reason": "archive_snapshot_unavailable", "detail": str(exc), "verify": result}
565
+ accepted_risks = _archived_risks(source)
566
+ base = _Path(root) / ".code-flow/tasks/archived" / date.today().isoformat()
567
+ target = base / source.name
568
+ suffix = 2
569
+ while target.exists():
570
+ target = base / f"{source.name}-{suffix}"
571
+ suffix += 1
572
+ base.mkdir(parents=True, exist_ok=True)
573
+ shutil.move(str(source), str(target))
574
+ result = {"decision": "pass", "archived_to": str(target), "verify": result,
575
+ **_verification_reporting(result, started)}
576
+ if accepted_risks:
577
+ result["accepted_risks"] = accepted_risks
578
+ return result
579
+
580
+
581
+ def confirm_manual(root: str, directory: str, refs: Sequence[str], scenarios: Sequence[str],
582
+ confirmed_by: str, source: str, reason: str = "", evidence: str = "") -> dict[str, object]:
583
+ """用户确认后批量写入 manual 验收记录(review 层 spec 规则 + manifest 场景)。
584
+
585
+ 只有用户明确确认后才能调用;Agent 不得代确认(agent 身份会被拒绝)。
586
+ """
587
+ owner = confirmed_by.strip()
588
+ if not owner:
589
+ raise ValueError("confirm-manual 需要 --confirmed-by <用户身份>")
590
+ if not source.strip():
591
+ raise ValueError("confirm-manual 需要 --source <用户回复原文>")
592
+ targets = collect_review_bindings(Path(directory))
593
+ pending_rules, confirmations = _pending_manual_rules(root, targets)
594
+ known = {str(item["ref"]) for item in pending_rules}
595
+ for spec_id, by_rule in confirmations.items():
596
+ known.update(f"{spec_id}#{rule_ref}" for rule_ref in by_rule)
597
+ chosen = [ref.strip() for ref in refs if ref.strip()] or sorted(str(item["ref"]) for item in pending_rules)
598
+ unknown = sorted(ref for ref in chosen if ref not in known)
599
+ if unknown:
600
+ raise ValueError(f"unknown_or_not_review_manual_ref: {', '.join(unknown)}")
601
+ reason_text = reason.strip() or "需求级人工验收确认"
602
+ confirmed_at = datetime.now(timezone.utc).isoformat()
603
+ records: dict[Path, SpecContext] = {}
604
+ for ref in chosen:
605
+ spec_id, rule_ref = ref.split("#", 1)
606
+ for ctx_path, _ in targets[spec_id]["rules"][rule_ref]:
607
+ context = records.get(ctx_path) or load_context(str(ctx_path))
608
+ decision = Decision("manual_verification", reason_text, owner, confirmed_at, source.strip(), None)
609
+ records[ctx_path] = apply_decision(context, spec_id, rule_ref, "review", decision)
610
+ for ctx_path in sorted(records, key=str):
611
+ save_context(str(ctx_path), records[ctx_path])
612
+ recorded_scenarios: list[str] = []
613
+ manifest = Path(directory) / ".acceptance-manifest.json"
614
+ if manifest.is_file():
615
+ data = load_manifest(manifest)
616
+ rows = {str(row.get("id")): row for row in data.get("scenarios", [])}
617
+ if scenarios:
618
+ chosen_scenarios = [sid.strip() for sid in scenarios if sid.strip()]
619
+ else:
620
+ chosen_scenarios = [sid for sid, row in rows.items()
621
+ if row.get("kind") == "manual" and not verified_evidence(row)]
622
+ for sid in chosen_scenarios:
623
+ row = rows.get(sid)
624
+ if row is None or (row.get("kind") != "manual" and row.get("exec") != "manual"):
625
+ raise ValueError(f"unknown_manual_scenario: {sid}")
626
+ if verified_evidence(row):
627
+ continue
628
+ record_manual_evidence(str(manifest), sid, owner, evidence.strip() or source.strip())
629
+ recorded_scenarios.append(sid)
630
+ if not chosen and not recorded_scenarios:
631
+ raise ValueError("no_pending_manual_confirmation")
632
+ return {
633
+ "ok": True,
634
+ "rules": chosen,
635
+ "scenarios": recorded_scenarios,
636
+ "confirmed_by": owner,
637
+ "confirmed_at": confirmed_at,
638
+ "note": "确认已写入 spec-context / manifest;重跑 verify-e2e 完成终验",
639
+ }
640
+
641
+
642
+ def _verify_layers_argument(layers: str, checks: bool) -> Optional[str]:
643
+ """Map the cf-validate compatible ``--checks`` flag onto validators only."""
644
+ if not checks:
645
+ return layers or None
646
+ if layers and parse_verify_layers(layers) != ("validators",):
647
+ raise ValueError("--checks is an alias for --layers validators; pass one of them")
648
+ return "validators"
649
+
650
+
651
+ def _verification_arguments(parser: argparse.ArgumentParser, action: str) -> None:
652
+ parser.add_argument("--layers", default="", help="verify layers: validators,review,e2e (default all)")
653
+ parser.add_argument("--checks", action="store_true",
654
+ help="alias for --layers validators (cf-validate compatible entry)")
655
+ default_profile = "full" if action == "verify-e2e" else "changed"
656
+ parser.add_argument("--profile", default="", choices=VERIFY_PROFILES,
657
+ help=f"verify profile (default {default_profile})")
658
+ parser.add_argument("--resume", action="store_true", default=False, help="resume from the first unfinished check")
659
+ parser.add_argument("--deadline", type=float, default=0.0, help="deadline in seconds from now (0 = unbounded)")
660
+ parser.add_argument("--max-failures", type=int, default=0, help="stop after N failed checks (0 = unlimited)")
661
+ parser.add_argument("--fail-fast", action="store_true", default=False, help="stop after the first failed check")
662
+ parser.add_argument("--jobs", type=int, default=0, help="parallel check workers (0 = automatic)")
663
+ if action == "verify":
664
+ parser.add_argument("--tasks", default="", help="verify selected completed TASKs, comma-separated")
665
+
666
+
667
+ def _workflow_parser() -> argparse.ArgumentParser:
668
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or None,
669
+ allow_abbrev=False)
670
+ actions = parser.add_subparsers(dest="action", required=True)
671
+ for name in ("finish", "block", "resume", "verify", "verify-e2e",
672
+ "confirm-manual", "cleanup-session", "archive"):
673
+ action = actions.add_parser(name, allow_abbrev=False)
674
+ action.add_argument("--root", default=os.getcwd())
675
+ action.add_argument("--task-dir", required=True)
676
+ action.add_argument("--json", action="store_true")
677
+ if name in ("verify", "verify-e2e"):
678
+ _verification_arguments(action, name)
679
+ if name in ("finish", "block", "resume"):
680
+ action.add_argument("--task", default="")
681
+ if name == "finish":
682
+ action.add_argument("--batch", default="", help="finish multiple TASKs: TASK-001,TASK-002")
683
+ action.add_argument("--capability", default="", help="finish TASKs whose Capability matches")
684
+ if name in ("block", "confirm-manual"):
685
+ action.add_argument("--reason", default="")
686
+ if name == "confirm-manual":
687
+ for flag in ("--refs", "--scenarios", "--confirmed-by", "--source", "--evidence"):
688
+ action.add_argument(flag, default="")
689
+ return parser
690
+
691
+
692
+ def _verify_from_arguments(args: argparse.Namespace, root: str, directory: str) -> dict[str, object]:
693
+ layers = _verify_layers_argument(args.layers, args.checks)
694
+ verify = verify_e2e if args.action == "verify-e2e" else verify_layers
695
+ tasks = [item.strip() for item in getattr(args, "tasks", "").split(",") if item.strip()]
696
+ if tasks and (len(set(tasks)) != len(tasks) or any(not re.fullmatch(r"TASK-\d+", task) for task in tasks)):
697
+ raise ValueError("--tasks must contain distinct TASK IDs")
698
+ scope = {"only_tasks": tasks} if tasks else {}
699
+ return verify(root, directory, layers, args.profile or None, **scope, resume=args.resume,
700
+ deadline=(time.monotonic() + args.deadline) if args.deadline else None,
701
+ max_failures=args.max_failures, fail_fast=args.fail_fast, jobs=args.jobs)
702
+
703
+
704
+ def _dispatch_action(args: argparse.Namespace, root: str, directory: str) -> dict[str, object]:
705
+ if args.action == "archive":
706
+ return archive_requirement(root, directory)
707
+ if args.action in ("verify", "verify-e2e"):
708
+ return _verify_from_arguments(args, root, directory)
709
+ if args.action == "cleanup-session":
710
+ return cleanup_session_projections(root, directory)
711
+ if args.action == "confirm-manual":
712
+ refs = [item.strip() for item in args.refs.split(",") if item.strip()]
713
+ scenarios = [item.strip() for item in args.scenarios.split(",") if item.strip()]
714
+ return confirm_manual(root, directory, refs, scenarios, args.confirmed_by, args.source,
715
+ args.reason, args.evidence)
716
+ if args.action == "finish" and (args.batch or args.capability):
717
+ return finish_batch(root, directory, [item for item in args.batch.split(",") if item.strip()],
718
+ args.capability)
719
+ task = locate_task_file(directory, args.task)
720
+ if task is None:
721
+ raise ValueError("task file missing or ambiguous")
722
+ if args.action == "finish":
723
+ return finish_task(root, directory, str(task), args.task)
724
+ if args.action == "block":
725
+ return block_task(root, directory, str(task), args.task, args.reason)
726
+ return resume_task(root, directory, str(task), args.task)
727
+
728
+
729
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
730
+ args = _workflow_parser().parse_args(argv)
731
+ try:
732
+ root = str(Path(args.root).resolve())
733
+ directory = str((Path(root) / args.task_dir).resolve())
734
+ if Path(root) not in Path(directory).parents:
735
+ raise ValueError("task directory outside project")
736
+ recover_transition(root)
737
+ result = _dispatch_action(args, root, directory)
738
+ stdout.write(json.dumps(redact_sensitive_payload(result), ensure_ascii=False))
739
+ return 3 if result.get("decision") in ("block", "provisional") else 0
740
+ except (OSError, ValueError) as exc:
741
+ stdout.write(json.dumps(redact_sensitive_payload({"decision": "block", "error": str(exc)}), ensure_ascii=False))
742
+ return 2