okstra 0.206.0 → 0.207.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 (259) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.mjs +7 -1
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +1 -1
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/docs/architecture/storage-model.md +1 -0
  7. package/docs/architecture.md +40 -16
  8. package/docs/cli.md +17 -15
  9. package/docs/contributor-change-matrix.md +3 -2
  10. package/docs/performance-improvement-plan-v2.md +1 -1
  11. package/docs/project-structure-overview.md +43 -20
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/agents/operations/code-review.json +1 -1
  15. package/runtime/bin/lib/okstra/usage.sh +3 -3
  16. package/runtime/bin/okstra-compact-reminder.sh +1 -1
  17. package/runtime/bin/okstra-spawn-followups.py +2 -2
  18. package/runtime/prompts/duties/direction-selection-worker.json +1 -1
  19. package/runtime/prompts/launch.template.md +2 -2
  20. package/runtime/prompts/lead/adapters/cmux.md +4 -3
  21. package/runtime/prompts/lead/context-loader.md +1 -1
  22. package/runtime/prompts/lead/convergence.md +44 -12
  23. package/runtime/prompts/lead/okstra-lead-contract.md +44 -73
  24. package/runtime/prompts/lead/phase-routing.md +64 -0
  25. package/runtime/prompts/lead/report-writer.md +10 -8
  26. package/runtime/prompts/lead/team-contract.md +1 -1
  27. package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
  28. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  29. package/runtime/prompts/profiles/_common-contract.md +2 -2
  30. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  31. package/runtime/prompts/profiles/forbidden-actions.json +0 -94
  32. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  33. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  34. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
  35. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  36. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
  40. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
  41. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
  42. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +1 -1
  43. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
  44. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +10 -1
  45. package/runtime/python/okstra_ctl/analysis_inputs.py +0 -39
  46. package/runtime/python/okstra_ctl/analysis_scope.py +31 -0
  47. package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
  48. package/runtime/python/okstra_ctl/asset_roots.py +19 -0
  49. package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
  50. package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
  51. package/runtime/python/okstra_ctl/code_review_target.py +92 -6
  52. package/runtime/python/okstra_ctl/consumers.py +12 -0
  53. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
  54. package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
  55. package/runtime/python/okstra_ctl/dispatch_state.py +34 -5
  56. package/runtime/python/okstra_ctl/doctor.py +2 -1
  57. package/runtime/python/okstra_ctl/domain/provider.py +5 -0
  58. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
  59. package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
  60. package/runtime/python/okstra_ctl/execution_mutation_audit.py +46 -9
  61. package/runtime/python/okstra_ctl/handoff.py +11 -466
  62. package/runtime/python/okstra_ctl/handoff_error.py +5 -0
  63. package/runtime/python/okstra_ctl/implementation_direction.py +0 -477
  64. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +13 -1
  65. package/runtime/python/okstra_ctl/lead_progress.py +33 -1
  66. package/runtime/python/okstra_ctl/manager_view.py +26 -19
  67. package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
  68. package/runtime/python/okstra_ctl/models.py +4 -1
  69. package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
  70. package/runtime/python/okstra_ctl/option_comparison.py +3 -165
  71. package/runtime/python/okstra_ctl/option_votes.py +3 -191
  72. package/runtime/python/okstra_ctl/paths.py +8 -6
  73. package/runtime/python/okstra_ctl/phases/catalog.py +56 -12
  74. package/runtime/python/okstra_ctl/phases/change_impact_analysis/boundary.json +11 -0
  75. package/runtime/python/okstra_ctl/phases/change_impact_analysis/entry.py +39 -0
  76. package/runtime/python/okstra_ctl/{report_html/view_models/change_impact_analysis.py → phases/change_impact_analysis/report.py} +3 -3
  77. package/runtime/python/okstra_ctl/phases/change_impact_analysis/spec.md +26 -0
  78. package/runtime/python/okstra_ctl/phases/change_impact_analysis/validation.py +23 -0
  79. package/runtime/python/okstra_ctl/phases/error_analysis/__init__.py +1 -0
  80. package/runtime/python/okstra_ctl/phases/error_analysis/boundary.json +9 -0
  81. package/runtime/{prompts/profiles/error-analysis.md → python/okstra_ctl/phases/error_analysis/profile.md} +2 -2
  82. package/runtime/python/okstra_ctl/{report_html/view_models/error_analysis.py → phases/error_analysis/report.py} +9 -8
  83. package/runtime/{templates/reports → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis-input.template.md +1 -1
  84. package/runtime/python/okstra_ctl/phases/error_analysis/spec.md +118 -0
  85. package/runtime/python/okstra_ctl/phases/error_analysis/validation.py +241 -0
  86. package/runtime/python/okstra_ctl/phases/feature_analysis/__init__.py +1 -0
  87. package/runtime/python/okstra_ctl/phases/feature_analysis/boundary.json +8 -0
  88. package/runtime/python/okstra_ctl/phases/feature_analysis/entry.py +63 -0
  89. package/runtime/python/okstra_ctl/{report_html/view_models/feature_analysis.py → phases/feature_analysis/report.py} +12 -5
  90. package/runtime/python/okstra_ctl/phases/feature_analysis/spec.md +22 -0
  91. package/runtime/python/okstra_ctl/phases/feature_analysis/validation.py +27 -0
  92. package/runtime/python/okstra_ctl/phases/feature_analysis/wizard.py +95 -0
  93. package/runtime/python/okstra_ctl/phases/final_verification/boundary.json +8 -0
  94. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -2
  95. package/runtime/{templates/reports → python/okstra_ctl/phases/final_verification/report_assets}/final-verification-input.template.md +1 -1
  96. package/runtime/python/okstra_ctl/phases/final_verification/spec.md +1 -1
  97. package/runtime/python/okstra_ctl/phases/implementation/__init__.py +1 -0
  98. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +17 -0
  99. package/runtime/python/okstra_ctl/{implementation_stage.py → phases/implementation/entry.py} +22 -10
  100. package/runtime/{prompts/host-orchestration/implementation.md → python/okstra_ctl/phases/implementation/host-rules.md} +1 -1
  101. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-deliverable.md +1 -1
  102. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-executor.md +4 -3
  103. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-verifier.md +18 -7
  104. package/runtime/{prompts/profiles/implementation.md → python/okstra_ctl/phases/implementation/profile.md} +5 -5
  105. package/runtime/python/okstra_ctl/{report_html/view_models/implementation.py → phases/implementation/report.py} +3 -3
  106. package/runtime/{templates/reports → python/okstra_ctl/phases/implementation/report_assets}/implementation-input.template.md +1 -1
  107. package/runtime/python/okstra_ctl/phases/implementation/spec.md +238 -0
  108. package/runtime/python/okstra_ctl/phases/implementation/validation.py +205 -0
  109. package/runtime/python/okstra_ctl/phases/implementation/wizard.py +39 -0
  110. package/runtime/python/okstra_ctl/phases/implementation_option_selection/__init__.py +1 -0
  111. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +80 -0
  112. package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +10 -0
  113. package/runtime/python/okstra_ctl/phases/implementation_option_selection/comparison.py +168 -0
  114. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +27 -0
  115. package/runtime/{prompts/profiles/implementation-option-selection.md → python/okstra_ctl/phases/implementation_option_selection/profile.md} +3 -3
  116. package/runtime/python/okstra_ctl/{report_html/view_models/implementation_option_selection.py → phases/implementation_option_selection/report.py} +2 -2
  117. package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +83 -0
  118. package/runtime/python/okstra_ctl/{implementation_options.py → phases/implementation_option_selection/validation.py} +3 -3
  119. package/runtime/python/okstra_ctl/phases/implementation_option_selection/votes.py +194 -0
  120. package/runtime/python/okstra_ctl/phases/implementation_planning/__init__.py +1 -0
  121. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +2345 -0
  122. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +12 -0
  123. package/runtime/python/okstra_ctl/phases/implementation_planning/entry.py +161 -0
  124. package/runtime/python/okstra_ctl/phases/implementation_planning/guidance.py +178 -0
  125. package/runtime/{prompts/lead → python/okstra_ctl/phases/implementation_planning/instructions}/plan-body-verification.md +61 -51
  126. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +3295 -0
  127. package/runtime/{prompts/profiles/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/profile.md} +74 -25
  128. package/runtime/python/okstra_ctl/phases/implementation_planning/report.py +237 -0
  129. package/runtime/{templates/reports → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning-input.template.md +2 -2
  130. package/runtime/python/okstra_ctl/phases/implementation_planning/spec.md +204 -0
  131. package/runtime/python/okstra_ctl/phases/implementation_planning/validation.py +597 -0
  132. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +166 -0
  133. package/runtime/python/okstra_ctl/phases/improvement_discovery/boundary.json +12 -0
  134. package/runtime/python/okstra_ctl/{improvement_lenses.py → phases/improvement_discovery/lenses.py} +1 -6
  135. package/runtime/{prompts/profiles/improvement-discovery.md → python/okstra_ctl/phases/improvement_discovery/profile.md} +5 -5
  136. package/runtime/python/okstra_ctl/{report_html/view_models/improvement_discovery.py → phases/improvement_discovery/report.py} +3 -3
  137. package/runtime/{templates/reports → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery-input.template.md +1 -2
  138. package/runtime/python/okstra_ctl/phases/improvement_discovery/spec.md +29 -0
  139. package/runtime/{validators/validate_improvement_report.py → python/okstra_ctl/phases/improvement_discovery/validation.py} +5 -14
  140. package/runtime/python/okstra_ctl/phases/project_analysis/__init__.py +1 -0
  141. package/runtime/python/okstra_ctl/phases/project_analysis/boundary.json +8 -0
  142. package/runtime/python/okstra_ctl/phases/project_analysis/entry.py +11 -0
  143. package/runtime/python/okstra_ctl/{report_html/view_models/project_analysis.py → phases/project_analysis/report.py} +3 -3
  144. package/runtime/python/okstra_ctl/phases/project_analysis/spec.md +33 -0
  145. package/runtime/python/okstra_ctl/phases/project_analysis/validation.py +55 -0
  146. package/runtime/python/okstra_ctl/phases/release_handoff/__init__.py +1 -0
  147. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +17 -0
  148. package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +147 -0
  149. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +446 -0
  150. package/runtime/{prompts/profiles/release-handoff.md → python/okstra_ctl/phases/release_handoff/profile.md} +3 -3
  151. package/runtime/python/okstra_ctl/{report_html/view_models/release_handoff.py → phases/release_handoff/report.py} +3 -3
  152. package/runtime/{templates/reports → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff-input.template.md +1 -1
  153. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +233 -0
  154. package/runtime/python/okstra_ctl/phases/release_handoff/wizard.py +84 -0
  155. package/runtime/python/okstra_ctl/phases/requirements_discovery/__init__.py +1 -0
  156. package/runtime/python/okstra_ctl/phases/requirements_discovery/boundary.json +9 -0
  157. package/runtime/{prompts/profiles/requirements-discovery.md → python/okstra_ctl/phases/requirements_discovery/profile.md} +2 -3
  158. package/runtime/python/okstra_ctl/{report_html/view_models/requirements_discovery.py → phases/requirements_discovery/report.py} +3 -3
  159. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +132 -0
  160. package/runtime/{validators/validate_fanout.py → python/okstra_ctl/phases/requirements_discovery/validation.py} +11 -12
  161. package/runtime/python/okstra_ctl/phases/technical_verification/__init__.py +1 -0
  162. package/runtime/python/okstra_ctl/phases/technical_verification/boundary.json +9 -0
  163. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +100 -0
  164. package/runtime/{prompts/profiles/technical-verification.md → python/okstra_ctl/phases/technical_verification/profile.md} +2 -2
  165. package/runtime/python/okstra_ctl/{report_html/view_models/technical_verification.py → phases/technical_verification/report.py} +2 -2
  166. package/runtime/python/okstra_ctl/phases/technical_verification/spec.md +37 -0
  167. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +90 -0
  168. package/runtime/python/okstra_ctl/plan_approval.py +70 -0
  169. package/runtime/python/okstra_ctl/plan_items_cli.py +2 -2130
  170. package/runtime/python/okstra_ctl/process_group.py +118 -0
  171. package/runtime/python/okstra_ctl/profile_show.py +3 -3
  172. package/runtime/python/okstra_ctl/render.py +15 -4
  173. package/runtime/python/okstra_ctl/report_assembly.py +28 -92
  174. package/runtime/python/okstra_ctl/report_finalize.py +106 -2
  175. package/runtime/python/okstra_ctl/report_html/context_links.py +1 -1
  176. package/runtime/python/okstra_ctl/report_projections.py +1 -36
  177. package/runtime/python/okstra_ctl/report_routing.py +23 -0
  178. package/runtime/python/okstra_ctl/report_synthesis_packet.py +4 -73
  179. package/runtime/python/okstra_ctl/report_validation_identity.py +38 -0
  180. package/runtime/python/okstra_ctl/report_views.py +1 -1
  181. package/runtime/python/okstra_ctl/run.py +68 -350
  182. package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
  183. package/runtime/python/okstra_ctl/stage_map.py +13 -0
  184. package/runtime/python/okstra_ctl/team.py +108 -9
  185. package/runtime/python/okstra_ctl/technical_verification_facts.py +52 -0
  186. package/runtime/python/okstra_ctl/wizard/__init__.py +31 -31
  187. package/runtime/python/okstra_ctl/wizard/api.py +18 -0
  188. package/runtime/python/okstra_ctl/wizard/outcome.py +3 -12
  189. package/runtime/python/okstra_ctl/wizard/registry.py +20 -12
  190. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +0 -97
  191. package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
  192. package/runtime/python/okstra_ctl/wizard/steps_plan.py +10 -263
  193. package/runtime/python/okstra_ctl/wizard/steps_roles.py +2 -1
  194. package/runtime/python/okstra_ctl/work_categories.py +1 -1
  195. package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
  196. package/runtime/python/okstra_ctl/worker_prompt_contract.py +36 -0
  197. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
  198. package/runtime/python/okstra_ctl/worker_runner.py +21 -3
  199. package/runtime/python/okstra_ctl/workflow.py +26 -143
  200. package/runtime/python/okstra_ctl/write_policy.py +57 -7
  201. package/runtime/python/okstra_project/dirs.py +14 -0
  202. package/runtime/python/okstra_project/resolver.py +2 -1
  203. package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
  204. package/runtime/skills/okstra-brief-gen/SKILL.md +3 -3
  205. package/runtime/skills/okstra-code-review/SKILL.md +70 -32
  206. package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
  207. package/runtime/skills/okstra-run/SKILL.md +3 -3
  208. package/runtime/templates/manager/view.template.html +18 -1
  209. package/runtime/templates/reports/quick-input.template.md +1 -1
  210. package/runtime/templates/reports/task-brief.template.md +1 -1
  211. package/runtime/validators/validate-brief.py +2 -2
  212. package/runtime/validators/validate-run.py +299 -3940
  213. package/runtime/validators/validate_analysis_report.py +14 -126
  214. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +0 -147
  215. package/runtime/python/okstra_ctl/technical_verification.py +0 -195
  216. /package/runtime/{prompts/profiles/change-impact-analysis.json → python/okstra_ctl/phases/change_impact_analysis/profile.json} +0 -0
  217. /package/runtime/{prompts/profiles/change-impact-analysis.md → python/okstra_ctl/phases/change_impact_analysis/profile.md} +0 -0
  218. /package/runtime/{templates/reports → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis-input.template.md +0 -0
  219. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.html +0 -0
  220. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.md +0 -0
  221. /package/runtime/{prompts/profiles/error-analysis.json → python/okstra_ctl/phases/error_analysis/profile.json} +0 -0
  222. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.html +0 -0
  223. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.md +0 -0
  224. /package/runtime/{prompts/profiles/feature-analysis.json → python/okstra_ctl/phases/feature_analysis/profile.json} +0 -0
  225. /package/runtime/{prompts/profiles/feature-analysis.md → python/okstra_ctl/phases/feature_analysis/profile.md} +0 -0
  226. /package/runtime/{templates/reports → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis-input.template.md +0 -0
  227. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.html +0 -0
  228. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.md +0 -0
  229. /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-diff-review.md +0 -0
  230. /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-self-check.md +0 -0
  231. /package/runtime/{prompts/profiles/implementation.json → python/okstra_ctl/phases/implementation/profile.json} +0 -0
  232. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.html +0 -0
  233. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.md +0 -0
  234. /package/runtime/{prompts/profiles/implementation-option-selection.json → python/okstra_ctl/phases/implementation_option_selection/profile.json} +0 -0
  235. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.html +0 -0
  236. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.md +0 -0
  237. /package/runtime/{prompts/host-orchestration/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/host-rules.md} +0 -0
  238. /package/runtime/{prompts/profiles/implementation-planning.json → python/okstra_ctl/phases/implementation_planning/profile.json} +0 -0
  239. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.html +0 -0
  240. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.md +0 -0
  241. /package/runtime/{prompts/profiles/improvement-discovery.json → python/okstra_ctl/phases/improvement_discovery/profile.json} +0 -0
  242. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.html +0 -0
  243. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.md +0 -0
  244. /package/runtime/{prompts/profiles/project-analysis.json → python/okstra_ctl/phases/project_analysis/profile.json} +0 -0
  245. /package/runtime/{prompts/profiles/project-analysis.md → python/okstra_ctl/phases/project_analysis/profile.md} +0 -0
  246. /package/runtime/{templates/reports → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis-input.template.md +0 -0
  247. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.html +0 -0
  248. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.md +0 -0
  249. /package/runtime/{prompts/profiles/release-handoff.json → python/okstra_ctl/phases/release_handoff/profile.json} +0 -0
  250. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.html +0 -0
  251. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.md +0 -0
  252. /package/runtime/python/okstra_ctl/{fanout.py → phases/requirements_discovery/fanout.py} +0 -0
  253. /package/runtime/{prompts/profiles/requirements-discovery.json → python/okstra_ctl/phases/requirements_discovery/profile.json} +0 -0
  254. /package/runtime/{templates/reports → python/okstra_ctl/phases/requirements_discovery/report_assets}/fan-out-unit.template.md +0 -0
  255. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.html +0 -0
  256. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.md +0 -0
  257. /package/runtime/{prompts/profiles/technical-verification.json → python/okstra_ctl/phases/technical_verification/profile.json} +0 -0
  258. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.html +0 -0
  259. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.md +0 -0
@@ -125,10 +125,11 @@ def validate_write_policy_payload(payload: Mapping[str, Any]) -> None:
125
125
  source = _mapping(payload.get("sourcePolicy"), "sourcePolicy")
126
126
  git = _mapping(payload.get("gitPolicy"), "gitPolicy")
127
127
  auxiliary = _mapping(payload.get("auxiliaryPolicy"), "auxiliaryPolicy")
128
- if set(artifact) != {"allowedRoot", "allowedPaths"}:
128
+ if set(artifact) - {"preservedPaths"} != {"allowedRoot", "allowedPaths"}:
129
129
  raise WritePolicyError("artifactPolicy is incomplete")
130
130
  _absolute_text(artifact.get("allowedRoot"), "artifactPolicy.allowedRoot")
131
131
  _relative_paths(artifact.get("allowedPaths", ()))
132
+ _relative_paths(artifact.get("preservedPaths", ()))
132
133
  if source.get("mode") not in {"source-readonly", "project-mutation"}:
133
134
  raise WritePolicyError("sourcePolicy mode is invalid")
134
135
  if set(source) - {"plannedPathsDeclared"} != {
@@ -123,6 +123,7 @@ class MutationAuditResult:
123
123
  declared_out_of_plan_paths: tuple[str, ...] = ()
124
124
  unobserved_artifact_paths: tuple[str, ...] = ()
125
125
  external_workspace_changes: tuple[str, ...] = ()
126
+ violating_artifact_paths: tuple[str, ...] = ()
126
127
 
127
128
  def change_summary(self) -> dict[str, Any]:
128
129
  return {
@@ -140,6 +141,7 @@ class MutationAuditResult:
140
141
  "declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
141
142
  "unobservedArtifactPaths": list(self.unobserved_artifact_paths),
142
143
  "externalWorkspaceChanges": list(self.external_workspace_changes),
144
+ "violatingArtifactPaths": list(self.violating_artifact_paths),
143
145
  "beforeDigest": self.before_digest,
144
146
  "afterDigest": self.after_digest,
145
147
  }
@@ -242,10 +244,11 @@ class ExecutionMutationAudit:
242
244
  rows,
243
245
  source_changes,
244
246
  )
245
- artifact_failures, untracked_artifact_changes, switched, external = (
247
+ violating_artifacts, untracked_artifact_changes, switched, external = (
246
248
  _artifact_policy_failures(before, rows, artifact_changed, after=after)
247
249
  )
248
- violations.extend(artifact_failures)
250
+ if violating_artifacts:
251
+ violations.append("artifact-root change exceeds batch policy union")
249
252
  worker_artifact_changes = {
250
253
  path for path in artifact_changed
251
254
  if path not in untracked_artifact_changes and path not in switched and path not in external
@@ -281,11 +284,13 @@ class ExecutionMutationAudit:
281
284
  + tuple(f"unattributed change outside assigned worktree and artifact paths: {path}"
282
285
  for path in sorted(external))
283
286
  + _plan_change_warnings(rows, source_changes | untracked_changes,
284
- artifact_changed, out_of_plan_edits, unobserved),
287
+ artifact_changed, out_of_plan_edits, unobserved)
288
+ + _linked_dependency_warnings(after.root),
285
289
  changed_artifact_paths=tuple(sorted(artifact_changed - external)),
286
290
  declared_out_of_plan_paths=tuple(out_of_plan_edits),
287
291
  unobserved_artifact_paths=tuple(sorted(unobserved)),
288
292
  external_workspace_changes=tuple(sorted(external)),
293
+ violating_artifact_paths=tuple(sorted(violating_artifacts)),
289
294
  )
290
295
 
291
296
 
@@ -370,6 +375,31 @@ def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
370
375
  )
371
376
 
372
377
 
378
+ def _linked_dependency_warnings(root: Path) -> tuple[str, ...]:
379
+ """worktree 밖을 가리키는 `node_modules` 링크를 알린다.
380
+
381
+ 감사는 `node_modules` 를 보지 않으므로, 그 링크를 통한 패키지 설치가 메인
382
+ 체크아웃의 의존성 트리를 덮어써도 흔적이 남지 않는다(2026-09-25 실측).
383
+ """
384
+ linked: list[str] = []
385
+ for current, directories, _ in os.walk(root, followlinks=False):
386
+ kept: list[str] = []
387
+ for name in directories:
388
+ path = Path(current) / name
389
+ if name == ".git":
390
+ continue
391
+ if name not in _INSTALLED_DEPENDENCY_DIRS:
392
+ kept.append(name)
393
+ elif path.is_symlink() and not _is_relative_to(path.resolve(), root.resolve()):
394
+ linked.append(f"{path.relative_to(root).as_posix()} -> {path.resolve()}")
395
+ directories[:] = kept
396
+ return tuple(
397
+ f"dependency tree links outside the assigned worktree: {row}; "
398
+ "a package install through it rewrites that target and this audit does not observe it"
399
+ for row in sorted(linked)
400
+ )
401
+
402
+
373
403
  def _branch_switch_warnings(
374
404
  before: MutationSnapshot, after: MutationSnapshot, switched: set[str],
375
405
  ) -> tuple[str, ...]:
@@ -758,8 +788,8 @@ def _artifact_policy_failures(
758
788
  changed: set[str],
759
789
  *,
760
790
  after: MutationSnapshot | None = None,
761
- ) -> tuple[list[str], set[str], set[str], set[str]]:
762
- """위반·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
791
+ ) -> tuple[set[str], set[str], set[str], set[str]]:
792
+ """위반 경로·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
763
793
 
764
794
  작업트리와 기본 체크아웃이 다르면 기본 체크아웃 소스의 변경 주체는
765
795
  전후 비교로 판단할 수 없다. 배정된 산출물 밖의 변경은 관측으로 남기고,
@@ -771,10 +801,18 @@ def _artifact_policy_failures(
771
801
  for value in snapshot.orchestrator_paths
772
802
  if _is_relative_to(Path(value), snapshot.artifact_root)
773
803
  }
804
+ preserved = {
805
+ path
806
+ for policy in policies
807
+ for path in policy.artifact_policy.get("preservedPaths", ())
808
+ }
774
809
  unauthorized = {
775
810
  path for path in changed
776
- if not any(_is_within(path, item) for item in allowed)
777
- and not any(_is_within(path, item) for item in orchestrator)
811
+ if path in preserved
812
+ or (
813
+ not any(_is_within(path, item) for item in allowed)
814
+ and not any(_is_within(path, item) for item in orchestrator)
815
+ )
778
816
  }
779
817
  if snapshot.artifact_root == snapshot.root:
780
818
  unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
@@ -800,8 +838,7 @@ def _artifact_policy_failures(
800
838
  and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
801
839
  }
802
840
  violating = unauthorized - untracked_outside - switched - external
803
- failures = ["artifact-root change exceeds batch policy union"] if violating else []
804
- return failures, untracked_outside, switched, external
841
+ return violating, untracked_outside, switched, external
805
842
 
806
843
 
807
844
  def _branch_switch_paths(
@@ -1,464 +1,26 @@
1
- """release-handoff 의 강제 지점 — stage 하나가 PR 하나다.
2
-
3
- 자격 판정(eligible) · stage 별 PR head/base 계산(pr-plan) · verified/pr 행 기록을
4
- 단일 모듈로 강제한다. lead 는 `okstra handoff <sub>` 로 호출만 한다.
5
- 설계: .project-docs/specs/2026-09-24-release-handoff-per-stage-pr-design.md
6
- """
7
-
1
+ """공통 검증 기록과 handoff 명령의 조립 진입점."""
8
2
  from __future__ import annotations
9
3
 
10
4
  import json
11
- import subprocess
12
5
  from pathlib import Path
13
6
  from typing import Any, Dict, List, Optional
14
7
 
15
- from . import consumers, stage_targets, worktree_registry
8
+ from . import consumers
9
+ from .handoff_error import HandoffError
16
10
  from .final_report_paths import final_report_data_path
17
- from .handoff_verification import (
18
- VerificationEvidenceError, load_latest_verification,
19
- verification_row_is_current,
20
- )
11
+ from .handoff_verification import VerificationEvidenceError, load_latest_verification
21
12
  from .paths import RunRef
22
13
  from .json_boundary import JsonBoundaryError, load_owned_object
23
- from .release_gate import (
24
- blocking_condition_ids,
25
- release_handoff_allowed,
26
- verdict_token,
27
- )
28
- from .stage_integrate import (IntegrateError, MergeBranchConflict,
29
- ensure_stage_merge_branch)
14
+ from .release_gate import blocking_condition_ids, release_handoff_allowed, verdict_token
30
15
  from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
31
- from .worktree import (compute_branch_name, main_worktree_path,
32
- is_dirty_excluding_okstra, nested_worktree_excludes,
33
- is_ancestor, remove_worktree_force, _git)
16
+ from .worktree import is_ancestor
34
17
 
35
18
 
36
19
  SINGLE_STAGE_SCOPE = "single-stage"
37
20
  WHOLE_TASK_SCOPE = "whole-task"
38
21
 
39
22
 
40
- class HandoffError(Exception):
41
- """자격/전제 위반 — exit 1, actionable 메시지."""
42
-
43
-
44
- class HandoffConflict(Exception):
45
- """선행 stage 들 사이의 merge 충돌 — exit 2, 충돌 경로 동봉."""
46
-
47
- def __init__(self, stage: int, branch: str, paths: List[str]):
48
- self.stage = stage
49
- self.branch = branch
50
- self.paths = paths
51
- super().__init__(
52
- f"merge conflict while merging stage {stage} ({branch}): "
53
- f"{', '.join(paths)}")
54
-
55
-
56
- def compute_eligibility(stage_map: List[Dict[str, Any]],
57
- rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
58
- """stage 별 PR 가능 여부와 차단 사유. 의존이 어느 브랜치로 내려앉는지는
59
- 선택 집합과 origin 의 상태가 정하므로 여기서는 판정하지 않는다
60
- (`pr_plan` 의 base 해소가 담당)."""
61
- eligibility = stage_targets.stage_lifecycle_snapshot_from_rows(
62
- stage_map,
63
- rows,
64
- ).handoff_eligibility()
65
- # 재구현된 stage 의 커밋 동일성은 `consumers.verified_accepted_stages` 가
66
- # 이미 본다(`handoff_eligibility` 가 그 집합을 읽는다). 여기 남는 판정은
67
- # "그 승인 행이 가리키는 검증 run 이 아직 최신인가" 하나다.
68
- last = {row["stage"]: row for row in rows if row.get("status") == "verified"}
69
- for item in eligibility:
70
- if item["eligible"] and not verification_row_is_current(last.get(item["stage"], {})):
71
- item["eligible"] = False
72
- item["reasons"].append("verification-stale-or-unproven: re-run final-verification")
73
- return eligibility
74
-
75
-
76
- def _run_git(args: List[str], cwd, check: bool = True) -> subprocess.CompletedProcess:
77
- """worktree._git(공용 러너) 위에 check=True 시 HandoffError 변환만 얹는다."""
78
- r = _git(Path(cwd), *args)
79
- if check and r.returncode != 0:
80
- raise HandoffError(f"git {' '.join(args)} failed: {r.stderr.strip()}")
81
- return r
82
-
83
-
84
- def _require_eligible(stage_map, rows, stages) -> Dict[int, Dict[str, Any]]:
85
- elig = {e["stage"]: e for e in compute_eligibility(stage_map, rows)}
86
- unknown = [n for n in stages if n not in elig]
87
- if unknown:
88
- raise HandoffError(f"stages not in Stage Map: {unknown}")
89
- bad = {n: elig[n]["reasons"] for n in stages if elig[n]["reasons"]}
90
- if bad:
91
- raise HandoffError(f"stages not eligible: {bad}")
92
- return elig
93
-
94
23
 
95
- def _branch_exists(project_root, branch: str) -> bool:
96
- return _git(Path(project_root), "rev-parse", "--verify", "--quiet",
97
- f"refs/heads/{branch}").returncode == 0
98
-
99
-
100
- def _stage_branch(work_category: str, task_id: str, stage: int) -> str:
101
- return compute_branch_name(work_category=work_category,
102
- task_id_segment=task_id, stage_number=stage)
103
-
104
-
105
- def _done_head(done: Dict[int, Dict[str, Any]], stage: int) -> str:
106
- head = (done.get(stage) or {}).get("head_commit", "")
107
- if not head:
108
- raise HandoffError(
109
- f"stage {stage} has no done row with head_commit in "
110
- "consumers.jsonl — finish that implementation stage first")
111
- return head
112
-
113
-
114
- def _merge_base_for(*, project_root, project_id, task_group, task_id,
115
- work_category, heads, anchor_base):
116
- """선행들을 담은 바닥 브랜치. 원시연산은 잠금 없이 돌므로 여기서 task-key
117
- 락을 잡는다 — implementation 경로는 이미 그 락 안에서 직접 부른다.
118
- 기존 브랜치에 빠진 commit 이 있으면 거부한다: 그 브랜치는 이미 열린 PR 의
119
- base 일 수 있고, 말없이 옮기면 리뷰어가 본 diff 가 바뀐다."""
120
- from okstra_project.dirs import okstra_home
121
-
122
- from .locks import worktree_provision_mutex
123
-
124
- try:
125
- with worktree_provision_mutex(okstra_home(), project_id, task_group,
126
- task_id):
127
- return ensure_stage_merge_branch(
128
- project_root=project_root, project_id=project_id,
129
- task_group=task_group, task_id=task_id,
130
- work_category=work_category, heads=heads,
131
- anchor_base=anchor_base)
132
- except MergeBranchConflict as exc:
133
- raise HandoffConflict(stage=exc.stage, branch=exc.branch,
134
- paths=exc.paths) from exc
135
- except IntegrateError as exc:
136
- raise HandoffError(str(exc)) from exc
137
-
138
-
139
- def _resolve_stage_base(
140
- *, stage: int, deps: List[int], selected: List[int], pr_covered: set,
141
- done: Dict[int, Dict[str, Any]], project_root, base_branch: str,
142
- work_category: str, task_id: str, project_id: str, task_group: str,
143
- anchor_base: str,
144
- ) -> Dict[str, Any]:
145
- """한 stage 의 PR base. 이미 릴리스 base 에 들어간 선행은 base 를 올리지
146
- 않으므로 제외하고, 남은 선행의 개수가 모양을 정한다."""
147
- live = [d for d in sorted(deps)
148
- if not is_ancestor(project_root, _done_head(done, d),
149
- f"origin/{base_branch}")]
150
- if not live:
151
- commit = _run_git(["rev-parse", f"origin/{base_branch}"],
152
- project_root).stdout.strip()
153
- return {"base_kind": "release-base", "base_branch": base_branch,
154
- "base_commit": commit, "base_stages": []}
155
-
156
- undelivered = [d for d in live if d not in selected and d not in pr_covered]
157
- if undelivered:
158
- raise HandoffError(
159
- f"stage {stage} depends on stage(s) {undelivered}, which are "
160
- f"neither selected in this run nor already PR'd nor merged into "
161
- f"origin/{base_branch} — a stacked PR needs its base branch to "
162
- "exist on origin, so include them in this handoff or merge them "
163
- "first")
164
-
165
- if len(live) == 1:
166
- dep = live[0]
167
- branch = _stage_branch(work_category, task_id, dep)
168
- if not _branch_exists(project_root, branch):
169
- raise HandoffError(
170
- f"stage {stage} needs branch {branch} (stage {dep}) as its PR "
171
- "base, but that branch no longer exists in this repository")
172
- return {"base_kind": "stage", "base_branch": branch,
173
- "base_commit": _done_head(done, dep), "base_stages": [dep]}
174
-
175
- merged = _merge_base_for(
176
- project_root=project_root, project_id=project_id,
177
- task_group=task_group, task_id=task_id, work_category=work_category,
178
- heads={n: _done_head(done, n) for n in live}, anchor_base=anchor_base)
179
- return {"base_kind": "merge-base", "base_branch": merged.branch,
180
- "base_commit": merged.commit, "base_stages": list(merged.stages),
181
- "base_merge_commits": list(merged.merge_commits),
182
- "base_branch_created": merged.created}
183
-
184
-
185
- def _local_tip(project_root, branch: str) -> str:
186
- """로컬 브랜치의 현재 커밋. 없으면 빈 문자열."""
187
- return _git(Path(project_root), "rev-parse", "--verify", "--quiet",
188
- f"refs/heads/{branch}").stdout.strip()
189
-
190
-
191
- def _remote_tips(project_root, branches: List[str]) -> Dict[str, str]:
192
- """origin 이 지금 가진 브랜치와 커밋. 없는 브랜치는 키가 없다.
193
-
194
- `ls-remote` 로 묻는다 — 비교하려고 ref 를 쓰지 않고, 없는 브랜치를 fetch
195
- 하려다 실패하지도 않는다.
196
- """
197
- if not branches:
198
- return {}
199
- res = _git(Path(project_root), "ls-remote", "--heads", "origin", *branches)
200
- if res.returncode != 0:
201
- raise HandoffError(f"cannot read origin branch state: {res.stderr.strip()}")
202
- tips: Dict[str, str] = {}
203
- for line in res.stdout.splitlines():
204
- parts = line.split()
205
- if len(parts) == 2 and parts[1].startswith("refs/heads/"):
206
- tips[parts[1][len("refs/heads/"):]] = parts[0]
207
- return tips
208
-
209
-
210
- def _branch_worktree(project_root, branch: str) -> Optional[Path]:
211
- """그 브랜치를 체크아웃해 둔 워크트리. 없으면 None.
212
-
213
- 체크아웃된 브랜치는 `git branch -f` 로 옮길 수 없으므로, fast-forward 는 그
214
- 워크트리 안에서 해야 한다.
215
- """
216
- res = _git(Path(project_root), "worktree", "list", "--porcelain")
217
- if res.returncode != 0:
218
- return None
219
- current: Optional[Path] = None
220
- for line in res.stdout.splitlines():
221
- if line.startswith("worktree "):
222
- current = Path(line[len("worktree "):].strip())
223
- elif line.strip() == f"branch refs/heads/{branch}":
224
- return current
225
- return None
226
-
227
-
228
- def _fast_forward_local(project_root, branch: str, remote_commit: str) -> None:
229
- """로컬 브랜치를 origin 의 커밋으로 fast-forward 한다.
230
-
231
- 호출자가 조상 관계를 이미 확인했으므로 내용이 사라질 수 없다. 체크아웃
232
- 상태면 그 워크트리에서 `merge --ff-only` 로, 아니면 ref 를 옮긴다.
233
- """
234
- _run_git(["fetch", "origin", branch], project_root)
235
- holder = _branch_worktree(project_root, branch)
236
- if holder is None:
237
- _run_git(["branch", "-f", branch, remote_commit], project_root)
238
- return
239
- if is_dirty_excluding_okstra(holder):
240
- raise HandoffError(
241
- f"branch {branch} is behind origin and is checked out in {holder}, "
242
- "which has uncommitted changes — commit or stash them so the "
243
- "branch can be fast-forwarded to origin")
244
- _run_git(["merge", "--ff-only", remote_commit], holder)
245
-
246
-
247
- # 계획 행이 부르는 브랜치를 실제로 푸시할 순서. merge-base 는 그것을 base 로
248
- # 삼는 PR 보다 먼저 origin 에 있어야 한다(프로필 §"branch push" 와 같은 순서).
249
- def _plan_branch_order(planned: List[Dict[str, Any]]) -> List[tuple[str, str]]:
250
- ordered: List[tuple[str, str]] = []
251
- seen: set[str] = set()
252
- for row in planned:
253
- if row["base_kind"] == "merge-base" and row["base_branch"] not in seen:
254
- seen.add(row["base_branch"])
255
- ordered.append((row["base_branch"], row["base_commit"]))
256
- for row in planned:
257
- if row["head_branch"] not in seen:
258
- seen.add(row["head_branch"])
259
- ordered.append((row["head_branch"], row["head_commit"]))
260
- return ordered
261
-
262
-
263
- def _reconcile_with_origin(
264
- project_root, planned: List[Dict[str, Any]],
265
- ) -> tuple[List[Dict[str, Any]], List[str]]:
266
- """PR 이 올라앉을 브랜치를 origin 기준으로 맞춘다.
267
-
268
- PR 의 head 와 base 는 GitHub 이 **이름으로 origin 에서** 찾는다. 계획이
269
- 로컬 ref 만 보고 짜이던 동안, origin 의 같은 이름이 다른 커밋에 있어도
270
- 계획은 그 사실을 몰랐다 — push 단계에서 거절되거나, PR 의 diff 가 계획서의
271
- 커밋과 달랐다.
272
-
273
- 네 가지 상태를 가른다. origin 에 없거나 뒤처져 있으면 push 가 해결하므로
274
- 무엇을 어떤 순서로 올려야 하는지만 계획에 싣는다. origin 이 앞서 있으면
275
- 로컬을 fast-forward 하고, 그 추가 커밋이 이 run 이 검증한 범위 밖이라는
276
- 사실을 경고로 남긴다. 갈라져 있으면 거절한다 — 그 상태를 push 로 넘기는
277
- 길은 force 뿐이고, 그건 열린 PR 의 리뷰 커밋을 지운다.
278
- """
279
- order = _plan_branch_order(planned)
280
- tips = _remote_tips(project_root, [branch for branch, _ in order])
281
- push_plan: List[Dict[str, Any]] = []
282
- warnings: List[str] = []
283
- for branch, recorded in order:
284
- local = _local_tip(project_root, branch)
285
- if not local:
286
- raise HandoffError(
287
- f"branch {branch} does not exist in this repository — there is "
288
- "nothing to open a PR from")
289
- # tip 이 기록된 커밋보다 앞선 것은 정상 상태다 — git-reconcile 이 done
290
- # 이후의 커밋을 그 브랜치에 남긴다. 담기지 않은 것만 거절한다: 검증한
291
- # 커밋이 브랜치에 없으면 그 PR 은 검증된 것을 나르지 않는다.
292
- if recorded and local != recorded:
293
- if not is_ancestor(project_root, recorded, local):
294
- raise HandoffError(
295
- f"branch {branch} is at {local}, which does not contain the "
296
- f"commit this task's ledger verified for it ({recorded}) — "
297
- "the branch was rewritten, so its PR would not carry the "
298
- "verified work")
299
- warnings.append(
300
- f"branch {branch} is ahead of the commit this run verified "
301
- f"({recorded} -> {local}); the commits it adds are outside the "
302
- "verified scope")
303
- remote = tips.get(branch)
304
- if remote is None:
305
- action = "create"
306
- elif remote == local:
307
- action = "up-to-date"
308
- elif is_ancestor(project_root, remote, local):
309
- action = "fast-forward"
310
- elif is_ancestor(project_root, local, remote):
311
- _fast_forward_local(project_root, branch, remote)
312
- action = "up-to-date"
313
- warnings.append(
314
- f"branch {branch} was behind origin ({local} -> {remote}) and "
315
- "was fast-forwarded to it; the commits origin adds are outside "
316
- f"what this run verified ({recorded or local})")
317
- else:
318
- raise HandoffError(
319
- f"branch {branch} and origin/{branch} have diverged (local "
320
- f"{local}, origin {remote}) — only a force push could deliver "
321
- "this branch, which would drop the commits origin holds")
322
- push_plan.append({"branch": branch, "commit": local, "action": action})
323
- return push_plan, warnings
324
-
325
-
326
- def pr_plan(*, project_root, plan_run_root, stage_map, stages, base_branch,
327
- work_category, project_id, task_group, task_id) -> Dict[str, Any]:
328
- """선택한 stage 각각의 PR head/base 를 정하고, 필요한 선행 머지 브랜치를
329
- 만든다. PR 은 stage 당 하나이고 head 는 그 stage 의 스택 브랜치다 — 커밋
330
- 히스토리를 접지 않으므로 이 경로는 stage 브랜치를 고쳐 쓰지 않는다."""
331
- stages = sorted(set(stages))
332
- rows = consumers.read_consumers(Path(plan_run_root))
333
- _require_eligible(stage_map, rows, stages)
334
- _run_git(["fetch", "origin", base_branch], project_root)
335
- done = consumers.latest_done_by_stage(rows)
336
- pr_covered = consumers.pr_covered_stages(rows)
337
- by_n = {s["stage_number"]: s for s in stage_map}
338
- anchor_base = worktree_registry.get_implementation_base(
339
- project_id, task_group, task_id) or ""
340
-
341
- planned: List[Dict[str, Any]] = []
342
- for n in stages:
343
- head_branch = _stage_branch(work_category, task_id, n)
344
- if not _branch_exists(project_root, head_branch):
345
- raise HandoffError(
346
- f"stage {n} branch {head_branch} does not exist in this "
347
- "repository — it was deleted, so there is nothing to open a "
348
- "PR from")
349
- row = {
350
- "stage": n,
351
- "head_branch": head_branch,
352
- "head_commit": _done_head(done, n),
353
- **_resolve_stage_base(
354
- stage=n, deps=list(by_n[n]["depends_on"]), selected=stages,
355
- pr_covered=pr_covered, done=done, project_root=project_root,
356
- base_branch=base_branch, work_category=work_category,
357
- task_id=task_id, project_id=project_id, task_group=task_group,
358
- anchor_base=anchor_base),
359
- }
360
- planned.append(row)
361
- push_plan, warnings = _reconcile_with_origin(project_root, planned)
362
- return {"ok": True, "release_base": base_branch, "stages": planned,
363
- "push_plan": push_plan, "warnings": warnings}
364
-
365
-
366
- def _remove_task_worktree(main_wt, target: str) -> Optional[str]:
367
- """Remove an okstra worktree — task-key or stage — already confirmed clean
368
- by the caller. No-op when it is already gone. Returns a human-readable
369
- detail string on failure, None on success or absence.
370
- Refuses while a nested worktree remains inside the target — a --force
371
- removal would drop only the physical directory and leave git's and the
372
- registry's stage-key admin entries orphaned."""
373
- if not Path(target).exists():
374
- return None # 이미 teardown 됨 — checkout 으로 진행
375
- nested = nested_worktree_excludes(target)
376
- if nested:
377
- return f"nested 워크트리가 남아있습니다: {', '.join(nested)}"
378
- rm = remove_worktree_force(main_wt, target)
379
- if rm.returncode != 0:
380
- return rm.stderr.strip()
381
- return None
382
-
383
-
384
- def _local_checkout_target(project_id, task_group, task_id,
385
- stage: int) -> tuple:
386
- """Resolve (branch, worktree_path) for a stage's local checkout. The
387
- returned path is "" only when the row carries no `worktree_path`; teardown
388
- does not clear it, so a torn-down stage yields a stale path to a directory
389
- that no longer exists. The caller must probe the path before acting on it.
390
-
391
- A stage row's `status == "active"` is a live reservation: done-time release
392
- flips it (`consumers._release_stage_occupancy_keeping_branch`,
393
- `stage_integrate._teardown_stage`), and `list_active_stage_numbers` feeds the
394
- stage resolver, so an active stage row means a run holds it."""
395
- row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage)
396
- if not row or not row.get("branch"):
397
- raise HandoffError(
398
- f"stage {stage} 의 worktree registry 엔트리가 없습니다 — "
399
- "local checkout 대상 없음")
400
- if row.get("status") == "active":
401
- raise HandoffError(
402
- f"stage {stage} 를 실행 중인 런이 점유하고 있습니다 (status=active) — "
403
- "그 런이 끝나 점유가 해제된 뒤 다시 시도하세요")
404
- return row["branch"], row.get("worktree_path") or ""
405
-
406
-
407
- def local_checkout(*, project_root, project_id, task_group, task_id,
408
- stage: int) -> Dict[str, Any]:
409
- """그 stage 의 okstra 워크트리를 제거하고 stage 브랜치를 메인 워크트리에
410
- checkout 한다(브랜치 보존). 메인·대상 워크트리가 dirty 면 거부. base 브랜치는
411
- 건드리지 않는다."""
412
- branch, wt_path = _local_checkout_target(
413
- project_id, task_group, task_id, stage)
414
- main_wt = main_worktree_path(Path(project_root))
415
- if is_dirty_excluding_okstra(main_wt):
416
- raise HandoffError(
417
- f"메인 워크트리에 미커밋 변경이 있습니다 ({main_wt}) — "
418
- "커밋/스태시 후 다시 시도하세요")
419
- # 브랜치 부재는 어떤 변경 앞에서 잡는다. 종전에는 워크트리 제거·registry
420
- # 해제 뒤에 `git checkout` 이 실패해(스택 브랜치를 사용자가 이미 지운 경우)
421
- # 절반만 바뀐 상태가 남았다(2026-09-07 dev-10626 stage-1 실측).
422
- probe = _git(Path(main_wt), "rev-parse", "--verify", "--quiet",
423
- f"refs/heads/{branch}")
424
- if probe.returncode != 0:
425
- raise HandoffError(
426
- f"브랜치 {branch} 가 저장소에 없습니다(삭제됨) — checkout 대상이 없어 "
427
- "워크트리와 registry 는 건드리지 않았습니다")
428
- removed = ""
429
- # The removal gate must inspect the okstra worktree being destroyed, not main.
430
- # A torn-down stage leaves a stale path in its row, so probe the directory.
431
- if wt_path and Path(wt_path).exists():
432
- if is_dirty_excluding_okstra(wt_path):
433
- raise HandoffError(
434
- f"okstra 워크트리에 미커밋 변경이 있습니다 ({wt_path}) — "
435
- "해당 워크트리에서 커밋/스태시 후 다시 시도하세요")
436
- detail = _remove_task_worktree(main_wt, wt_path)
437
- if detail is not None:
438
- raise HandoffError(
439
- f"워크트리 제거 실패 ({wt_path}): {detail} — "
440
- "위 사유를 해소한 뒤 다시 시도하세요")
441
- removed = wt_path
442
- worktree_registry.release_status(project_id, task_group, task_id,
443
- stage_number=stage)
444
- co = _git(Path(main_wt), "checkout", branch)
445
- if co.returncode != 0:
446
- removal = f"워크트리는 제거됨({removed})" if removed else "제거된 워크트리 없음"
447
- raise HandoffError(
448
- f"git checkout {branch} 실패: {co.stderr.strip()} — {removal}; "
449
- f"메인({main_wt})에서 수동 checkout 으로 복구하세요")
450
- return {"ok": True, "branch": branch, "mainWorktreePath": str(main_wt),
451
- "removedWorktree": removed, "status": "checked-out", "stage": stage}
452
-
453
-
454
- def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
455
- done = consumers.latest_done_by_stage(rows)
456
- row = done.get(stage)
457
- if not row:
458
- raise HandoffError(
459
- f"stage {stage} has no done row in consumers.jsonl — "
460
- "finish the implementation stage first")
461
- return row.get("impl_task_key", "")
462
24
 
463
25
 
464
26
  def record_verified(*, plan_run_root, stage: int, report_path: str,
@@ -497,7 +59,7 @@ def record_verified(*, plan_run_root, stage: int, report_path: str,
497
59
  "verdict must be `accepted`, or `conditional-accept` whose every "
498
60
  f"condition declares `blocksReleaseHandoff: false` — {detail}")
499
61
  rows = consumers.read_consumers(Path(plan_run_root))
500
- key = _impl_task_key_for(rows, stage)
62
+ key = consumers.require_done_task_key(rows, stage)
501
63
  _record_verified_target(Path(plan_run_root), stage, key, report_path,
502
64
  Path(data_json), data, rows)
503
65
  return {"ok": True, "stage": stage, "report_path": report_path}
@@ -575,27 +137,6 @@ def _whole_task_stage_head(rows: List[Dict[str, Any]], stage: int, captured: str
575
137
  return head
576
138
 
577
139
 
578
- def record_pr(*, plan_run_root, stage: int, branch: str, base: str,
579
- url: str) -> Dict[str, Any]:
580
- """한 stage 의 PR 기록. PR 은 stage 당 하나이므로 행도 stage 당 하나다."""
581
- rows = consumers.read_consumers(Path(plan_run_root))
582
- key = _impl_task_key_for(rows, stage)
583
- consumers.append_pr(Path(plan_run_root), impl_task_key=key,
584
- stages=[stage], branch=branch, base=base, url=url)
585
- return {"ok": True, "stage": stage, "branch": branch, "base": base,
586
- "url": url}
587
-
588
-
589
- def _parse_stages_csv(raw: str) -> List[int]:
590
- try:
591
- out = sorted({int(x) for x in raw.split(",") if x.strip()})
592
- except ValueError:
593
- raise HandoffError(f"--stages must be a comma-separated int list, got {raw!r}")
594
- if not out:
595
- raise HandoffError("--stages must select at least one stage")
596
- return out
597
-
598
-
599
140
  _CLI_EPILOG = r"""JSON 출력.
600
141
 
601
142
  Usage:
@@ -617,6 +158,10 @@ _CLI_DESCRIPTION = "Release-handoff stage 단위 보조 (자격/PR 계획/기록
617
158
 
618
159
  def main(argv: Optional[list] = None) -> int:
619
160
  import argparse
161
+ from .phases.release_handoff.operations import (
162
+ HandoffConflict, compute_eligibility, pr_plan, local_checkout,
163
+ record_pr, _parse_stages_csv,
164
+ )
620
165
 
621
166
  p = argparse.ArgumentParser(
622
167
  description=_CLI_DESCRIPTION,
@@ -0,0 +1,5 @@
1
+ """명령 조립부와 단계 정책이 공유하는 인계 오류."""
2
+
3
+
4
+ class HandoffError(Exception):
5
+ """자격 또는 전제 위반으로 인계를 진행할 수 없다."""