@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,1142 @@
1
+ #!/usr/bin/env python3
2
+ """Task-level worktree orchestration for parallel batch execution.
3
+
4
+ `cf-task:start` 整文件模式对拓扑批次内的独立 TASK 并行派发子 agent 时,本脚本
5
+ 负责可测试的确定性步骤,子 agent 派发与合并冲突解决仍由命令提示词编排:
6
+
7
+ prepare 预检(git 仓库 / worktree 忽略规则 / 无 active marker / tracked 干净)
8
+ → 自动提交仅位于需求目录内的流程产物 → 校验任务可并行 → 建 worktree+分支
9
+ collect 回并前校验:改动已提交、Status 已 done/verified、marker 已清理
10
+ merge 按 TASK-ID 顺序回并:worktree 内 rebase → 状态文件冲突按确定性并集规则
11
+ 解决 → 主区 --no-ff 合并 → refresh 收敛;代码文件冲突保留人工(fail-closed)
12
+ cleanup 合并完成后移除 worktree;已合入的分支一并删除,未合入的保留可追溯
13
+
14
+ 协议(详见 cf-task:start 命令正文):
15
+ - worktree: .code-flow/worktrees/<run-id>/<TASK-ID>/
16
+ - branch: cf-task/<slug>/<run-id>/<TASK-ID>
17
+ - 元数据: .code-flow/worktrees/<run-id>/run.json(base head / 任务清单)
18
+
19
+ stdout 始终为 JSON:{"ok": true, ...} 或 {"ok": false, "code": ..., "message": ...}。
20
+ 预检失败时命令方回退串行,不得手工绕过本脚本建 worktree/分支。
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import os
27
+ import json
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import IO, Mapping, Optional, Sequence
34
+
35
+ from codeflow.cli.cf_task_index import parse_task_file
36
+ from codeflow.cli.cf_task_index import task_batches
37
+ from codeflow.task.cf_task_merge import MergeConflictError
38
+ from codeflow.task.cf_task_merge import merge_context_text
39
+ from codeflow.task.cf_task_merge import merge_manifest_text
40
+ from codeflow.task.cf_task_merge import merge_task_markdown
41
+ from codeflow.task.cf_task_merge import stale_context_text
42
+ from codeflow.core.cf_task_state import FINISHED_STATUSES
43
+ from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids
44
+ from codeflow.evidence.cf_evidence_store import EvidenceStore
45
+ from codeflow.core.cf_exec_base import worktree_fingerprint
46
+ from codeflow.core import cf_git
47
+ from codeflow.core.cf_runtime_install import verify_install
48
+
49
+
50
+ WORKTREES_DIR = ".code-flow/worktrees"
51
+ MARKER = ".code-flow/.active-task.json"
52
+ GITIGNORE = ".code-flow/.gitignore"
53
+ _RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
54
+ _TASK_ID = re.compile(r"^TASK-\d+$")
55
+
56
+
57
+ class ParallelError(ValueError):
58
+ """Precondition or protocol violation with a stable machine code."""
59
+
60
+ def __init__(self, code: str, message: str,
61
+ details: Optional[Mapping[str, object]] = None) -> None:
62
+ super().__init__(message)
63
+ self.code = code
64
+ self.message = message
65
+ self.details = dict(details) if details else {}
66
+
67
+
68
+ class ParallelEvidenceError(ParallelError):
69
+ """Done-Gate evidence rejection carrying machine-readable missing checks."""
70
+
71
+ def __init__(
72
+ self,
73
+ code: str,
74
+ message: str,
75
+ missing_checks: Sequence[Mapping[str, object]] = (),
76
+ required_checks: Sequence[str] = (),
77
+ ) -> None:
78
+ super().__init__(code, message)
79
+ self.missing_checks = tuple(dict(item) for item in missing_checks)
80
+ self.required_checks = tuple(required_checks)
81
+
82
+
83
+ def _run_git(root: Path, arguments: Sequence[str]) -> str:
84
+ try:
85
+ return cf_git.run_git(str(root), arguments)
86
+ except cf_git.GitError as exc:
87
+ raise ParallelError(exc.code, exc.message) from exc
88
+
89
+
90
+ def _assert_repo(root: Path) -> None:
91
+ try:
92
+ top = _run_git(root, ("rev-parse", "--show-toplevel")).strip()
93
+ except ParallelError as exc:
94
+ raise ParallelError("git_unavailable", "并行执行需要 git 仓库") from exc
95
+ if Path(top).resolve() != root:
96
+ raise ParallelError("not_project_root", f"root 必须是仓库根目录: {top}")
97
+
98
+
99
+ def _status_paths(root: Path) -> list[str]:
100
+ try:
101
+ return list(cf_git.changed_files(str(root)))
102
+ except cf_git.GitError as exc:
103
+ raise ParallelError(exc.code, exc.message) from exc
104
+
105
+
106
+ def _require_ignored(root: Path) -> None:
107
+ ignore = root / GITIGNORE
108
+ try:
109
+ text = ignore.read_text(encoding="utf-8")
110
+ except OSError as exc:
111
+ raise ParallelError("worktrees_not_ignored", f"缺少 {GITIGNORE}: {exc}") from exc
112
+ if "worktrees/" not in text.split():
113
+ raise ParallelError(
114
+ "worktrees_not_ignored", f"{GITIGNORE} 缺少 worktrees/ 规则,请先运行 code-flow init 升级"
115
+ )
116
+
117
+
118
+ def _require_no_active(root: Path) -> None:
119
+ if (root / MARKER).exists():
120
+ raise ParallelError("active_exists", "主 worktree 已有 active TASK,先 finish/doctor 后再并行")
121
+
122
+
123
+ def _commit_task_dir(root: Path, task_file: str) -> Optional[str]:
124
+ # Git porcelain emits forward-slash paths; compare in POSIX form so
125
+ # Windows backslash paths are not all classified as "outside".
126
+ task_dir = Path(task_file).parent.as_posix()
127
+ entries = _status_paths(root)
128
+ outside = [
129
+ path
130
+ for path in entries
131
+ if not (path == task_dir or path.startswith(f"{task_dir}/"))
132
+ ]
133
+ if outside:
134
+ sample = ", ".join(outside[:8]) + (" ..." if len(outside) > 8 else "")
135
+ raise ParallelError(
136
+ "dirty_tree",
137
+ "并行要求 tracked 工作区干净(需求目录以外仍有改动: "
138
+ f"{sample})。请先提交(git add -A && git commit -m 'wip')"
139
+ "或暂存(git stash push -u)后重试;需求目录内的流程产物会自动提交。",
140
+ )
141
+ if not entries:
142
+ return None
143
+ _run_git(root, ("add", "-A", "--", str(task_dir)))
144
+ _run_git(root, ("commit", "-q", "-m", f"chore(cf-task): plan {Path(task_file).stem}"))
145
+ return _run_git(root, ("rev-parse", "HEAD")).strip()
146
+
147
+
148
+ def _validate_batch(root: Path, task_file: str, tasks: Sequence[str]) -> str:
149
+ path = root / task_file
150
+ if not path.is_file():
151
+ raise ParallelError("task_file_missing", f"任务文件不存在: {task_file}")
152
+ try:
153
+ nodes = parse_task_file(str(path))
154
+ except ValueError as exc:
155
+ raise ParallelError("task_parse_error", str(exc)) from exc
156
+ by_id = {node.task_id: node for node in nodes}
157
+ for task in tasks:
158
+ if task not in by_id:
159
+ raise ParallelError("unknown_task", f"{task} 不在任务文件中")
160
+ if by_id[task].status != "draft":
161
+ raise ParallelError("task_not_draft", f"{task} 当前 Status 为 {by_id[task].status},不可并行激活")
162
+ try:
163
+ ready = task_batches(nodes)[0]
164
+ except ValueError as exc:
165
+ raise ParallelError("task_graph_error", str(exc)) from exc
166
+ not_ready = [task for task in tasks if task not in ready]
167
+ if not_ready:
168
+ raise ParallelError("task_not_ready", f"依赖未完成,本批不可激活: {', '.join(not_ready)}(可激活: {', '.join(ready)})")
169
+ return str(path)
170
+
171
+
172
+ def _branch_slug(task_file: str) -> str:
173
+ stem = Path(task_file).stem
174
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._")
175
+ return slug[:40] or "batch"
176
+
177
+
178
+ def _worktree_paths(root: Path, run_id: str, task: str, task_file: str) -> tuple[Path, str]:
179
+ branch = f"cf-task/{_branch_slug(task_file)}/{run_id}/{task}"
180
+ return root / WORKTREES_DIR / run_id / task, branch
181
+
182
+
183
+ def _run_id() -> str:
184
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
185
+
186
+
187
+ def _unique_run_id(root: Path) -> str:
188
+ """Second-precision run ids collide when two batches are dispatched in the
189
+ same second; that overwrote run.json and orphaned the first run's worktrees.
190
+ Suffix -2, -3, … until the run directory is free."""
191
+ base = _run_id()
192
+ identifier = base
193
+ counter = 1
194
+ while (root / WORKTREES_DIR / identifier).exists() or not _RUN_ID.match(identifier):
195
+ counter += 1
196
+ identifier = f"{base}-{counter}"
197
+ return identifier
198
+
199
+
200
+ def _write_run_meta(run_dir: Path, payload: dict[str, object]) -> None:
201
+ run_dir.mkdir(parents=True, exist_ok=True)
202
+ meta = run_dir / "run.json"
203
+ if meta.exists():
204
+ raise ParallelError("run_id_conflict", f"run 记录已存在: {meta}")
205
+ temporary = meta.with_suffix(".json.tmp")
206
+ temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
207
+ temporary.replace(meta)
208
+
209
+
210
+ def _read_run_meta(root: Path, run_id: str) -> dict[str, object]:
211
+ meta = root / WORKTREES_DIR / run_id / "run.json"
212
+ try:
213
+ data = json.loads(meta.read_text(encoding="utf-8"))
214
+ except (OSError, json.JSONDecodeError) as exc:
215
+ raise ParallelError("run_not_found", f"找不到并行记录 {meta}: {exc}") from exc
216
+ if not isinstance(data, dict) or not isinstance(data.get("base_head"), str) or not isinstance(data.get("task_file"), str):
217
+ raise ParallelError("run_corrupt", f"并行记录损坏(缺 base_head/task_file): {meta}")
218
+ return data
219
+
220
+
221
+ def _update_run_meta(root: Path, run_id: str, updates: Mapping[str, object]) -> None:
222
+ """Persist batch-level state (smoke status) without rewriting worktrees."""
223
+ data = _read_run_meta(root, run_id)
224
+ data.update(updates)
225
+ path = root / WORKTREES_DIR / run_id / "run.json"
226
+ temporary = path.with_suffix(".json.tmp")
227
+ temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
228
+ temporary.replace(path)
229
+
230
+
231
+ def _install_worktree_manifest(root: Path, worktree: Path) -> None:
232
+ source = root / ".code-flow/.runtime-install.json"
233
+ if not source.is_file():
234
+ return # Core-only repositories have no installed platform runtime.
235
+ try:
236
+ verify_install(str(root))
237
+ # Platform files such as .claude/settings.local.json are commonly
238
+ # gitignored, so a fresh worktree does not receive them from checkout.
239
+ # Copy every managed artifact that is missing before verifying.
240
+ manifest = json.loads(source.read_text(encoding="utf-8"))
241
+ managed = list(manifest.get("files", {})) + list(manifest.get("hook_contracts", {}))
242
+ for relative in managed:
243
+ if not isinstance(relative, str) or not relative:
244
+ continue
245
+ target_file = worktree / relative
246
+ if target_file.exists():
247
+ continue
248
+ source_file = root / relative
249
+ if not source_file.is_file():
250
+ continue
251
+ target_file.parent.mkdir(parents=True, exist_ok=True)
252
+ target_file.write_bytes(source_file.read_bytes())
253
+ target = worktree / ".code-flow/.runtime-install.json"
254
+ target.write_bytes(source.read_bytes())
255
+ verify_install(str(worktree))
256
+ except (OSError, ValueError) as exc:
257
+ raise ParallelError("worktree_runtime_invalid", f"worktree 运行时安装校验失败: {exc}") from exc
258
+
259
+
260
+ def _create_worktrees(root: Path, task_file: str, tasks: Sequence[str], run_id: str) -> dict[str, object]:
261
+ created: list[tuple[Path, str]] = []
262
+ base_head = _run_git(root, ("rev-parse", "HEAD")).strip()
263
+ try:
264
+ for task in tasks:
265
+ path, branch = _worktree_paths(root, run_id, task, task_file)
266
+ if path.exists() or _branch_exists(root, branch):
267
+ raise ParallelError("worktree_exists", f"{task} 的 worktree 或分支已存在: {branch}")
268
+ path.parent.mkdir(parents=True, exist_ok=True)
269
+ _run_git(root, ("worktree", "add", "-b", branch, str(path), "HEAD"))
270
+ created.append((path, branch))
271
+ if not (path / task_file).is_file():
272
+ raise ParallelError("task_file_missing", f"{task}: worktree 内缺少 {task_file}(需先提交任务文件)")
273
+ _install_worktree_manifest(root, path)
274
+ except ParallelError:
275
+ for path, branch in created:
276
+ _remove_worktree(root, path, force=True)
277
+ _run_git(root, ("branch", "-D", branch))
278
+ raise
279
+ return {"base_head": base_head, "created": created}
280
+
281
+
282
+ def _branch_exists(root: Path, branch: str) -> bool:
283
+ result = subprocess.run(
284
+ ("git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"),
285
+ cwd=str(root), capture_output=True, check=False,
286
+ )
287
+ return result.returncode == 0
288
+
289
+
290
+ def _delete_branch(root: Path, branch: str, force: bool = False) -> bool:
291
+ # 默认 -d 只删已合入分支;--force 表示用户明确接受未合入分支的删除。
292
+ flag = "-D" if force else "-d"
293
+ result = subprocess.run(
294
+ ("git", "branch", flag, branch), cwd=str(root), capture_output=True, check=False,
295
+ )
296
+ return result.returncode == 0
297
+
298
+
299
+ def _remove_worktree(root: Path, path: Path, force: bool) -> None:
300
+ arguments = ["worktree", "remove"]
301
+ if force:
302
+ arguments.append("--force")
303
+ arguments.append(str(path))
304
+ _run_git(root, tuple(arguments))
305
+
306
+
307
+ def _relative_task_file(root: Path, task_file: str) -> str:
308
+ candidate = Path(task_file)
309
+ absolute = candidate if candidate.is_absolute() else root / candidate
310
+ try:
311
+ return absolute.resolve().relative_to(root).as_posix()
312
+ except ValueError as exc:
313
+ raise ParallelError("task_file_outside_root", f"任务文件不在仓库内: {task_file}") from exc
314
+
315
+
316
+ def prepare(root: Path, task_file: str, tasks: Sequence[str], run_id: Optional[str]) -> dict[str, object]:
317
+ root = root.resolve()
318
+ _assert_repo(root)
319
+ task_file = _relative_task_file(root, task_file)
320
+ _require_ignored(root)
321
+ _require_no_active(root)
322
+ identifier = run_id or _unique_run_id(root)
323
+ if not _RUN_ID.match(identifier):
324
+ raise ParallelError("invalid_run_id", f"run-id 非法: {identifier}")
325
+ if (root / WORKTREES_DIR / identifier / "run.json").exists():
326
+ raise ParallelError(
327
+ "run_id_conflict",
328
+ f"run 记录已存在: {identifier}。请清理后重试(cleanup)或使用新的 run-id。",
329
+ )
330
+ if not tasks:
331
+ raise ParallelError("no_tasks", "未指定并行 TASK")
332
+ if len(set(tasks)) != len(tasks):
333
+ raise ParallelError("duplicate_task", "并行 TASK 列表存在重复")
334
+ for task in tasks:
335
+ if not _TASK_ID.match(task):
336
+ raise ParallelError("invalid_task_id", f"TASK-ID 非法: {task}")
337
+ _validate_batch(root, task_file, tasks)
338
+ _commit_task_dir(root, task_file)
339
+ try:
340
+ outcome = _create_worktrees(root, task_file, tasks, identifier)
341
+ except Exception:
342
+ try:
343
+ _run_git(root, ("worktree", "prune"))
344
+ except ParallelError:
345
+ pass
346
+ raise
347
+ worktrees = [
348
+ {"task": task, "branch": branch, "path": str(path)}
349
+ for task, (path, branch) in zip(tasks, outcome["created"])
350
+ ]
351
+ _write_run_meta(
352
+ root / WORKTREES_DIR / identifier,
353
+ {"version": 1, "run_id": identifier, "task_file": task_file,
354
+ "base_head": outcome["base_head"], "worktrees": worktrees},
355
+ )
356
+ return {"ok": True, "action": "prepare", "run_id": identifier, "task_file": task_file,
357
+ "base_head": outcome["base_head"], "worktrees": worktrees}
358
+
359
+
360
+ def _required_check_map(manifest: Mapping[str, object], task: str) -> dict[str, list[str]]:
361
+ """Required code checks: functional scenarios traceable to this TASK.
362
+
363
+ ``check`` is the schema3 execution identity. Schema2 manifests have no
364
+ scenario→check link, so evidence validation is skipped for them and the
365
+ status gate remains authoritative.
366
+ """
367
+ scenarios = manifest.get("scenarios")
368
+ if not isinstance(scenarios, list):
369
+ return {}
370
+ required: dict[str, list[str]] = {}
371
+ for item in scenarios:
372
+ if not isinstance(item, Mapping):
373
+ continue
374
+ if str(item.get("kind") or "functional") in ("manual", "e2e"):
375
+ continue
376
+ owner = item.get("owner")
377
+ if isinstance(owner, str) and owner and owner != task:
378
+ continue
379
+ scenario_id = item.get("id")
380
+ if not isinstance(scenario_id, str) or not scenario_id:
381
+ continue
382
+ check_ids = scenario_check_ids(item)
383
+ if not check_ids:
384
+ # schema2 has no check link: the scenario id is the evidence key.
385
+ check_ids = [scenario_id]
386
+ for check_id in check_ids:
387
+ required.setdefault(check_id, []).append(scenario_id)
388
+ return required
389
+
390
+
391
+ def _requires_acceptance(task_file: str, task: str) -> bool:
392
+ """True when the TASK owns Spec rules or acceptance scenarios."""
393
+ try:
394
+ text = Path(task_file).read_text(encoding="utf-8")
395
+ except (OSError, UnicodeError):
396
+ return False
397
+ match = re.search(rf"(?ms)^##\s+{re.escape(task)}:.*?(?=^##\s+TASK-|\Z)", text)
398
+ if match is None:
399
+ return False
400
+ body = match.group(0)
401
+ for field in ("Spec-Refs", "Acceptance-Refs"):
402
+ refs = re.search(rf"(?m)^- \*\*{field}\*\*:[ \t]*([^\n]*)", body)
403
+ if refs and refs.group(1).strip().upper() not in ("", "N/A"):
404
+ return True
405
+ return False
406
+
407
+
408
+ def _task_manifest(worktree: Path, task_file: str) -> Optional[dict[str, object]]:
409
+ path = worktree / Path(task_file).parent / ".acceptance-manifest.json"
410
+ if not path.is_file():
411
+ return None
412
+ try:
413
+ return load_manifest(path)
414
+ except (OSError, ValueError) as exc:
415
+ raise ParallelError("acceptance_manifest_invalid", f"acceptance manifest 无效: {exc}") from exc
416
+
417
+
418
+ def _run_matches(run: Mapping[str, object], expected_hash: str,
419
+ check: Optional[Mapping[str, object]] = None,
420
+ scenarios: Sequence[Mapping[str, object]] = ()) -> bool:
421
+ """A central evidence run proves this check against this worktree content."""
422
+ run_id = run.get("run_id")
423
+ status = run.get("status")
424
+ if status == "accepted_risk" and not _live_accepted_risk(check, scenarios):
425
+ return False
426
+ return (
427
+ isinstance(run_id, str)
428
+ and bool(run_id.strip())
429
+ and run.get("input_hash") == expected_hash
430
+ and (status == "accepted_risk" or run.get("exit_code") == 0)
431
+ and status in ("passed", "verified", "accepted_risk")
432
+ )
433
+
434
+
435
+ def _live_accepted_risk(check: Optional[Mapping[str, object]],
436
+ scenarios: Sequence[Mapping[str, object]]) -> bool:
437
+ from codeflow.acceptance.cf_acceptance_risk import accepted_risk
438
+ return bool(scenarios) and all(accepted_risk(check, row) is not None for row in scenarios)
439
+
440
+
441
+ def _evidence_reason(runs: Sequence[Mapping[str, object]], expected_hash: str) -> str:
442
+ if not runs:
443
+ return "not_executed"
444
+ passed = [
445
+ run for run in runs
446
+ if run.get("exit_code") == 0 and run.get("status") in ("passed", "verified")
447
+ ]
448
+ if not passed:
449
+ return "nonzero_exit"
450
+ if any(run.get("input_hash") == expected_hash for run in passed):
451
+ return "missing_run_id"
452
+ return "input_hash_mismatch"
453
+
454
+
455
+ def _missing_evidence(
456
+ worktree: Path, task: str, manifest: Mapping[str, object], task_file: str
457
+ ) -> tuple[list[dict[str, object]], list[str]]:
458
+ """Return checks without matching central evidence plus all required IDs."""
459
+ required = _required_check_map(manifest, task)
460
+ if not required:
461
+ return [], []
462
+ expected = worktree_fingerprint(str(worktree))
463
+ if not expected:
464
+ raise ParallelError(
465
+ "evidence_hash_unavailable",
466
+ f"{task}: 无法计算 worktree input_hash,拒绝用无法校验的旧证据放行",
467
+ )
468
+ from codeflow.acceptance.cf_check_inputs import check_input_hash
469
+ checks = {
470
+ str(item.get("id")): item
471
+ for item in (manifest.get("checks") if isinstance(manifest.get("checks"), list) else [])
472
+ if isinstance(item, Mapping) and item.get("id")
473
+ }
474
+ from codeflow.evidence.cf_evidence_store import requirement_key
475
+ store = EvidenceStore(worktree, requirement=requirement_key(Path(task_file).parent))
476
+ missing: list[dict[str, object]] = []
477
+ for check_id in sorted(required):
478
+ check = checks.get(check_id)
479
+ check_hash = (
480
+ str(check_input_hash(str(worktree), check, expected).get("input_hash") or expected)
481
+ if check is not None else expected
482
+ )
483
+ runs = store.list_runs(check_id)
484
+ rows = [row for row in manifest.get("scenarios", [])
485
+ if isinstance(row, Mapping) and row.get("id") in required[check_id]]
486
+ if not any(_run_matches(run, check_hash, check, rows) for run in runs):
487
+ missing.append({
488
+ "check_id": check_id,
489
+ "scenarios": sorted(required[check_id]),
490
+ "reason": _evidence_reason(runs, check_hash),
491
+ "expected_input_hash": check_hash,
492
+ })
493
+ return missing, sorted(required)
494
+
495
+
496
+ def _evidence_state(
497
+ worktree: Path, task: str, task_file: str
498
+ ) -> tuple[list[dict[str, object]], list[str]]:
499
+ manifest = _task_manifest(worktree, task_file)
500
+ if manifest is None:
501
+ if _requires_acceptance(str(worktree / task_file), task):
502
+ raise ParallelEvidenceError(
503
+ "required_evidence_missing",
504
+ f"{task}: 有 Spec-Refs 但缺少 .acceptance-manifest.json;先锁定验收计划并执行 required 检查",
505
+ [{"check_id": "manifest", "scenarios": [], "reason": "manifest_missing"}],
506
+ ["manifest"],
507
+ )
508
+ return [], []
509
+ return _missing_evidence(worktree, task, manifest, task_file)
510
+
511
+
512
+ def _evidence_summary(missing: Sequence[Mapping[str, object]]) -> str:
513
+ return ", ".join(
514
+ f"{item.get('check_id')}({','.join(str(s) for s in item.get('scenarios', []))}: {item.get('reason')})"
515
+ for item in missing
516
+ )
517
+
518
+
519
+ def _reject_unfinished(task: str, status: str, path: Path, task_file: str) -> None:
520
+ missing, required = _evidence_state(path, task, task_file)
521
+ detail = _evidence_summary(missing)
522
+ suffix = f";先补 required 检查: {detail}" if detail else ";先补 required 检查后重新 finish"
523
+ raise ParallelEvidenceError(
524
+ f"task_{status}",
525
+ f"{task}: Status 为 {status},未通过 Done Gate{suffix}",
526
+ missing,
527
+ required,
528
+ )
529
+
530
+
531
+ def _require_evidence(task: str, path: Path, task_file: str, files: Sequence[str] = ()) -> list[str]:
532
+ context_path = path / Path(task_file).parent / "spec-context.yml"
533
+ if context_path.is_file():
534
+ from codeflow.cli.cf_spec_context import load_context
535
+ from codeflow.spec.cf_workflow_service import task_truth_issues
536
+ from codeflow.task.cf_task_runtime import task_spec_scope
537
+ context = load_context(str(context_path))
538
+ issues = task_truth_issues(str(path / task_file), task, context)
539
+ if issues:
540
+ try:
541
+ scope = task_spec_scope(str(path), str(context_path.parent), task, tuple(files))
542
+ except ValueError as exc:
543
+ raise ParallelError("task_scope_invalid", f"{task}: {exc}") from exc
544
+ issues = task_truth_issues(str(path / task_file), task, context, spec_ids=scope)
545
+ if issues:
546
+ raise ParallelEvidenceError(
547
+ "task_status_truth_mismatch",
548
+ f"{task}: 文档状态与 spec-context 冲突: {', '.join(issues)}",
549
+ [{"check_id": issue.split(":", 1)[0], "scenarios": [], "reason": "unverified_rule"} for issue in issues],
550
+ list(issues),
551
+ )
552
+ missing, required = _evidence_state(path, task, task_file)
553
+ if missing:
554
+ raise ParallelEvidenceError(
555
+ "required_evidence_missing",
556
+ f"{task}: required code checks 缺少匹配 input_hash 的 central evidence: "
557
+ f"{_evidence_summary(missing)};先补 required 检查(在 worktree 内重跑 task finish)",
558
+ missing,
559
+ required,
560
+ )
561
+ return required
562
+
563
+
564
+ def _collect_one(root: Path, entry: dict[str, object], base_head: str, task_file: str, commit: bool = True) -> dict[str, object]:
565
+ task = str(entry["task"])
566
+ path = Path(str(entry["path"]))
567
+ branch = str(entry["branch"])
568
+ if not path.is_dir():
569
+ raise ParallelError("worktree_missing", f"{task}: worktree 不存在: {path}")
570
+ try:
571
+ nodes = parse_task_file(str(path / task_file))
572
+ except ValueError as exc:
573
+ raise ParallelError("task_parse_error", f"{task}: {exc}") from exc
574
+ status = next((node.status for node in nodes if node.task_id == task), None)
575
+ if status in ("provisional", "blocked"):
576
+ _reject_unfinished(task, status, path, task_file)
577
+ if status not in FINISHED_STATUSES:
578
+ raise ParallelError("task_not_finished", f"{task}: Status 为 {status},未通过 Done Gate")
579
+ if (path / MARKER).exists():
580
+ raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
581
+ dirty = _status_paths(path)
582
+ if dirty:
583
+ outside = [item for item in dirty if not item.startswith(".code-flow/")]
584
+ if outside:
585
+ raise ParallelError(
586
+ "worktree_dirty",
587
+ f"{task}: .code-flow 之外存在未提交改动: {', '.join(outside[:8])};"
588
+ "collect 只允许自动提交 finish 的流程回写",
589
+ )
590
+ # finish 会在通过后回写 Evidence/状态;只提交受管流程目录,绝不 add -A。
591
+ if not commit:
592
+ raise ParallelError(
593
+ "worktree_dirty",
594
+ f"{task}: 尚有未提交的流程回写: {', '.join(dirty[:8])};去掉 --no-commit 或手工提交",
595
+ )
596
+ _run_git(path, ("add", "-A", "--", ".code-flow"))
597
+ _run_git(path, ("commit", "-q", "-m", f"cf-task({task}): finish 回写验收证据与任务状态"))
598
+ head = _run_git(path, ("rev-parse", "HEAD")).strip()
599
+ if head == base_head:
600
+ raise ParallelError("no_commits", f"{task}: worktree 无新提交")
601
+ files = _run_git(root, ("diff", "--name-only", f"{base_head}..{branch}")).splitlines()
602
+ required = _require_evidence(task, path, task_file, files)
603
+ return {"task": task, "branch": branch, "path": str(path), "head": head,
604
+ "status": status, "files": sorted(item for item in files if item),
605
+ "evidence": {"required_checks": required, "verified_checks": required}}
606
+
607
+
608
+ def collect(root: Path, run_id: str, tasks: Optional[Sequence[str]], commit: bool = True) -> dict[str, object]:
609
+ root = root.resolve()
610
+ meta = _read_run_meta(root, run_id)
611
+ entries = [item for item in meta.get("worktrees", []) if isinstance(item, dict)]
612
+ if tasks:
613
+ wanted = set(tasks)
614
+ entries = [item for item in entries if item.get("task") in wanted]
615
+ results: list[dict[str, object]] = []
616
+ for entry in entries:
617
+ try:
618
+ results.append(_collect_one(root, entry, str(meta["base_head"]), str(meta["task_file"]), commit))
619
+ except ParallelEvidenceError as exc:
620
+ results.append({
621
+ "task": str(entry.get("task", "")),
622
+ "ok": False,
623
+ "code": exc.code,
624
+ "message": exc.message,
625
+ "missing_checks": list(exc.missing_checks),
626
+ })
627
+ except ParallelError as exc:
628
+ results.append({"task": str(entry.get("task", "")), "ok": False, "code": exc.code, "message": exc.message})
629
+ ok = bool(results) and all(item.get("code") is None for item in results)
630
+ payload: dict[str, object] = {"ok": ok, "action": "collect", "run_id": run_id, "results": results}
631
+ if ok:
632
+ # Batch-level smoke is executed once after merge (TASK-011); collect
633
+ # only records that the batch still owes it.
634
+ payload["batch_smoke"] = "pending"
635
+ _update_run_meta(root, run_id, {
636
+ "batch_smoke": {"status": "pending", "profile": "smoke"},
637
+ })
638
+ return payload
639
+
640
+
641
+ # --- merge: rebase + deterministic state-file resolution + --no-ff back-merge ---
642
+
643
+ _MAX_CONFLICT_ROUNDS = 8
644
+ _STATE_MERGE_NAMES = ("spec-context.yml", ".acceptance-manifest.json")
645
+
646
+
647
+ def _git_try(root: Path, arguments: Sequence[str]) -> subprocess.CompletedProcess:
648
+ return subprocess.run(
649
+ ("git", *arguments), cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False
650
+ )
651
+
652
+
653
+ def _output_tail(result: subprocess.CompletedProcess) -> str:
654
+ text = (result.stderr or result.stdout or "").strip()
655
+ return text[-300:]
656
+
657
+
658
+ def _abort_git(root: Path, *arguments: str) -> None:
659
+ subprocess.run(("git", *arguments), cwd=str(root), capture_output=True, check=False)
660
+
661
+
662
+ def _unmerged_files(root: Path) -> list[str]:
663
+ result = _git_try(root, ("diff", "--name-only", "--diff-filter=U"))
664
+ return [line for line in result.stdout.splitlines() if line.strip()]
665
+
666
+
667
+ def _git_show(root: Path, stage: int, relative: str) -> str:
668
+ result = _git_try(root, ("show", f":{stage}:{relative}"))
669
+ if result.returncode != 0:
670
+ raise ParallelError("conflict_stage_missing", f"{relative}: 缺少冲突 stage {stage}")
671
+ return result.stdout
672
+
673
+
674
+ def _is_state_file(relative: str, task_file: str) -> bool:
675
+ name = Path(relative).name
676
+ if name in _STATE_MERGE_NAMES:
677
+ return True
678
+ parent = Path(task_file).parent.as_posix()
679
+ return name.endswith(".md") and Path(relative).parent.as_posix() == parent
680
+
681
+
682
+ def _resolve_state_file(workdir: Path, relative: str, task: str) -> None:
683
+ ours = _git_show(workdir, 2, relative)
684
+ theirs = _git_show(workdir, 3, relative)
685
+ name = Path(relative).name
686
+ try:
687
+ if name == "spec-context.yml":
688
+ merged = merge_context_text(ours, theirs)
689
+ elif name == ".acceptance-manifest.json":
690
+ merged = merge_manifest_text(ours, theirs)
691
+ elif "## TASK-" in ours or "## TASK-" in theirs:
692
+ merged = merge_task_markdown(ours, theirs, task)
693
+ else:
694
+ raise ParallelError("state_file_unresolvable", f"{relative}: 非任务状态文件,需人工解决")
695
+ except MergeConflictError as exc:
696
+ raise ParallelError(
697
+ exc.code,
698
+ f"{relative}: {exc}",
699
+ {"conflicts": list(exc.conflicts)},
700
+ ) from exc
701
+ except ValueError as exc:
702
+ raise ParallelError("state_file_parse_error", f"{relative}: {exc}") from exc
703
+ (workdir / relative).write_text(merged, encoding="utf-8")
704
+ _run_git(workdir, ("add", "--", relative))
705
+
706
+
707
+ def _resolve_conflicts(workdir: Path, task: str, task_file: str, resolved: list[str]) -> None:
708
+ unresolved = _unmerged_files(workdir)
709
+ if not unresolved:
710
+ raise ParallelError("conflict_state_lost", "冲突状态已丢失,无法自动解决")
711
+ for relative in unresolved:
712
+ if not _is_state_file(relative, task_file):
713
+ raise ParallelError(
714
+ "code_conflict",
715
+ f"代码文件冲突需人工解决(状态文件已按并集规则自动处理): {', '.join(unresolved)}",
716
+ )
717
+ _resolve_state_file(workdir, relative, task)
718
+ if relative not in resolved:
719
+ resolved.append(relative)
720
+
721
+
722
+ def _rebase_onto_main(path: Path, main_branch: str, task: str, task_file: str, resolved: list[str]) -> None:
723
+ result = _git_try(path, ("rebase", main_branch))
724
+ rounds = 0
725
+ while result.returncode != 0 and rounds < _MAX_CONFLICT_ROUNDS:
726
+ if not _unmerged_files(path):
727
+ _abort_git(path, "rebase", "--abort")
728
+ raise ParallelError("rebase_failed", f"{task}: git rebase 失败: {_output_tail(result)}")
729
+ try:
730
+ _resolve_conflicts(path, task, task_file, resolved)
731
+ except ParallelError:
732
+ _abort_git(path, "rebase", "--abort")
733
+ raise
734
+ result = _git_try(path, ("-c", "core.editor=true", "rebase", "--continue"))
735
+ if result.returncode != 0:
736
+ combined = (result.stdout or "") + (result.stderr or "")
737
+ if "rebase --skip" in combined or "is now empty" in combined:
738
+ result = _git_try(path, ("rebase", "--skip"))
739
+ rounds += 1
740
+ if result.returncode != 0:
741
+ _abort_git(path, "rebase", "--abort")
742
+ raise ParallelError("rebase_failed", f"{task}: git rebase 失败: {_output_tail(result)}")
743
+
744
+
745
+ def _merge_branch(root: Path, branch: str, task: str, task_file: str, resolved: list[str]) -> None:
746
+ result = _git_try(root, ("merge", "--no-ff", "-m", f"merge cf-task {task}", branch))
747
+ rounds = 0
748
+ while result.returncode != 0 and rounds < _MAX_CONFLICT_ROUNDS:
749
+ if not _unmerged_files(root):
750
+ _abort_git(root, "merge", "--abort")
751
+ raise ParallelError("merge_failed", f"{task}: git merge 失败: {_output_tail(result)}")
752
+ try:
753
+ _resolve_conflicts(root, task, task_file, resolved)
754
+ except ParallelError:
755
+ _abort_git(root, "merge", "--abort")
756
+ raise
757
+ result = _git_try(root, ("commit", "--no-edit"))
758
+ rounds += 1
759
+ if result.returncode != 0:
760
+ _abort_git(root, "merge", "--abort")
761
+ raise ParallelError("merge_failed", f"{task}: git merge 失败: {_output_tail(result)}")
762
+
763
+
764
+ def _branch_is_ancestor(root: Path, ancestor: str) -> bool:
765
+ return _git_try(root, ("merge-base", "--is-ancestor", ancestor, "HEAD")).returncode == 0
766
+
767
+
768
+ def _merge_one(root: Path, main_branch: str, entry: dict, task_file: str) -> dict[str, object]:
769
+ task = str(entry.get("task", ""))
770
+ path = Path(str(entry.get("path", "")))
771
+ branch = str(entry.get("branch", ""))
772
+ if not path.is_dir():
773
+ raise ParallelError("worktree_missing", f"{task}: worktree 不存在: {path}")
774
+ if (path / MARKER).exists():
775
+ raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
776
+ try:
777
+ nodes = parse_task_file(str(path / task_file))
778
+ except ValueError as exc:
779
+ raise ParallelError("task_parse_error", f"{task}: {exc}") from exc
780
+ status = next((node.status for node in nodes if node.task_id == task), None)
781
+ if status not in FINISHED_STATUSES:
782
+ raise ParallelError("task_not_finished", f"{task}: Status 为 {status},未通过 Done Gate")
783
+ main_manifest = _task_manifest(root, task_file)
784
+ worktree_manifest = _task_manifest(path, task_file)
785
+ if main_manifest is not None and worktree_manifest is not None:
786
+ from codeflow.task.cf_task_merge import compare_manifest_checks
787
+ conflicts = compare_manifest_checks(main_manifest, worktree_manifest)
788
+ if conflicts:
789
+ detail = ", ".join(f"{item['check_id']}({','.join(item['fields'])})" for item in conflicts)
790
+ raise ParallelError("check_conflict", f"{task}: check 配置冲突: {detail}",
791
+ {"conflicts": conflicts})
792
+ changed = _run_git(path, ("diff", "--name-only", f"{main_branch}...HEAD")).splitlines()
793
+ _require_evidence(task, path, task_file, changed)
794
+ if _status_paths(path):
795
+ raise ParallelError("worktree_dirty", f"{task}: worktree 有未提交改动,先运行 collect")
796
+ if _branch_is_ancestor(root, branch):
797
+ return {"task": task, "ok": True, "status": "already_merged"}
798
+ resolved: list[str] = []
799
+ _rebase_onto_main(path, main_branch, task, task_file, resolved)
800
+ _merge_branch(root, branch, task, task_file, resolved)
801
+ return {
802
+ "task": task, "ok": True, "status": "merged", "resolved": sorted(set(resolved)),
803
+ "commit": _run_git(root, ("rev-parse", "HEAD")).strip(),
804
+ }
805
+
806
+
807
+ def _context_files(root: Path, task_file: str) -> list[Path]:
808
+ demand = root / Path(task_file).parent
809
+ return sorted(demand.rglob("spec-context.yml")) if demand.is_dir() else []
810
+
811
+
812
+ def _write_text_atomic(path: Path, text: str) -> None:
813
+ temporary = path.with_suffix(path.suffix + ".tmp")
814
+ temporary.write_text(text, encoding="utf-8")
815
+ temporary.replace(path)
816
+
817
+
818
+ def _stale_after_merge(root: Path, task_file: str, input_hash: str) -> list[dict[str, object]]:
819
+ """Downgrade verified rules whose merge-time input hash changed.
820
+
821
+ The old evidence stays in place for audit; only the rule state becomes
822
+ stale so the next changed-profile run re-executes it instead of reusing a
823
+ green result from the pre-merge tree.
824
+ """
825
+ changed: list[dict[str, object]] = []
826
+ for path in _context_files(root, task_file):
827
+ try:
828
+ text = path.read_text(encoding="utf-8")
829
+ merged, stale = stale_context_text(text, input_hash)
830
+ except (OSError, ValueError) as exc:
831
+ raise ParallelError("stale_context_failed", f"{path}: {exc}") from exc
832
+ if not stale:
833
+ continue
834
+ _write_text_atomic(path, merged)
835
+ changed.append({"path": path.relative_to(root).as_posix(), "rules": stale})
836
+ manifest = root / Path(task_file).parent / ".acceptance-manifest.json"
837
+ if manifest.is_file():
838
+ from codeflow.acceptance.cf_acceptance_evidence import invalidate_manifest_evidence
839
+ try:
840
+ stale_rows = invalidate_manifest_evidence(root, manifest, input_hash)
841
+ except (OSError, ValueError) as exc:
842
+ raise ParallelError("stale_acceptance_failed", f"{manifest}: {exc}") from exc
843
+ if stale_rows:
844
+ changed.append({"path": manifest.relative_to(root).as_posix(),
845
+ "scenarios": [row["scenario_id"] for row in stale_rows],
846
+ "checks": sorted({check for row in stale_rows for check in row["checks"]})})
847
+ return changed
848
+
849
+
850
+ def _refresh_demand(root: Path, task_file: str) -> str:
851
+ demand = root / Path(task_file).parent
852
+ script = root / ".code-flow" / "scripts" / "cf_spec_context.py"
853
+ result = subprocess.run(
854
+ (sys.executable, str(script), "refresh", "--task-dir", str(demand), "--root", str(root), "--json"),
855
+ cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False,
856
+ )
857
+ if result.returncode == 0:
858
+ return "pass"
859
+ detail = (result.stdout or result.stderr or "").strip()
860
+ return f"block: {detail[-200:]}"
861
+
862
+
863
+ _BATCH_SMOKE_BLOCKING = ("failed", "env_blocked", "incomplete")
864
+
865
+
866
+ def _smoke_status(result: Mapping[str, object]) -> str:
867
+ if result.get("decision") == "pass":
868
+ return "passed"
869
+ if int(result.get("env_blocked") or 0) > 0:
870
+ return "env_blocked"
871
+ if int(result.get("incomplete") or 0) > 0:
872
+ return "incomplete"
873
+ return "failed"
874
+
875
+
876
+ def _smoke_summary(result: Mapping[str, object]) -> dict[str, object]:
877
+ summary: dict[str, object] = {
878
+ "status": _smoke_status(result),
879
+ "profile": "smoke",
880
+ "changed": True,
881
+ "decision": result.get("decision", "block"),
882
+ "reason": result.get("reason", ""),
883
+ "executed": int(result.get("executed") or 0),
884
+ "reused": int(result.get("reused") or 0),
885
+ "failed": list(result.get("failed") or ()),
886
+ "env_blocked": int(result.get("env_blocked") or 0),
887
+ "incomplete": int(result.get("incomplete") or 0),
888
+ }
889
+ layers = result.get("layers")
890
+ if isinstance(layers, Mapping):
891
+ summary["layers"] = {
892
+ str(name): {
893
+ key: layer.get(key)
894
+ for key in ("decision", "reason", "executed", "reused", "env_blocked", "incomplete", "failed")
895
+ }
896
+ for name, layer in layers.items() if isinstance(layer, Mapping)
897
+ }
898
+ return summary
899
+
900
+
901
+ def _run_batch_smoke(root: Path, run_id: str, task_file: str,
902
+ task_ids: Sequence[str]) -> dict[str, object]:
903
+ """One post-merge smoke for the whole batch, reusing the layer runner."""
904
+ from codeflow.task.cf_requirement_verification import verify_layers
905
+
906
+ directory = root / Path(task_file).parent
907
+ try:
908
+ result = verify_layers(str(root), str(directory), layers="validators,e2e",
909
+ profile="smoke", only_tasks=list(task_ids))
910
+ except (OSError, ValueError) as exc:
911
+ summary = {
912
+ "status": "failed", "profile": "smoke", "changed": True,
913
+ "decision": "block", "reason": "batch_smoke_error", "detail": str(exc),
914
+ "executed": 0, "reused": 0, "failed": [], "env_blocked": 0, "incomplete": 0,
915
+ }
916
+ else:
917
+ summary = _smoke_summary(result)
918
+ _update_run_meta(root, run_id, {
919
+ "batch_smoke": summary,
920
+ "batch_smoke_at": datetime.now(timezone.utc).isoformat(),
921
+ })
922
+ return summary
923
+
924
+
925
+ def _merge_entries(root: Path, main_branch: str, entries: Sequence[dict[str, object]],
926
+ task_file: str) -> list[dict[str, object]]:
927
+ results: list[dict[str, object]] = []
928
+ for entry in entries:
929
+ try:
930
+ results.append(_merge_one(root, main_branch, entry, task_file))
931
+ except ParallelError as exc:
932
+ failure = {
933
+ "task": str(entry.get("task", "")), "ok": False,
934
+ "code": exc.code, "message": exc.message,
935
+ }
936
+ failure.update(exc.details)
937
+ results.append(failure)
938
+ break
939
+ return results
940
+
941
+
942
+ def merge(root: Path, run_id: str, tasks: Optional[Sequence[str]]) -> dict[str, object]:
943
+ root = root.resolve()
944
+ _assert_repo(root)
945
+ meta = _read_run_meta(root, run_id)
946
+ main_branch = _run_git(root, ("rev-parse", "--abbrev-ref", "HEAD")).strip()
947
+ if main_branch == "HEAD":
948
+ raise ParallelError("detached_head", "主工作区处于 detached HEAD,无法回并")
949
+ wanted = set(tasks) if tasks else None
950
+ all_entries = [item for item in meta.get("worktrees", []) if isinstance(item, dict)]
951
+ all_task_ids = {str(item.get("task", "")) for item in all_entries}
952
+ full_batch = wanted is None or wanted >= all_task_ids
953
+ entries = all_entries
954
+ if wanted:
955
+ entries = [item for item in entries if item.get("task") in wanted]
956
+ entries.sort(key=lambda item: str(item.get("task", "")))
957
+ task_file = str(meta["task_file"])
958
+ _update_run_meta(root, run_id, {
959
+ "merge": {
960
+ "status": "running",
961
+ "at": datetime.now(timezone.utc).isoformat(),
962
+ }
963
+ })
964
+ before_hash = worktree_fingerprint(str(root))
965
+ results = _merge_entries(root, main_branch, entries, task_file)
966
+ ok = bool(results) and all(item.get("ok") for item in results)
967
+ payload: dict[str, object] = {
968
+ "ok": ok, "action": "merge", "run_id": run_id,
969
+ "main_branch": main_branch, "results": results,
970
+ }
971
+ after_hash = worktree_fingerprint(str(root))
972
+ if after_hash and (before_hash != after_hash or any(row.get("status") == "merged" for row in results)):
973
+ payload["input_hash"] = after_hash
974
+ payload["stale"] = _stale_after_merge(root, task_file, after_hash)
975
+ if ok:
976
+ if (root / Path(task_file).parent / "spec-context.yml").is_file():
977
+ payload["refresh"] = _refresh_demand(root, task_file)
978
+ previous = meta.get("batch_smoke")
979
+ previous_status = previous.get("status") if isinstance(previous, Mapping) else ""
980
+ merged_any = any(item.get("status") == "merged" for item in results)
981
+ if full_batch and (merged_any or previous_status != "passed"):
982
+ smoke = _run_batch_smoke(root, run_id, task_file, sorted(all_task_ids))
983
+ payload["batch_smoke"] = smoke
984
+ if smoke.get("status") != "passed":
985
+ payload["ok"] = False
986
+ payload["code"] = "batch_smoke_failed"
987
+ payload["message"] = (
988
+ f"批次 smoke {smoke.get('status')}: "
989
+ f"{smoke.get('reason') or 'shared environment verification failed'};保留 worktree"
990
+ )
991
+ elif isinstance(previous, Mapping) and previous_status == "passed":
992
+ payload["batch_smoke"] = dict(previous)
993
+ merged_tasks = sorted({
994
+ str(item.get("task")) for item in results
995
+ if item.get("ok") and item.get("status") in ("merged", "already_merged")
996
+ })
997
+ merge_code = str(payload.get("code", "")) or next(
998
+ (str(item.get("code", "")) for item in results if not item.get("ok") and item.get("code")), ""
999
+ )
1000
+ _update_run_meta(root, run_id, {
1001
+ "merge": {
1002
+ "status": "passed" if payload.get("ok") else "blocked",
1003
+ "code": merge_code,
1004
+ "full_batch": full_batch,
1005
+ "merged": merged_tasks,
1006
+ "all_tasks": sorted(all_task_ids),
1007
+ "at": datetime.now(timezone.utc).isoformat(),
1008
+ }
1009
+ })
1010
+ return payload
1011
+
1012
+
1013
+ def _cleanup_block_reason(meta: Mapping[str, object], force: bool) -> tuple[str, str]:
1014
+ """Refuse cleanup while the latest merge is not in a passed state."""
1015
+ if force:
1016
+ return "", ""
1017
+ merge_state = meta.get("merge")
1018
+ if isinstance(merge_state, Mapping) and merge_state.get("status") != "passed":
1019
+ code = str(merge_state.get("code") or "")
1020
+ if code == "batch_smoke_failed":
1021
+ return (
1022
+ "batch_smoke_failed",
1023
+ "批次 smoke failed,保留 worktree;修复后重新 merge,或 --force 明确放弃现场",
1024
+ )
1025
+ return (
1026
+ "merge_not_passed",
1027
+ f"最近一次 merge 状态为 {merge_state.get('status') or 'missing'}({code or 'no code'});"
1028
+ "保留 worktree,修复后重新 merge,或 --force 明确放弃现场",
1029
+ )
1030
+ if isinstance(merge_state, Mapping) and "full_batch" in merge_state:
1031
+ merged = {str(item) for item in (merge_state.get("merged") or [])}
1032
+ all_tasks = {str(item) for item in (merge_state.get("all_tasks") or [])}
1033
+ if all_tasks:
1034
+ missing = sorted(all_tasks - merged)
1035
+ else:
1036
+ missing = [
1037
+ str(item.get("task")) for item in meta.get("worktrees", [])
1038
+ if isinstance(item, Mapping)
1039
+ ]
1040
+ if merge_state.get("full_batch") is False or missing:
1041
+ return (
1042
+ "partial_merge",
1043
+ f"仍有未合并任务: {', '.join(missing) or 'subset merge'};"
1044
+ "保留 worktree,完成全批次 merge 后再 cleanup,或 --force 明确放弃现场",
1045
+ )
1046
+ smoke = meta.get("batch_smoke")
1047
+ if isinstance(smoke, Mapping) and smoke.get("status") in _BATCH_SMOKE_BLOCKING:
1048
+ return (
1049
+ "batch_smoke_failed",
1050
+ f"批次 smoke {smoke.get('status')},保留 worktree;"
1051
+ "修复后重新 merge,或 --force 明确放弃现场",
1052
+ )
1053
+ return "", ""
1054
+
1055
+
1056
+ def cleanup(root: Path, run_id: str, force: bool) -> dict[str, object]:
1057
+ root = root.resolve()
1058
+ meta = _read_run_meta(root, run_id)
1059
+ code, message = _cleanup_block_reason(meta, force)
1060
+ if code:
1061
+ return {
1062
+ "ok": False, "action": "cleanup", "run_id": run_id,
1063
+ "code": code, "message": message,
1064
+ "results": [
1065
+ {"task": item.get("task"), "removed": False,
1066
+ "code": code, "message": message}
1067
+ for item in meta.get("worktrees", []) if isinstance(item, dict)
1068
+ ],
1069
+ }
1070
+ results: list[dict[str, object]] = []
1071
+ for item in meta.get("worktrees", []):
1072
+ if not isinstance(item, dict):
1073
+ continue
1074
+ path = Path(str(item.get("path", "")))
1075
+ if not path.exists():
1076
+ results.append({"task": item.get("task"), "removed": False, "reason": "already_absent"})
1077
+ continue
1078
+ try:
1079
+ _require_clean_for_removal(path, force)
1080
+ _remove_worktree(root, path, force)
1081
+ branch = str(item.get("branch", ""))
1082
+ merged = _delete_branch(root, branch, force) if branch else False
1083
+ results.append({"task": item.get("task"), "removed": True, "branch_deleted": merged})
1084
+ except ParallelError as exc:
1085
+ results.append({"task": item.get("task"), "removed": False, "code": exc.code, "message": exc.message})
1086
+ _run_git(root, ("worktree", "prune"))
1087
+ run_dir = root / WORKTREES_DIR / run_id
1088
+ if run_dir.is_dir() and not any(child.is_dir() for child in run_dir.iterdir()):
1089
+ (run_dir / "run.json").unlink(missing_ok=True)
1090
+ run_dir.rmdir()
1091
+ ok = all(item.get("removed") or item.get("reason") == "already_absent" for item in results)
1092
+ return {"ok": ok, "action": "cleanup", "run_id": run_id, "results": results}
1093
+
1094
+
1095
+ def _require_clean_for_removal(path: Path, force: bool) -> None:
1096
+ if force:
1097
+ return
1098
+ dirty = _status_paths(path)
1099
+ if dirty:
1100
+ raise ParallelError("worktree_dirty", f"worktree 有未提交改动: {', '.join(dirty[:8])}")
1101
+
1102
+
1103
+ def _emit(payload: dict[str, object], stdout: IO[str]) -> None:
1104
+ stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
1105
+
1106
+
1107
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
1108
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_task_parallel.py"))
1109
+ sub = parser.add_subparsers(dest="action", required=True)
1110
+ for name in ("prepare", "collect", "cleanup", "merge"):
1111
+ command = sub.add_parser(name)
1112
+ command.add_argument("--root", default=".")
1113
+ command.add_argument("--json", action="store_true")
1114
+ prepare_parser = sub.choices["prepare"]
1115
+ prepare_parser.add_argument("--task-file", required=True)
1116
+ prepare_parser.add_argument("--tasks", required=True)
1117
+ prepare_parser.add_argument("--run-id", default="")
1118
+ for name in ("collect", "cleanup", "merge"):
1119
+ sub.choices[name].add_argument("--run-id", required=True)
1120
+ sub.choices["collect"].add_argument("--tasks", default="")
1121
+ sub.choices["collect"].add_argument("--no-commit", action="store_true")
1122
+ sub.choices["cleanup"].add_argument("--force", action="store_true")
1123
+ sub.choices["merge"].add_argument("--tasks", default="")
1124
+ args = parser.parse_args(argv)
1125
+ root = Path(args.root).resolve()
1126
+ try:
1127
+ if args.action == "prepare":
1128
+ tasks = [item.strip() for item in args.tasks.split(",") if item.strip()]
1129
+ payload = prepare(root, args.task_file, tasks, args.run_id or None)
1130
+ elif args.action == "collect":
1131
+ tasks = [item.strip() for item in args.tasks.split(",") if item.strip()] or None
1132
+ payload = collect(root, args.run_id, tasks, commit=not args.no_commit)
1133
+ elif args.action == "merge":
1134
+ tasks = [item.strip() for item in args.tasks.split(",") if item.strip()] or None
1135
+ payload = merge(root, args.run_id, tasks)
1136
+ else:
1137
+ payload = cleanup(root, args.run_id, args.force)
1138
+ except ParallelError as exc:
1139
+ payload = {"ok": False, "action": args.action, "code": exc.code, "message": exc.message}
1140
+ payload.update(exc.details)
1141
+ _emit(payload, stdout)
1142
+ return 0 if payload.get("ok") else 2