okstra 0.201.3 → 0.204.0

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 (273) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.mjs +7 -7
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +50 -124
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/dist/commands/lifecycle/setup.mjs +15 -0
  7. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  8. package/dist/commands/memory/memory.mjs +41 -8
  9. package/dist/commands/memory/memory.mjs.map +1 -1
  10. package/dist/lib/citation-guidance.d.mts +21 -0
  11. package/dist/lib/citation-guidance.mjs +79 -0
  12. package/dist/lib/citation-guidance.mjs.map +1 -0
  13. package/dist/lib/install-assets.mjs +3 -0
  14. package/dist/lib/install-assets.mjs.map +1 -1
  15. package/dist/lib/runtime-manifest.mjs +2 -1
  16. package/dist/lib/runtime-manifest.mjs.map +1 -1
  17. package/dist/lib/types.d.mts +2 -1
  18. package/docs/architecture/storage-model.md +17 -10
  19. package/docs/architecture.md +26 -20
  20. package/docs/cli.md +16 -13
  21. package/docs/contributor-change-matrix.md +3 -2
  22. package/docs/performance-improvement-plan-v2.md +2 -3
  23. package/docs/project-structure-overview.md +38 -9
  24. package/docs/task-process/README.md +1 -1
  25. package/docs/task-process/common-flow.md +1 -1
  26. package/docs/task-process/final-verification.md +3 -1
  27. package/docs/task-process/implementation.md +1 -1
  28. package/docs/task-process/release-handoff.md +36 -39
  29. package/package.json +1 -2
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/common.json +28 -0
  32. package/runtime/agents/operations/code-review.json +6 -0
  33. package/runtime/agents/operations/report-translation.json +6 -0
  34. package/runtime/agents/operations/schedule-verification.json +6 -0
  35. package/runtime/agents/roles/analyser.json +18 -0
  36. package/runtime/agents/roles/critic.json +18 -0
  37. package/runtime/agents/roles/designer.json +18 -0
  38. package/runtime/agents/roles/implementer.json +20 -0
  39. package/runtime/agents/roles/leader.json +20 -0
  40. package/runtime/agents/roles/planner.json +18 -0
  41. package/runtime/agents/roles/report-writer.json +19 -0
  42. package/runtime/agents/roles/translator.json +19 -0
  43. package/runtime/agents/roles/verifier.json +18 -0
  44. package/runtime/bin/lib/okstra/usage.sh +5 -5
  45. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  46. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  47. package/runtime/prompts/duties/analysis-worker.json +32 -0
  48. package/runtime/prompts/duties/code-reviewer.json +32 -0
  49. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  50. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  51. package/runtime/prompts/duties/discovery-worker.json +32 -0
  52. package/runtime/prompts/duties/implementation-executor.json +32 -0
  53. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  54. package/runtime/prompts/duties/lead.json +32 -0
  55. package/runtime/prompts/duties/planning-worker.json +36 -0
  56. package/runtime/prompts/duties/report-writer.json +32 -0
  57. package/runtime/prompts/duties/reverification-worker.json +32 -0
  58. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  59. package/runtime/prompts/duties/scope-critic.json +32 -0
  60. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  61. package/runtime/prompts/duties/translator.json +32 -0
  62. package/runtime/prompts/launch.template.md +3 -2
  63. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  64. package/runtime/prompts/lead/convergence.md +4 -4
  65. package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
  66. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  67. package/runtime/prompts/lead/report-writer.md +3 -3
  68. package/runtime/prompts/profiles/_common-contract.md +2 -2
  69. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  70. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  71. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  72. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  73. package/runtime/prompts/profiles/error-analysis.json +39 -0
  74. package/runtime/prompts/profiles/error-analysis.md +0 -25
  75. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  76. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  77. package/runtime/prompts/profiles/final-verification.json +30 -0
  78. package/runtime/prompts/profiles/final-verification.md +3 -22
  79. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  80. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  81. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  82. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  83. package/runtime/prompts/profiles/implementation-planning.md +6 -29
  84. package/runtime/prompts/profiles/implementation.json +30 -0
  85. package/runtime/prompts/profiles/implementation.md +1 -20
  86. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  87. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  88. package/runtime/prompts/profiles/project-analysis.json +31 -0
  89. package/runtime/prompts/profiles/project-analysis.md +0 -20
  90. package/runtime/prompts/profiles/release-handoff.json +5 -0
  91. package/runtime/prompts/profiles/release-handoff.md +71 -73
  92. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  93. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  94. package/runtime/prompts/profiles/technical-verification.json +39 -0
  95. package/runtime/prompts/profiles/technical-verification.md +0 -25
  96. package/runtime/prompts/wizard/prompts.ko.json +12 -17
  97. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  98. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  101. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  103. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  104. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  105. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  106. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  107. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  108. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  109. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  110. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  111. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  112. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  113. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  114. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  115. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  116. package/runtime/python/okstra_ctl/cmux.py +69 -20
  117. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  118. package/runtime/python/okstra_ctl/conformance.py +43 -0
  119. package/runtime/python/okstra_ctl/consumers.py +6 -3
  120. package/runtime/python/okstra_ctl/container.py +31 -8
  121. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  122. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  123. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  124. package/runtime/python/okstra_ctl/convergence_provenance.py +81 -18
  125. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  126. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  127. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  128. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  129. package/runtime/python/okstra_ctl/error_report.py +4 -3
  130. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  131. package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
  132. package/runtime/python/okstra_ctl/handoff.py +167 -277
  133. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  134. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  135. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  136. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +175 -14
  138. package/runtime/python/okstra_ctl/manager_launch.py +41 -19
  139. package/runtime/python/okstra_ctl/manager_paths.py +22 -3
  140. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  141. package/runtime/python/okstra_ctl/manager_store.py +331 -21
  142. package/runtime/python/okstra_ctl/manager_sync.py +37 -16
  143. package/runtime/python/okstra_ctl/manager_view.py +217 -0
  144. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  145. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  146. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  147. package/runtime/python/okstra_ctl/models.py +1 -1
  148. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  149. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  150. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  151. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  152. package/runtime/python/okstra_ctl/paths.py +3 -0
  153. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  154. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  155. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  156. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  157. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  158. package/runtime/python/okstra_ctl/render.py +50 -0
  159. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  160. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  161. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  162. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  163. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  164. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  165. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  166. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  167. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  168. package/runtime/python/okstra_ctl/rollup.py +3 -1
  169. package/runtime/python/okstra_ctl/run.py +76 -29
  170. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  171. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  172. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  173. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  174. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  175. package/runtime/python/okstra_ctl/team.py +22 -13
  176. package/runtime/python/okstra_ctl/time_report.py +2 -1
  177. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  178. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  179. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  180. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  181. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  182. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  183. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -23
  184. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  185. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  186. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  187. package/runtime/python/okstra_ctl/workflow.py +1 -1
  188. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  189. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  190. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  191. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  192. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  193. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  194. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  195. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  196. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  197. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  198. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  199. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  200. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  201. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  202. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  203. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  204. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  205. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  206. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  207. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  208. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  209. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  210. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  211. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  212. package/runtime/skills/okstra-manager/SKILL.md +70 -5
  213. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  214. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  215. package/runtime/skills/okstra-run/SKILL.md +32 -13
  216. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  217. package/runtime/skills/okstra-setup/SKILL.md +21 -10
  218. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  219. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  220. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  221. package/runtime/templates/manager/view.template.html +109 -0
  222. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  223. package/runtime/templates/reports/brief.template.md +14 -4
  224. package/runtime/templates/reports/html/i18n/en.json +7 -4
  225. package/runtime/templates/reports/html/i18n/ko.json +7 -4
  226. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  227. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  228. package/runtime/templates/reports/i18n/en.json +1 -1
  229. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  230. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  231. package/runtime/templates/translator-prompt-preamble.md +36 -0
  232. package/runtime/validators/checks/validate-assets-01.py +7 -8
  233. package/runtime/validators/validate-brief.py +77 -2
  234. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  235. package/runtime/validators/validate-run.py +59 -9
  236. package/runtime/validators/validate-schedule.py +9 -0
  237. package/docs/for-ai/README.md +0 -68
  238. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  239. package/docs/for-ai/skills/okstra-chat.md +0 -34
  240. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  241. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  242. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  243. package/docs/for-ai/skills/okstra-manager.md +0 -69
  244. package/docs/for-ai/skills/okstra-memory.md +0 -126
  245. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  246. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  247. package/docs/for-ai/skills/okstra-run.md +0 -250
  248. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  249. package/docs/for-ai/skills/okstra-setup.md +0 -158
  250. package/docs/for-ai/skills/okstra-usage.md +0 -29
  251. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  252. package/runtime/agents/workers/claude-worker.md +0 -128
  253. package/runtime/agents/workers/report-writer-worker.md +0 -37
  254. package/runtime/agents/workers/translator-worker.md +0 -63
  255. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  256. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  257. package/runtime/prompts/duties/analysis-worker.md +0 -44
  258. package/runtime/prompts/duties/code-reviewer.md +0 -44
  259. package/runtime/prompts/duties/common.md +0 -39
  260. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  261. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  262. package/runtime/prompts/duties/discovery-worker.md +0 -44
  263. package/runtime/prompts/duties/implementation-executor.md +0 -44
  264. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  265. package/runtime/prompts/duties/lead.md +0 -44
  266. package/runtime/prompts/duties/planning-worker.md +0 -52
  267. package/runtime/prompts/duties/report-writer.md +0 -44
  268. package/runtime/prompts/duties/reverification-worker.md +0 -44
  269. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  270. package/runtime/prompts/duties/scope-critic.md +0 -44
  271. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  272. package/runtime/prompts/duties/translator.md +0 -44
  273. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -1,10 +1,15 @@
1
- """whole-task stage 통합(머지) + worktree teardown 코어.
1
+ """stage done commit 을 한 곳에 모으는 두 가지 방법.
2
2
 
3
- Phase A: stage done commit 을 task 브랜치에 위상순 --no-ff 머지(이미 머지면
4
- skip, 충돌이면 해당 머지만 abort 후 IntegrateError). Phase B: 머지/skip 된
5
- stage 의 worktree 디렉터리와 registry 키를 정리(dirty 면 skip). stage 브랜치는
6
- 스택 보존을 위해 삭제하지 않는다.
3
+ `integrate_stages` — whole-task 통합. Phase A: stage done commit 을 task 브랜치에
4
+ 위상순 --no-ff 머지(이미 머지면 skip, 충돌이면 해당 머지만 abort 후 IntegrateError).
5
+ Phase B: 머지/skip 된 stage 의 worktree 디렉터리와 registry 키를 정리(dirty 면 skip).
6
+ stage 브랜치는 스택 보존을 위해 삭제하지 않는다.
7
7
  설계: docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md
8
+
9
+ `ensure_stage_merge_branch` — 여러 stage 의 done commit 만 담은 브랜치를 만든다.
10
+ 선행이 둘 이상인 stage 의 바닥(구현 워크트리의 base, PR 의 base)이 필요할 때 쓰며,
11
+ task 브랜치를 건드리지 않는다.
12
+ 설계: .project-docs/specs/2026-09-24-release-handoff-per-stage-pr-design.md §4
8
13
  """
9
14
  from __future__ import annotations
10
15
 
@@ -28,6 +33,141 @@ class IntegrateResult:
28
33
  warnings: list[str] = field(default_factory=list)
29
34
 
30
35
 
36
+ @dataclass(frozen=True)
37
+ class StageMergeBranch:
38
+ branch: str
39
+ commit: str
40
+ created: bool
41
+ stages: list[int]
42
+ merge_commits: list[str]
43
+
44
+
45
+ class MergeBranchConflict(IntegrateError):
46
+ """선행 stage 들끼리 내용이 충돌해 공통 바닥을 만들 수 없다."""
47
+
48
+ def __init__(self, stage: int, branch: str, paths: list[str]):
49
+ self.stage = stage
50
+ self.branch = branch
51
+ self.paths = paths
52
+ super().__init__(
53
+ f"merge conflict while merging stage {stage} ({branch}): "
54
+ f"{', '.join(paths)}")
55
+
56
+
57
+ def ensure_stage_merge_branch(
58
+ *, project_root, project_id: str, task_group: str, task_id: str,
59
+ work_category: str, heads: dict[int, str], anchor_base: str,
60
+ advance_existing: bool = False,
61
+ ) -> StageMergeBranch:
62
+ """`heads` 의 done commit 을 모두 담은 브랜치를 보장한다. 멱등.
63
+
64
+ 이름은 stage 집합이 정한다(`<namespace>/<task>-g<n1>-<n2>`) — 같은 집합은
65
+ 언제나 같은 브랜치다. 브랜치는 anchor 에서 떼어 각 done commit 을 `--no-ff`
66
+ 로 머지해 만든다. 머지 대상이 브랜치 tip 이 아니라 기록된 done commit 인 이유는
67
+ `_merge_stage` 와 같다: reconcile 로 둘이 갈라져 있을 수 있다.
68
+
69
+ 워크트리는 머지하는 동안만 존재하고 끝나면 지운다 — 이 브랜치는 누가 체크아웃해
70
+ 작업하는 곳이 아니라 다른 브랜치가 올라앉는 바닥이다.
71
+
72
+ 이미 있는 브랜치에 빠진 commit 이 있을 때 `advance_existing` 이 갈린다.
73
+ 거짓(기본)이면 거부한다 — 열려 있는 PR 의 base 를 말없이 옮기지 않기 위해서다.
74
+ 참이면 빠진 commit 만 추가로 머지한다 — 구현 워크트리의 base 는 언제나 현재
75
+ 선행 위에 있어야 하기 때문이다.
76
+
77
+ **호출자가 task-key `worktree_provision_mutex` 를 쥐고 들어와야 한다.** flock 은
78
+ 재진입이 안 되므로 여기서 다시 잡으면 이미 락을 쥔 implementation 경로가
79
+ 교착한다 — `_resolve_and_integrate_whole_task_unlocked` 와 같은 분리다.
80
+ """
81
+ from .worktree import (compute_branch_name, compute_worktree_path,
82
+ is_ancestor, remove_worktree_force, stage_group_id)
83
+
84
+ if not heads:
85
+ raise IntegrateError("ensure_stage_merge_branch requires at least one stage")
86
+ stages = sorted(heads)
87
+ branch = compute_branch_name(work_category=work_category,
88
+ task_id_segment=task_id,
89
+ group_id=stage_group_id(stages))
90
+ root = Path(project_root)
91
+ if _branch_exists(root, branch):
92
+ tip = _rev_parse(root, branch)
93
+ missing = [n for n in stages if not is_ancestor(root, heads[n], tip)]
94
+ if not missing:
95
+ return StageMergeBranch(branch=branch, commit=tip, created=False,
96
+ stages=stages, merge_commits=[])
97
+ if not advance_existing:
98
+ raise IntegrateError(
99
+ f"merge-base branch {branch} exists but stage(s) {missing} "
100
+ "are not merged into it — delete the branch and retry so "
101
+ "okstra can rebuild it")
102
+ # 기존 브랜치를 그대로 체크아웃해 빠진 commit 만 얹는다.
103
+ creating, start = False, branch
104
+ to_merge = {n: heads[n] for n in missing}
105
+ else:
106
+ if not anchor_base:
107
+ raise IntegrateError(
108
+ f"cannot create {branch}: the implementation base commit is not "
109
+ "recorded in the worktree registry — run at least one "
110
+ "implementation stage first")
111
+ creating, start = True, anchor_base
112
+ to_merge = dict(heads)
113
+
114
+ wt_path = compute_worktree_path(
115
+ project_id=project_id, task_group_segment=task_group,
116
+ task_id_segment=task_id, group_id=stage_group_id(stages))
117
+ wt_path.parent.mkdir(parents=True, exist_ok=True)
118
+ add = _git(root, "worktree", "add",
119
+ *(["-b", branch] if creating else []), str(wt_path), start)
120
+ if add.returncode != 0:
121
+ raise IntegrateError(
122
+ f"git worktree add for {branch} failed: "
123
+ f"{(add.stderr or add.stdout).strip()}")
124
+ try:
125
+ merge_commits = _merge_heads_into(wt_path, to_merge, work_category,
126
+ task_id)
127
+ commit = _rev_parse(wt_path, "HEAD")
128
+ except Exception:
129
+ remove_worktree_force(root, wt_path)
130
+ if creating:
131
+ _git(root, "branch", "-D", branch)
132
+ raise
133
+ remove_worktree_force(root, wt_path)
134
+ return StageMergeBranch(branch=branch, commit=commit, created=creating,
135
+ stages=stages, merge_commits=merge_commits)
136
+
137
+
138
+ def _merge_heads_into(wt_path, heads: dict[int, str], work_category: str,
139
+ task_id: str) -> list[str]:
140
+ """stage 번호 순서로 done commit 을 `--no-ff` 머지한다. 이미 담긴 commit 은
141
+ git 이 `Already up to date` 로 지나가므로 빈 머지 커밋이 생기지 않는다."""
142
+ from .worktree import MergeError, compute_branch_name, merge_branch
143
+
144
+ merge_commits: list[str] = []
145
+ for n in sorted(heads):
146
+ stage_branch = compute_branch_name(work_category=work_category,
147
+ task_id_segment=task_id,
148
+ stage_number=n)
149
+ try:
150
+ conflicts = merge_branch(wt_path, heads[n], no_ff=True)
151
+ except MergeError as exc:
152
+ raise IntegrateError(
153
+ f"stage {n} ({stage_branch}) 머지 실패(내용 충돌 아님): {exc}"
154
+ ) from exc
155
+ if conflicts is not None:
156
+ raise MergeBranchConflict(stage=n, branch=stage_branch,
157
+ paths=conflicts)
158
+ merge_commits.append(_rev_parse(wt_path, "HEAD"))
159
+ return merge_commits
160
+
161
+
162
+ def _branch_exists(project_root, branch: str) -> bool:
163
+ return _git(project_root, "rev-parse", "--verify", "--quiet",
164
+ f"refs/heads/{branch}").returncode == 0
165
+
166
+
167
+ def _rev_parse(cwd, ref: str) -> str:
168
+ return _git(cwd, "rev-parse", ref).stdout.strip()
169
+
170
+
31
171
  def _merge_stage(task_wt: str, stage_n: int, branch: str, done_commit: str) -> str:
32
172
  """한 stage 의 done commit 을 task 브랜치에 머지. 'merged'|'already_merged'.
33
173
  충돌 시 abort 후 IntegrateError.
@@ -62,12 +202,20 @@ def integrate_stages(
62
202
  task_worktree_path: str, stage_map: list[dict[str, Any]],
63
203
  done_rows: list[dict[str, Any]],
64
204
  teardown: bool = True,
205
+ merge: bool = True,
206
+ verified_head: str = "",
65
207
  ) -> IntegrateResult:
66
208
  """whole-task 통합: Phase A 머지 → Phase B teardown.
67
209
 
68
210
  teardown=False 면 Phase B(stage worktree 디렉터리·registry 키 정리)를 건너뛴다 —
69
- container 는 머지만 하고 stage 트리를 보존한다(스펙 §10). final-verification
70
- 호출은 기본값(True)으로 기존 행위를 유지한다."""
211
+ container 는 머지만 하고 stage 트리를 보존한다(스펙 §10).
212
+ merge=False 면 Phase A 를 건너뛴다 — 한 stage 브랜치가 이미 전체를 담고 있어
213
+ 검증이 거기서 돌았다면 task 브랜치로의 머지는 아무도 읽지 않는 산출물이고,
214
+ 정리 시점에 충돌만 만들 수 있다(`stage_targets.containing_stage`). 정리 대상은
215
+ 그때도 완료된 stage 전부다 — stage 브랜치는 어느 경우에도 남는다.
216
+ verified_head 는 "검증이 실제로 본 커밋"이다. 비면 task worktree HEAD 를 쓴다
217
+ (Phase A 가 만든 통합 commit). merge=False 로 들어올 때는 검증이 돈 stage
218
+ 브랜치의 head 를 넘겨야 브랜치 tip 경고가 옳은 기준으로 판정된다."""
71
219
  from .consumers import latest_done_by_stage
72
220
 
73
221
  res = IntegrateResult()
@@ -75,6 +223,12 @@ def integrate_stages(
75
223
  return res # non-git / degraded — no-op
76
224
  done = latest_done_by_stage(done_rows)
77
225
  ordered = sorted((s["stage_number"] for s in stage_map))
226
+ if not merge:
227
+ res.already_merged.extend(n for n in ordered if done.get(n))
228
+ if teardown:
229
+ _teardown_all(project_id, task_group, task_id,
230
+ task_worktree_path, res, verified_head)
231
+ return res
78
232
 
79
233
  for n in ordered:
80
234
  row = done.get(n)
@@ -98,7 +252,8 @@ def integrate_stages(
98
252
  (res.merged if outcome == "merged" else res.already_merged).append(n)
99
253
 
100
254
  if teardown:
101
- _teardown_all(project_id, task_group, task_id, task_worktree_path, res)
255
+ _teardown_all(project_id, task_group, task_id, task_worktree_path, res,
256
+ verified_head)
102
257
  return res
103
258
 
104
259
 
@@ -111,13 +266,13 @@ def _stage_branch_name(project_id, task_group, task_id, stage_n) -> str | None:
111
266
 
112
267
 
113
268
  def _teardown_all(project_id, task_group, task_id, task_wt: str,
114
- res: IntegrateResult) -> None:
269
+ res: IntegrateResult, verified_head: str = "") -> None:
115
270
  from . import worktree_registry
116
271
 
117
272
  for n in [*res.merged, *res.already_merged]:
118
273
  row = worktree_registry.get_stage_row(project_id, task_group, task_id, n)
119
274
  outcome, detail = _teardown_stage(project_id, task_group, task_id,
120
- task_wt, n, row)
275
+ task_wt, n, row, verified_head)
121
276
  if outcome == "torn_down":
122
277
  res.torn_down.append(n)
123
278
  elif outcome == "skipped":
@@ -138,9 +293,12 @@ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, st
138
293
  return None
139
294
 
140
295
 
141
- def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str) -> str:
142
- """Warn text when the stage branch tip is not an ancestor of the task
143
- worktree HEAD, or "" when it is (or cannot be resolved).
296
+ def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str,
297
+ verified_head: str = "") -> str:
298
+ """Warn text when the stage branch tip is not an ancestor of the commit
299
+ verification actually inspected, or "" when it is (or cannot be resolved).
300
+ `verified_head` defaults to the task worktree HEAD — the integration commit
301
+ Phase A builds. A tip-branch verification passes its own head instead.
144
302
 
145
303
  Phase A merges `done.head_commit`, never the branch ref, so a stage branch
146
304
  can carry commits that whole-task verification never saw. `local-checkout
@@ -152,14 +310,15 @@ def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str) -> str
152
310
  if tip.returncode != 0:
153
311
  return ""
154
312
  sha = tip.stdout.strip()
155
- head = _git(task_wt, "rev-parse", "HEAD").stdout.strip()
313
+ head = verified_head or _git(task_wt, "rev-parse", "HEAD").stdout.strip()
156
314
  if is_ancestor(task_wt, sha, head):
157
315
  return ""
158
- return (f"stage {stage_n} 브랜치 {branch} 의 tip {sha[:12]} 이 검증된 task "
159
- f"HEAD 에 포함되지 않습니다 — 검증에 포함되지 않은 커밋이 있습니다")
316
+ return (f"stage {stage_n} 브랜치 {branch} 의 tip {sha[:12]} 이 검증된 "
317
+ f"{head[:12]} 에 포함되지 않습니다 — 검증에 포함되지 않은 커밋이 있습니다")
160
318
 
161
319
 
162
- def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
320
+ def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row,
321
+ verified_head: str = ""):
163
322
  """Remove the clean stage worktree directory and mark the registry row
164
323
  released. The stage branch is deliberately kept: the per-stage stack
165
324
  branches must survive whole-task verification so that
@@ -181,7 +340,8 @@ def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
181
340
  return outcome
182
341
  worktree_registry.release_status(project_id, task_group, task_id, stage_number=stage_n)
183
342
  if branch:
184
- warning = _unmerged_branch_tip_warning(task_wt, stage_n, branch)
343
+ warning = _unmerged_branch_tip_warning(task_wt, stage_n, branch,
344
+ verified_head)
185
345
  if warning:
186
346
  return ("warn", warning)
187
347
  return ("torn_down", "")
@@ -223,6 +223,21 @@ def _parse_stage_map_row(
223
223
  )
224
224
 
225
225
 
226
+ def stage_number_gap_message(stage_number: int, row_number: int) -> str:
227
+ """행 위치와 stage 번호가 어긋났을 때의 사유. 두 검증기가 같은 문장을 쓴다.
228
+
229
+ "1..N monotonic" 만 적던 동안 이어지는 계획의 작성자는 이 거절을 "검증기가
230
+ 첫 계획만 상정한다" 로 읽었고, 우회한 계획은 `okstra prepare` 에서 다시
231
+ 막혔다(2026-09-24, dev-10860). 무엇을 고쳐야 하는지 문장이 말한다.
232
+ """
233
+ return (
234
+ f"stage numbers must be 1..N with no gap, got {stage_number} at row "
235
+ f"{row_number}: a plan that continues an earlier one re-declares every "
236
+ "earlier stage — copy each `done` stage row unchanged — instead of "
237
+ "starting above them"
238
+ )
239
+
240
+
226
241
  def _validate_stage_numbers(
227
242
  stages: list[StageMapStage], source_plan_path: str,
228
243
  ) -> None:
@@ -238,8 +253,7 @@ def _validate_stage_numbers(
238
253
  if stage.stage_number != row_number:
239
254
  raise StageMapError(
240
255
  "stage_map",
241
- "stage numbers must be 1..N monotonic, "
242
- f"got {stage.stage_number} at row {row_number}",
256
+ stage_number_gap_message(stage.stage_number, row_number),
243
257
  source_plan_path,
244
258
  )
245
259
 
@@ -427,39 +427,114 @@ def commit_is_ancestor(project_root: Path, ancestor: str, descendant: str) -> bo
427
427
  return is_ancestor(project_root, ancestor, descendant)
428
428
 
429
429
 
430
- def check_multi_dep_merged(
430
+ def reconcile_pred_commits(
431
431
  project_root: Path,
432
432
  plan_run_root: Path | None,
433
433
  latest: dict[int, dict[str, Any]],
434
434
  pred_commits: dict[int, str],
435
435
  candidate_base: str,
436
- stage_n: int,
437
- ) -> None:
438
- """Ensure all predecessor commits are merged into the candidate base."""
436
+ ) -> dict[int, str]:
437
+ """선행의 기록 commit 을 실제로 머지할 commit 으로 옮긴다.
438
+
439
+ 기록된 done commit 이 task 브랜치에서 rebase·squash 로 다시 쓰였다면, 그
440
+ commit 을 그대로 머지하는 것은 이미 다른 모양으로 들어간 내용을 한 번 더
441
+ 얹는 일이다. patch-id 로 동치 commit 을 찾으면 그쪽으로 옮기고 원장에도
442
+ 보정 row 를 남긴다(`git-reconcile` 과 같은 기준).
443
+
444
+ 찾지 못하는 것은 오류가 아니다 — 아직 어디에도 머지되지 않은 선행이 오히려
445
+ 기본이고, 그런 선행은 기록된 commit 그대로 공통 바닥에 들어간다. 종전에는
446
+ 이 자리가 게이트였고, task 브랜치에 선행을 손으로 머지하기 전에는 다중 의존
447
+ stage 가 시작할 수 없었다."""
439
448
  from .git_reconcile import content_merged, _record_reconciled
440
449
 
450
+ if not candidate_base:
451
+ return dict(pred_commits)
452
+ resolved: dict[int, str] = {}
441
453
  for dep_stage, head in pred_commits.items():
454
+ resolved[dep_stage] = head
442
455
  if commit_is_ancestor(project_root, head, candidate_base):
443
456
  continue
444
457
  match = content_merged(project_root, head, candidate_base)
445
- if match.status in ("ancestor", "patch-equivalent"):
446
- if plan_run_root is not None:
447
- _record_reconciled(
448
- plan_run_root,
449
- impl_task_key=(latest.get(dep_stage) or {}).get("impl_task_key", ""),
450
- stage=dep_stage,
451
- new_commit=match.matched_commit,
452
- replaced=head,
453
- reason="auto-patch-id",
454
- )
458
+ if match.status != "patch-equivalent" or not match.matched_commit:
455
459
  continue
460
+ resolved[dep_stage] = match.matched_commit
461
+ if plan_run_root is not None:
462
+ _record_reconciled(
463
+ plan_run_root,
464
+ impl_task_key=(latest.get(dep_stage) or {}).get("impl_task_key", ""),
465
+ stage=dep_stage,
466
+ new_commit=match.matched_commit,
467
+ replaced=head,
468
+ reason="auto-patch-id",
469
+ )
470
+ return resolved
471
+
472
+
473
+ @dataclass(frozen=True)
474
+ class StageWorktreeIdentity:
475
+ """선행 머지 브랜치를 만들 때 필요한 이름 조각. 읽기 전용 호출자는 넘기지
476
+ 않으며, 그때 다중 의존 base 는 만들어야 하는 순간에 거부된다."""
477
+
478
+ project_id: str
479
+ task_group: str
480
+ task_id: str
481
+ work_category: str
482
+
483
+
484
+ def _multi_dep_base(
485
+ stage_number: int,
486
+ deps: list[int],
487
+ latest: dict[int, dict[str, Any]],
488
+ anchor_base_commit: str,
489
+ candidate_base: str,
490
+ project_root: Path | None,
491
+ plan_run_root: Path | None,
492
+ identity: "StageWorktreeIdentity | None",
493
+ ) -> str:
494
+ """선행이 둘 이상인 stage 의 base — 그 선행들만 담은 공통 바닥.
495
+
496
+ 종전에는 task 워크트리 HEAD 를 base 로 쓰고, 선행이 거기 머지돼 있지 않으면
497
+ 거부했다. 그 base 는 선행이 아닌 stage 의 작업까지 담고 있어서 stage 경계가
498
+ 흐려졌고, 사람이 손으로 머지하기 전에는 stage 가 시작조차 못 했다. 이제
499
+ 선행들의 done commit 만 담은 브랜치를 만들어 그 위에서 시작한다."""
500
+ from .stage_integrate import IntegrateError, ensure_stage_merge_branch
501
+
502
+ pred_commits: dict[int, str] = {}
503
+ for dep_stage in deps:
504
+ head = (latest.get(dep_stage) or {}).get("head_commit")
505
+ if not head:
506
+ raise StageTargetError(
507
+ f"predecessor stage {dep_stage} has no done row with head_commit "
508
+ f"in consumers.jsonl; multi-dependency stage {stage_number} cannot start"
509
+ )
510
+ pred_commits[dep_stage] = head
511
+ if project_root is None:
456
512
  raise StageTargetError(
457
- f"multi-dependency stage {stage_n}: predecessor stage {dep_stage} "
458
- f"({head[:8]}) is not merged into the task worktree "
459
- f"({candidate_base[:8]}). Merge stage branches "
460
- f"(e.g. the `-s{dep_stage}` branches) into the task worktree "
461
- "(or into main, then refresh the worktree) and retry."
513
+ f"multi-dependency stage {stage_number} needs a git worktree to "
514
+ "resolve its base; the task-key worktree could not be resolved"
462
515
  )
516
+ heads = reconcile_pred_commits(
517
+ project_root, plan_run_root, latest, pred_commits, candidate_base)
518
+ contained = single_containing_head(project_root, heads)
519
+ if contained is not None:
520
+ # 한 선행이 나머지를 이미 담고 있으면 그 commit 이 곧 공통 바닥이다.
521
+ return heads[contained]
522
+ if identity is None:
523
+ raise StageTargetError(
524
+ f"multi-dependency stage {stage_number} has no single predecessor "
525
+ f"commit containing the others ({sorted(heads)}), so its base is an "
526
+ "integration branch that only an implementation run may create"
527
+ )
528
+ try:
529
+ merged = ensure_stage_merge_branch(
530
+ project_root=project_root, project_id=identity.project_id,
531
+ task_group=identity.task_group, task_id=identity.task_id,
532
+ work_category=identity.work_category, heads=heads,
533
+ anchor_base=anchor_base_commit, advance_existing=True)
534
+ except IntegrateError as exc:
535
+ raise StageTargetError(
536
+ f"multi-dependency stage {stage_number}: {exc}") from exc
537
+ return merged.commit
463
538
 
464
539
 
465
540
  def resolve_stage_base_commit(
@@ -469,6 +544,7 @@ def resolve_stage_base_commit(
469
544
  candidate_base: str = "",
470
545
  project_root: Path | None = None,
471
546
  plan_run_root: Path | None = None,
547
+ identity: "StageWorktreeIdentity | None" = None,
472
548
  ) -> str:
473
549
  """Pick the git base commit a stage's isolated worktree branches from."""
474
550
  from .consumers import latest_done_by_stage
@@ -476,30 +552,9 @@ def resolve_stage_base_commit(
476
552
  latest = latest_done_by_stage(consumer_done_rows)
477
553
  deps = stage.get("depends_on") or []
478
554
  if len(deps) >= 2:
479
- stage_number = stage["stage_number"]
480
- pred_commits: dict[int, str] = {}
481
- for dep_stage in deps:
482
- head = (latest.get(dep_stage) or {}).get("head_commit")
483
- if not head:
484
- raise StageTargetError(
485
- f"predecessor stage {dep_stage} has no done row with head_commit "
486
- f"in consumers.jsonl; multi-dependency stage {stage_number} cannot start"
487
- )
488
- pred_commits[dep_stage] = head
489
- if not candidate_base or project_root is None:
490
- raise StageTargetError(
491
- f"candidate base missing for multi-dependency stage {stage_number}; "
492
- "task-key worktree HEAD could not be resolved"
493
- )
494
- check_multi_dep_merged(
495
- project_root,
496
- plan_run_root,
497
- latest,
498
- pred_commits,
499
- candidate_base,
500
- stage_number,
501
- )
502
- return candidate_base
555
+ return _multi_dep_base(
556
+ stage["stage_number"], list(deps), latest, anchor_base_commit,
557
+ candidate_base, project_root, plan_run_root, identity)
503
558
  if not deps:
504
559
  if not anchor_base_commit:
505
560
  raise StageTargetError(
@@ -677,9 +732,22 @@ def _resolve_and_integrate_whole_task_unlocked(
677
732
  done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
678
733
  ) -> dict[str, Any]:
679
734
  """Integrate and resolve a whole-task target while the caller owns the lock."""
735
+ from .consumers import latest_done_by_stage
680
736
  from .stage_integrate import IntegrateError, integrate_stages
681
737
  from .worktree import _git, is_dirty_excluding_okstra
682
738
 
739
+ # 끝나지 않은 stage 가 있으면 _resolve_whole_task_target 이 어차피 거부한다.
740
+ # 그 전에 끝난 stage 를 머지해 두면 거부된 요청이 task worktree 에 머지를
741
+ # 남긴다(되돌리지 않는다). 머지하기 전에 같은 조건으로 거부한다.
742
+ done_by_stage = latest_done_by_stage(done_rows)
743
+ for stage in stage_map:
744
+ n = stage["stage_number"]
745
+ if n not in done_by_stage:
746
+ raise PrepareError(
747
+ f"final-verification(whole-task): stage {n} not done — "
748
+ f"run implementation --stage {n} first"
749
+ )
750
+
683
751
  try:
684
752
  integration = integrate_stages(
685
753
  project_id=project_id,
@@ -852,6 +920,93 @@ def _acquire_single_stage_target(
852
920
  )
853
921
 
854
922
 
923
+ def single_containing_head(project_root: Path,
924
+ heads: dict[int, str]) -> int | None:
925
+ """주어진 stage 들의 commit 을 혼자서 모두 담고 있는 stage. 없으면 None.
926
+
927
+ stage 브랜치는 스택이라 한 stage 가 다른 stage 들을 이미 담고 있는 경우가
928
+ 흔하다. 그럴 땐 새 브랜치를 만들 이유가 없다 — 그 commit 이 곧 공통 바닥이다.
929
+ 판정은 계획의 의존 그래프가 아니라 git 조상 관계로 한다: 계획상 tip 이어도 그
930
+ 브랜치가 실제로 다른 stage 의 커밋을 담고 있는지가 문제이기 때문이다."""
931
+ from .worktree import is_ancestor
932
+
933
+ if not heads:
934
+ return None
935
+ for candidate, head in heads.items():
936
+ if all(is_ancestor(project_root, other, head)
937
+ for n, other in heads.items() if n != candidate):
938
+ return candidate
939
+ return None
940
+
941
+
942
+ def containing_stage(project_root: Path,
943
+ done_by_stage: dict[int, dict[str, Any]]) -> int | None:
944
+ """완료된 stage 전부를 혼자 담고 있는 stage. 하나라도 commit 이 비면 None.
945
+
946
+ whole-task 검증이 "이 브랜치가 곧 전체인가" 를 묻는 자리다. 병렬 stage 가 있어
947
+ tip 이 여럿이면 어느 브랜치도 전체가 아니므로 None 이다."""
948
+ heads = {n: row.get("head_commit", "")
949
+ for n, row in done_by_stage.items() if row.get("head_commit")}
950
+ if len(heads) != len(done_by_stage):
951
+ return None
952
+ return single_containing_head(project_root, heads)
953
+
954
+
955
+ def _acquire_tip_stage_target(
956
+ request: FinalVerificationTargetRequest,
957
+ done_rows: list[dict[str, Any]],
958
+ registry_coordinates: tuple[str, str, str],
959
+ ) -> FinalVerificationTargetAcquisition | None:
960
+ """전체를 담은 단일 stage 브랜치가 있으면 그것을 whole-task 검증 대상으로
961
+ 삼는다 — 머지 없이. 없거나 그 워크트리가 쓸 수 없으면 None 을 돌려 기존
962
+ task 브랜치 통합 경로로 넘긴다."""
963
+ from . import worktree_registry
964
+ from .consumers import latest_done_by_stage
965
+ from .worktree import _git, is_dirty_excluding_okstra
966
+
967
+ done = latest_done_by_stage(done_rows)
968
+ planned = [int(s["stage_number"]) for s in request.stage_map]
969
+ if any(n not in done for n in planned):
970
+ return None # 미완 stage 의 거부 메시지는 기존 경로가 낸다
971
+ tip = containing_stage(request.project_root,
972
+ {n: done[n] for n in planned})
973
+ if tip is None:
974
+ return None
975
+ row = worktree_registry.get_stage_row(*registry_coordinates, tip) or {}
976
+ worktree_path = row.get("worktree_path", "")
977
+ if not worktree_path or not Path(worktree_path).is_dir():
978
+ return None
979
+ head_result = _git(worktree_path, "rev-parse", "HEAD")
980
+ if head_result.returncode != 0:
981
+ return None
982
+ head = head_result.stdout.strip()
983
+ if head != done[tip].get("head_commit", ""):
984
+ # 브랜치 tip 이 done 기록과 갈라져 있다. 검증 대상이 무엇인지 모호하므로
985
+ # 판정을 기존 경로로 넘긴다.
986
+ return None
987
+ if is_dirty_excluding_okstra(worktree_path):
988
+ raise StageTargetError(
989
+ "final-verification: worktree has uncommitted source changes "
990
+ "(outside .okstra/) — commit or stash before verifying"
991
+ )
992
+ anchor_base = worktree_registry.get_implementation_base(
993
+ *registry_coordinates) or ""
994
+ stages = sorted(planned)
995
+ return FinalVerificationTargetAcquisition(
996
+ target=FinalVerificationTarget(
997
+ scope="whole-task",
998
+ base=anchor_base,
999
+ head=head,
1000
+ worktree_path=worktree_path,
1001
+ stages=stages,
1002
+ reports=[done[n].get("report_path", "") for n in stages],
1003
+ ),
1004
+ relocations=(),
1005
+ worktree_branch=row.get("branch", ""),
1006
+ integration_result=None,
1007
+ )
1008
+
1009
+
855
1010
  def _acquire_whole_task_target(
856
1011
  request: FinalVerificationTargetRequest,
857
1012
  done_rows: list[dict[str, Any]],
@@ -859,6 +1014,10 @@ def _acquire_whole_task_target(
859
1014
  ) -> FinalVerificationTargetAcquisition:
860
1015
  from . import worktree_registry
861
1016
 
1017
+ tip = _acquire_tip_stage_target(request, done_rows, registry_coordinates)
1018
+ if tip is not None:
1019
+ return tip
1020
+
862
1021
  project_id, task_group, task_id = registry_coordinates
863
1022
  entry = worktree_registry.lookup(project_id, task_group, task_id)
864
1023
  worktree_path = (
@@ -946,6 +1105,8 @@ def integrate_and_teardown_whole_task(
946
1105
  if entry is None:
947
1106
  return {"skipped": "task worktree is no longer registered"}
948
1107
 
1108
+ done_by_stage = consumers.latest_done_by_stage(done_rows)
1109
+ tip = containing_stage(project_root, done_by_stage)
949
1110
  with worktree_provision_mutex(okstra_home(), project_id, task_group, task_id):
950
1111
  result = integrate_stages(
951
1112
  project_id=project_id,
@@ -955,6 +1116,11 @@ def integrate_and_teardown_whole_task(
955
1116
  stage_map=stage_map,
956
1117
  done_rows=done_rows,
957
1118
  teardown=True,
1119
+ # 한 stage 브랜치가 이미 전체를 담고 있으면 검증은 거기서 돌았다
1120
+ # (`_acquire_tip_stage_target`). 그 경우 task 브랜치로의 머지는 아무도
1121
+ # 읽지 않는 산출물이므로 정리만 한다.
1122
+ merge=tip is None,
1123
+ verified_head=(done_by_stage.get(tip) or {}).get("head_commit", ""),
958
1124
  )
959
1125
  return {
960
1126
  "tornDown": result.torn_down,
@@ -36,7 +36,7 @@ from .dispatch_core import (
36
36
  await_dispatches,
37
37
  dispatch_plan,
38
38
  )
39
- from .ports.worker_dispatch import WorkerDispatchRequest
39
+ from .ports.worker_dispatch import WorkerDispatchPort, WorkerDispatchRequest
40
40
  from .registry.host_registry import default_host_registry
41
41
  from .registry.provider_registry import default_provider_registry
42
42
  from .session import observe_lead_session
@@ -161,18 +161,7 @@ def _dispatch(args) -> int:
161
161
  raise DispatchError("--jobs-file and --workers cannot be combined")
162
162
  manifest = _load_manifest(args.project_root, args.run_manifest)
163
163
  _observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
164
- terminal_backend = _manifest_backend(manifest)
165
- lead_runtime = _require_string(manifest, "leadRuntime")
166
- fallback_port = default_host_registry().resolve(
167
- lead_runtime
168
- ).worker_dispatch()
169
- port = dispatch_port_for_terminal_backend(
170
- terminal_backend,
171
- fallback_port,
172
- supported_worker_wrappers=_SUPPORTED_WRAPPERS,
173
- unsupported_worker_label=f"{lead_runtime} lead",
174
- required_lead_runtime=None if _is_cmux_run(manifest) else lead_runtime,
175
- )
164
+ port = team_dispatch_port(manifest)
176
165
  request = WorkerDispatchRequest(
177
166
  project_root=Path(args.project_root),
178
167
  run_manifest=Path(args.run_manifest),
@@ -195,6 +184,26 @@ def _dispatch(args) -> int:
195
184
  return result
196
185
 
197
186
 
187
+ def team_dispatch_port(manifest: Mapping[str, Any]) -> WorkerDispatchPort:
188
+ """이 run 의 워커를 띄울 포트. cmux run 이면 pane, 아니면 리드 호스트의 포트.
189
+
190
+ `team dispatch` 와 finalize 의 `translate` 단계가 같이 쓴다 — 번역자만
191
+ CLI 래퍼 디스패처로 띄우던 동안 cmux run 에서도 번역자는 pane 없이 돌았다
192
+ (실측 2026-09-22, dev-10860 implementation-planning-001: 18건 중 번역자 1건).
193
+ """
194
+ lead_runtime = _require_string(manifest, "leadRuntime")
195
+ fallback_port = default_host_registry().resolve(
196
+ lead_runtime
197
+ ).worker_dispatch()
198
+ return dispatch_port_for_terminal_backend(
199
+ _manifest_backend(manifest),
200
+ fallback_port,
201
+ supported_worker_wrappers=_SUPPORTED_WRAPPERS,
202
+ unsupported_worker_label=f"{lead_runtime} lead",
203
+ required_lead_runtime=None if _is_cmux_run(manifest) else lead_runtime,
204
+ )
205
+
206
+
198
207
  def _await(args) -> int:
199
208
  manifest = _load_manifest(args.project_root, args.run_manifest)
200
209
  _validate_team_manifest(manifest)