okstra 0.173.0 → 0.175.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (402) hide show
  1. package/README.md +18 -9
  2. package/bin/okstra +8 -4
  3. package/dist/cli-registry.d.mts +16 -0
  4. package/dist/cli-registry.mjs +560 -0
  5. package/dist/cli-registry.mjs.map +1 -0
  6. package/dist/commands/execute/agent-prompt.d.mts +1 -0
  7. package/{src → dist}/commands/execute/agent-prompt.mjs +10 -11
  8. package/dist/commands/execute/agent-prompt.mjs.map +1 -0
  9. package/dist/commands/execute/codex-dispatch.d.mts +3 -0
  10. package/dist/commands/execute/codex-dispatch.mjs +6 -0
  11. package/dist/commands/execute/codex-dispatch.mjs.map +1 -0
  12. package/dist/commands/execute/codex-run.d.mts +3 -0
  13. package/{src → dist}/commands/execute/codex-run.mjs +30 -37
  14. package/dist/commands/execute/codex-run.mjs.map +1 -0
  15. package/dist/commands/execute/convergence.d.mts +1 -0
  16. package/{src → dist}/commands/execute/convergence.mjs +15 -16
  17. package/dist/commands/execute/convergence.mjs.map +1 -0
  18. package/dist/commands/execute/error-log.d.mts +1 -0
  19. package/{src → dist}/commands/execute/error-log.mjs +2 -3
  20. package/dist/commands/execute/error-log.mjs.map +1 -0
  21. package/dist/commands/execute/git-reconcile.d.mts +1 -0
  22. package/{src → dist}/commands/execute/git-reconcile.mjs +10 -11
  23. package/dist/commands/execute/git-reconcile.mjs.map +1 -0
  24. package/dist/commands/execute/handoff.d.mts +1 -0
  25. package/{src → dist}/commands/execute/handoff.mjs +10 -11
  26. package/dist/commands/execute/handoff.mjs.map +1 -0
  27. package/dist/commands/execute/incremental-carry.d.mts +1 -0
  28. package/{src → dist}/commands/execute/incremental-carry.mjs +2 -3
  29. package/dist/commands/execute/incremental-carry.mjs.map +1 -0
  30. package/dist/commands/execute/incremental-scope.d.mts +1 -0
  31. package/{src → dist}/commands/execute/incremental-scope.mjs +2 -3
  32. package/dist/commands/execute/incremental-scope.mjs.map +1 -0
  33. package/dist/commands/execute/integrate-stages.d.mts +1 -0
  34. package/{src → dist}/commands/execute/integrate-stages.mjs +10 -11
  35. package/dist/commands/execute/integrate-stages.mjs.map +1 -0
  36. package/dist/commands/execute/pane-title.d.mts +1 -0
  37. package/dist/commands/execute/pane-title.mjs +20 -0
  38. package/dist/commands/execute/pane-title.mjs.map +1 -0
  39. package/dist/commands/execute/plan-items.d.mts +1 -0
  40. package/dist/commands/execute/plan-items.mjs +9 -0
  41. package/dist/commands/execute/plan-items.mjs.map +1 -0
  42. package/dist/commands/execute/plan-validate.d.mts +1 -0
  43. package/dist/commands/execute/plan-validate.mjs +68 -0
  44. package/dist/commands/execute/plan-validate.mjs.map +1 -0
  45. package/dist/commands/execute/plan-verify.d.mts +1 -0
  46. package/{src → dist}/commands/execute/plan-verify.mjs +13 -16
  47. package/dist/commands/execute/plan-verify.mjs.map +1 -0
  48. package/dist/commands/execute/render-bundle.d.mts +3 -0
  49. package/{src → dist}/commands/execute/render-bundle.mjs +65 -61
  50. package/dist/commands/execute/render-bundle.mjs.map +1 -0
  51. package/dist/commands/execute/run.d.mts +24 -0
  52. package/dist/commands/execute/run.mjs +196 -0
  53. package/dist/commands/execute/run.mjs.map +1 -0
  54. package/dist/commands/execute/spawn-followups.d.mts +1 -0
  55. package/{src → dist}/commands/execute/spawn-followups.mjs +6 -7
  56. package/dist/commands/execute/spawn-followups.mjs.map +1 -0
  57. package/dist/commands/execute/team.d.mts +3 -0
  58. package/dist/commands/execute/team.mjs +56 -0
  59. package/dist/commands/execute/team.mjs.map +1 -0
  60. package/dist/commands/execute/token-usage.d.mts +1 -0
  61. package/{src → dist}/commands/execute/token-usage.mjs +2 -3
  62. package/dist/commands/execute/token-usage.mjs.map +1 -0
  63. package/dist/commands/execute/wizard.d.mts +8 -0
  64. package/dist/commands/execute/wizard.mjs +191 -0
  65. package/dist/commands/execute/wizard.mjs.map +1 -0
  66. package/dist/commands/execute/worker-audit-check.d.mts +1 -0
  67. package/{src → dist}/commands/execute/worker-audit-check.mjs +11 -12
  68. package/dist/commands/execute/worker-audit-check.mjs.map +1 -0
  69. package/dist/commands/execute/worker-dispatch.d.mts +7 -0
  70. package/{src → dist}/commands/execute/worker-dispatch.mjs +34 -46
  71. package/dist/commands/execute/worker-dispatch.mjs.map +1 -0
  72. package/dist/commands/execute/worker-state.d.mts +1 -0
  73. package/{src → dist}/commands/execute/worker-state.mjs +15 -16
  74. package/dist/commands/execute/worker-state.mjs.map +1 -0
  75. package/dist/commands/execute/worktree-lookup.d.mts +1 -0
  76. package/{src → dist}/commands/execute/worktree-lookup.mjs +38 -37
  77. package/dist/commands/execute/worktree-lookup.mjs.map +1 -0
  78. package/dist/commands/execute/worktree-status.d.mts +1 -0
  79. package/dist/commands/execute/worktree-status.mjs +115 -0
  80. package/dist/commands/execute/worktree-status.mjs.map +1 -0
  81. package/dist/commands/inspect/code-review.d.mts +1 -0
  82. package/{src → dist}/commands/inspect/code-review.mjs +16 -19
  83. package/dist/commands/inspect/code-review.mjs.map +1 -0
  84. package/dist/commands/inspect/container.d.mts +1 -0
  85. package/{src → dist}/commands/inspect/container.mjs +11 -13
  86. package/dist/commands/inspect/container.mjs.map +1 -0
  87. package/dist/commands/inspect/context-cost.d.mts +1 -0
  88. package/{src → dist}/commands/inspect/context-cost.mjs +11 -13
  89. package/dist/commands/inspect/context-cost.mjs.map +1 -0
  90. package/dist/commands/inspect/design-prep.d.mts +1 -0
  91. package/{src → dist}/commands/inspect/design-prep.mjs +10 -11
  92. package/dist/commands/inspect/design-prep.mjs.map +1 -0
  93. package/dist/commands/inspect/error-report.d.mts +1 -0
  94. package/{src → dist}/commands/inspect/error-report.mjs +11 -13
  95. package/dist/commands/inspect/error-report.mjs.map +1 -0
  96. package/dist/commands/inspect/error-zip.d.mts +1 -0
  97. package/{src → dist}/commands/inspect/error-zip.mjs +11 -12
  98. package/dist/commands/inspect/error-zip.mjs.map +1 -0
  99. package/dist/commands/inspect/log-report.d.mts +1 -0
  100. package/{src → dist}/commands/inspect/log-report.mjs +11 -13
  101. package/dist/commands/inspect/log-report.mjs.map +1 -0
  102. package/dist/commands/inspect/profile-show.d.mts +1 -0
  103. package/{src → dist}/commands/inspect/profile-show.mjs +10 -11
  104. package/dist/commands/inspect/profile-show.mjs.map +1 -0
  105. package/dist/commands/inspect/recap.d.mts +1 -0
  106. package/{src → dist}/commands/inspect/recap.mjs +11 -13
  107. package/dist/commands/inspect/recap.mjs.map +1 -0
  108. package/dist/commands/inspect/resolve-task-key.d.mts +1 -0
  109. package/{src → dist}/commands/inspect/resolve-task-key.mjs +11 -13
  110. package/dist/commands/inspect/resolve-task-key.mjs.map +1 -0
  111. package/dist/commands/inspect/rollup.d.mts +1 -0
  112. package/{src → dist}/commands/inspect/rollup.mjs +11 -13
  113. package/dist/commands/inspect/rollup.mjs.map +1 -0
  114. package/dist/commands/inspect/run-audit.d.mts +1 -0
  115. package/{src → dist}/commands/inspect/run-audit.mjs +11 -12
  116. package/dist/commands/inspect/run-audit.mjs.map +1 -0
  117. package/dist/commands/inspect/set-work-status.d.mts +1 -0
  118. package/{src → dist}/commands/inspect/set-work-status.mjs +11 -13
  119. package/dist/commands/inspect/set-work-status.mjs.map +1 -0
  120. package/dist/commands/inspect/stage-map.d.mts +1 -0
  121. package/dist/commands/inspect/stage-map.mjs +110 -0
  122. package/dist/commands/inspect/stage-map.mjs.map +1 -0
  123. package/dist/commands/inspect/task-list.d.mts +1 -0
  124. package/dist/commands/inspect/task-list.mjs +103 -0
  125. package/dist/commands/inspect/task-list.mjs.map +1 -0
  126. package/dist/commands/inspect/task-show.d.mts +1 -0
  127. package/dist/commands/inspect/task-show.mjs +108 -0
  128. package/dist/commands/inspect/task-show.mjs.map +1 -0
  129. package/dist/commands/inspect/time-report.d.mts +1 -0
  130. package/{src → dist}/commands/inspect/time-report.mjs +11 -13
  131. package/dist/commands/inspect/time-report.mjs.map +1 -0
  132. package/dist/commands/inspect/usage-report.d.mts +1 -0
  133. package/{src → dist}/commands/inspect/usage-report.mjs +11 -13
  134. package/dist/commands/inspect/usage-report.mjs.map +1 -0
  135. package/dist/commands/inspect/user-response.d.mts +1 -0
  136. package/{src → dist}/commands/inspect/user-response.mjs +10 -11
  137. package/dist/commands/inspect/user-response.mjs.map +1 -0
  138. package/dist/commands/inspect/worker-liveness.d.mts +1 -0
  139. package/{src → dist}/commands/inspect/worker-liveness.mjs +11 -13
  140. package/dist/commands/inspect/worker-liveness.mjs.map +1 -0
  141. package/dist/commands/lifecycle/check-project.d.mts +21 -0
  142. package/dist/commands/lifecycle/check-project.mjs +175 -0
  143. package/dist/commands/lifecycle/check-project.mjs.map +1 -0
  144. package/dist/commands/lifecycle/config.d.mts +1 -0
  145. package/dist/commands/lifecycle/config.mjs +369 -0
  146. package/dist/commands/lifecycle/config.mjs.map +1 -0
  147. package/dist/commands/lifecycle/doctor.d.mts +10 -0
  148. package/dist/commands/lifecycle/doctor.mjs +317 -0
  149. package/dist/commands/lifecycle/doctor.mjs.map +1 -0
  150. package/dist/commands/lifecycle/install.d.mts +17 -0
  151. package/dist/commands/lifecycle/install.mjs +1077 -0
  152. package/dist/commands/lifecycle/install.mjs.map +1 -0
  153. package/dist/commands/lifecycle/migrate.d.mts +1 -0
  154. package/{src → dist}/commands/lifecycle/migrate.mjs +10 -11
  155. package/dist/commands/lifecycle/migrate.mjs.map +1 -0
  156. package/dist/commands/lifecycle/model.d.mts +1 -0
  157. package/dist/commands/lifecycle/model.mjs +22 -0
  158. package/dist/commands/lifecycle/model.mjs.map +1 -0
  159. package/dist/commands/lifecycle/paths.d.mts +1 -0
  160. package/dist/commands/lifecycle/paths.mjs +102 -0
  161. package/dist/commands/lifecycle/paths.mjs.map +1 -0
  162. package/dist/commands/lifecycle/preflight.d.mts +1 -0
  163. package/dist/commands/lifecycle/preflight.mjs +124 -0
  164. package/dist/commands/lifecycle/preflight.mjs.map +1 -0
  165. package/dist/commands/lifecycle/setup.d.mts +1 -0
  166. package/dist/commands/lifecycle/setup.mjs +297 -0
  167. package/dist/commands/lifecycle/setup.mjs.map +1 -0
  168. package/dist/commands/lifecycle/uninstall.d.mts +4 -0
  169. package/dist/commands/lifecycle/uninstall.mjs +262 -0
  170. package/dist/commands/lifecycle/uninstall.mjs.map +1 -0
  171. package/dist/commands/manager.d.mts +3 -0
  172. package/{src → dist}/commands/manager.mjs +25 -32
  173. package/dist/commands/manager.mjs.map +1 -0
  174. package/dist/commands/memory/memory.d.mts +1 -0
  175. package/dist/commands/memory/memory.mjs +569 -0
  176. package/dist/commands/memory/memory.mjs.map +1 -0
  177. package/dist/commands/pr/pr.d.mts +33 -0
  178. package/dist/commands/pr/pr.mjs +296 -0
  179. package/dist/commands/pr/pr.mjs.map +1 -0
  180. package/dist/commands/report/agent-activity.d.mts +1 -0
  181. package/{src → dist}/commands/report/agent-activity.mjs +11 -12
  182. package/dist/commands/report/agent-activity.mjs.map +1 -0
  183. package/dist/commands/report/finalize.d.mts +4 -0
  184. package/dist/commands/report/finalize.mjs +64 -0
  185. package/dist/commands/report/finalize.mjs.map +1 -0
  186. package/dist/commands/report/inject-report-index.d.mts +1 -0
  187. package/{src → dist}/commands/report/inject-report-index.mjs +6 -7
  188. package/dist/commands/report/inject-report-index.mjs.map +1 -0
  189. package/dist/commands/report/render-final-report.d.mts +1 -0
  190. package/{src → dist}/commands/report/render-final-report.mjs +6 -7
  191. package/dist/commands/report/render-final-report.mjs.map +1 -0
  192. package/dist/commands/report/render-views.d.mts +1 -0
  193. package/{src → dist}/commands/report/render-views.mjs +6 -7
  194. package/dist/commands/report/render-views.mjs.map +1 -0
  195. package/dist/commands/report/translate.d.mts +1 -0
  196. package/{src → dist}/commands/report/translate.mjs +6 -7
  197. package/dist/commands/report/translate.mjs.map +1 -0
  198. package/dist/lib/helper-scripts.d.mts +3 -0
  199. package/{src → dist}/lib/helper-scripts.mjs +11 -14
  200. package/dist/lib/helper-scripts.mjs.map +1 -0
  201. package/dist/lib/host-registry-client.d.mts +25 -0
  202. package/dist/lib/host-registry-client.mjs +152 -0
  203. package/dist/lib/host-registry-client.mjs.map +1 -0
  204. package/dist/lib/install-assets.d.mts +8 -0
  205. package/{src → dist}/lib/install-assets.mjs +17 -20
  206. package/dist/lib/install-assets.mjs.map +1 -0
  207. package/dist/lib/okstra-dirs.d.mts +4 -0
  208. package/{src → dist}/lib/okstra-dirs.mjs +3 -6
  209. package/dist/lib/okstra-dirs.mjs.map +1 -0
  210. package/dist/lib/paths.d.mts +4 -0
  211. package/dist/lib/paths.mjs +78 -0
  212. package/dist/lib/paths.mjs.map +1 -0
  213. package/dist/lib/proc.d.mts +3 -0
  214. package/dist/lib/proc.mjs +31 -0
  215. package/dist/lib/proc.mjs.map +1 -0
  216. package/dist/lib/python-helper.d.mts +8 -0
  217. package/dist/lib/python-helper.mjs +123 -0
  218. package/dist/lib/python-helper.mjs.map +1 -0
  219. package/dist/lib/runtime-manifest.d.mts +7 -0
  220. package/dist/lib/runtime-manifest.mjs +73 -0
  221. package/dist/lib/runtime-manifest.mjs.map +1 -0
  222. package/dist/lib/skill-catalog.d.mts +4 -0
  223. package/dist/lib/skill-catalog.mjs +50 -0
  224. package/dist/lib/skill-catalog.mjs.map +1 -0
  225. package/dist/lib/types.d.mts +129 -0
  226. package/dist/lib/types.mjs +2 -0
  227. package/dist/lib/types.mjs.map +1 -0
  228. package/dist/lib/version.d.mts +2 -0
  229. package/dist/lib/version.mjs +27 -0
  230. package/dist/lib/version.mjs.map +1 -0
  231. package/docs/architecture/storage-model.md +17 -3
  232. package/docs/architecture.md +12 -22
  233. package/docs/cli.md +29 -8
  234. package/docs/container.md +1 -1
  235. package/docs/contributor-change-matrix.md +3 -3
  236. package/docs/project-structure-overview.md +29 -20
  237. package/docs/task-process/README.md +3 -1
  238. package/docs/task-process/implementation-planning.md +2 -2
  239. package/docs/task-process/implementation.md +1 -1
  240. package/package.json +11 -5
  241. package/runtime/BUILD.json +2 -2
  242. package/runtime/agents/workers/claude-worker.md +3 -2
  243. package/runtime/agents/workers/report-writer-worker.md +3 -1
  244. package/runtime/agents/workers/translator-worker.md +3 -1
  245. package/runtime/bin/lib/okstra/cli.sh +12 -0
  246. package/runtime/bin/lib/okstra/globals.sh +4 -1
  247. package/runtime/bin/lib/okstra/usage.sh +4 -4
  248. package/runtime/bin/okstra-error-log.py +27 -0
  249. package/runtime/bin/okstra-provider-exec.py +245 -16
  250. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  251. package/runtime/bin/okstra.sh +10 -0
  252. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  253. package/runtime/prompts/lead/convergence.md +27 -18
  254. package/runtime/prompts/lead/okstra-lead-contract.md +4 -2
  255. package/runtime/prompts/lead/plan-body-verification.md +13 -6
  256. package/runtime/prompts/lead/report-writer.md +6 -6
  257. package/runtime/prompts/lead/team-contract.md +1 -1
  258. package/runtime/prompts/profiles/_common-contract.md +2 -2
  259. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  260. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  261. package/runtime/prompts/profiles/change-impact-analysis.md +16 -0
  262. package/runtime/prompts/profiles/error-analysis.md +21 -1
  263. package/runtime/prompts/profiles/feature-analysis.md +16 -0
  264. package/runtime/prompts/profiles/final-verification.md +16 -0
  265. package/runtime/prompts/profiles/implementation-option-selection.md +16 -0
  266. package/runtime/prompts/profiles/implementation-planning.md +32 -9
  267. package/runtime/prompts/profiles/implementation.md +17 -1
  268. package/runtime/prompts/profiles/improvement-discovery.md +16 -0
  269. package/runtime/prompts/profiles/project-analysis.md +16 -0
  270. package/runtime/prompts/profiles/release-handoff.md +5 -1
  271. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  272. package/runtime/prompts/wizard/prompts.ko.json +18 -0
  273. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  274. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +8 -0
  275. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +8 -0
  276. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  277. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +2 -2
  278. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +25 -5
  279. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  280. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +4 -1
  281. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  282. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  283. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +2 -2
  284. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +79 -11
  285. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +100 -25
  286. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +59 -14
  287. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +55 -9
  288. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +51 -12
  289. package/runtime/python/okstra_ctl/agent_activity.py +5 -1
  290. package/runtime/python/okstra_ctl/agent_invocation.py +608 -14
  291. package/runtime/python/okstra_ctl/agent_prompt_cli.py +249 -50
  292. package/runtime/python/okstra_ctl/application/resolve_assignment.py +53 -12
  293. package/runtime/python/okstra_ctl/assignment_environment.py +102 -0
  294. package/runtime/python/okstra_ctl/assignment_resolver.py +790 -0
  295. package/runtime/python/okstra_ctl/attempt_evidence.py +342 -0
  296. package/runtime/python/okstra_ctl/conformance.py +68 -0
  297. package/runtime/python/okstra_ctl/convergence.py +409 -3
  298. package/runtime/python/okstra_ctl/convergence_engine.py +248 -15
  299. package/runtime/python/okstra_ctl/convergence_store.py +170 -1
  300. package/runtime/python/okstra_ctl/dispatch_core.py +1181 -102
  301. package/runtime/python/okstra_ctl/dispatch_state.py +835 -22
  302. package/runtime/python/okstra_ctl/doctor.py +16 -2
  303. package/runtime/python/okstra_ctl/domain/host.py +32 -1
  304. package/runtime/python/okstra_ctl/domain/provider.py +212 -6
  305. package/runtime/python/okstra_ctl/domain/role.py +125 -0
  306. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +13 -0
  307. package/runtime/python/okstra_ctl/domain/worker_exec.py +39 -1
  308. package/runtime/python/okstra_ctl/domain/worker_role.py +13 -3
  309. package/runtime/python/okstra_ctl/domain/worker_stream.py +24 -0
  310. package/runtime/python/okstra_ctl/error_log_write.py +9 -0
  311. package/runtime/python/okstra_ctl/error_report.py +21 -1
  312. package/runtime/python/okstra_ctl/execution_identity.py +529 -0
  313. package/runtime/python/okstra_ctl/execution_manifest.py +1281 -0
  314. package/runtime/python/okstra_ctl/execution_mutation_audit.py +619 -0
  315. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  316. package/runtime/python/okstra_ctl/index.py +6 -2
  317. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +43 -3
  318. package/runtime/python/okstra_ctl/legacy_model_selection.py +490 -0
  319. package/runtime/python/okstra_ctl/model_cli.py +278 -0
  320. package/runtime/python/okstra_ctl/model_defaults.py +46 -0
  321. package/runtime/python/okstra_ctl/model_discovery.py +26 -7
  322. package/runtime/python/okstra_ctl/model_pool.py +501 -0
  323. package/runtime/python/okstra_ctl/models.py +47 -27
  324. package/runtime/python/okstra_ctl/mutation_recovery.py +293 -0
  325. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  326. package/runtime/python/okstra_ctl/pane_title.py +151 -0
  327. package/runtime/python/okstra_ctl/ports/host_model.py +48 -17
  328. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  329. package/runtime/python/okstra_ctl/registry/provider_registry.py +8 -4
  330. package/runtime/python/okstra_ctl/render.py +78 -10
  331. package/runtime/python/okstra_ctl/render_final_report.py +34 -19
  332. package/runtime/python/okstra_ctl/report_contract.py +39 -1
  333. package/runtime/python/okstra_ctl/report_finalize.py +84 -9
  334. package/runtime/python/okstra_ctl/report_html/render.py +1 -0
  335. package/runtime/python/okstra_ctl/role_requirements.py +217 -0
  336. package/runtime/python/okstra_ctl/run.py +1250 -260
  337. package/runtime/python/okstra_ctl/run_index_row.py +7 -1
  338. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  339. package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
  340. package/runtime/python/okstra_ctl/session.py +69 -12
  341. package/runtime/python/okstra_ctl/team.py +51 -25
  342. package/runtime/python/okstra_ctl/time_report.py +38 -4
  343. package/runtime/python/okstra_ctl/tmux.py +19 -149
  344. package/runtime/python/okstra_ctl/usage_identity.py +86 -0
  345. package/runtime/python/okstra_ctl/usage_report.py +15 -3
  346. package/runtime/python/okstra_ctl/wizard.py +1253 -62
  347. package/runtime/python/okstra_ctl/worker_audit_ledger.py +68 -65
  348. package/runtime/python/okstra_ctl/worker_request.py +9 -1
  349. package/runtime/python/okstra_ctl/worker_runner.py +130 -5
  350. package/runtime/python/okstra_ctl/workers.py +7 -5
  351. package/runtime/python/okstra_ctl/worktree.py +69 -3
  352. package/runtime/python/okstra_ctl/write_policy.py +571 -0
  353. package/runtime/python/okstra_token_usage/cli.py +1 -1
  354. package/runtime/python/okstra_token_usage/collect.py +66 -6
  355. package/runtime/schemas/convergence-groups-v2.0.schema.json +128 -0
  356. package/runtime/schemas/execution-manifest-v2.schema.json +340 -0
  357. package/runtime/schemas/final-report-v2.0.schema.json +25 -0
  358. package/runtime/skills/okstra-run/SKILL.md +9 -9
  359. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  360. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  361. package/runtime/templates/reports/final-report-v2.template.md +7 -1
  362. package/runtime/templates/reports/html/base.template.html +21 -0
  363. package/runtime/templates/reports/html/i18n/en.json +4 -0
  364. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  365. package/runtime/templates/reports/settings.template.json +0 -24
  366. package/runtime/templates/worker-prompt-preamble.md +6 -2
  367. package/runtime/validators/lib/fixtures.sh +98 -33
  368. package/runtime/validators/validate-implementation-plan-stages.py +63 -3
  369. package/runtime/validators/validate-run.py +443 -508
  370. package/runtime/validators/validate_session_conformance.py +26 -8
  371. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  372. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  373. package/runtime/templates/reports/final-report.template.md +0 -1258
  374. package/src/cli-registry.mjs +0 -532
  375. package/src/commands/execute/codex-dispatch.mjs +0 -10
  376. package/src/commands/execute/plan-items.mjs +0 -9
  377. package/src/commands/execute/plan-validate.mjs +0 -68
  378. package/src/commands/execute/run.mjs +0 -208
  379. package/src/commands/execute/team.mjs +0 -63
  380. package/src/commands/execute/wizard.mjs +0 -167
  381. package/src/commands/inspect/stage-map.mjs +0 -98
  382. package/src/commands/inspect/task-list.mjs +0 -97
  383. package/src/commands/inspect/task-show.mjs +0 -100
  384. package/src/commands/lifecycle/check-project.mjs +0 -187
  385. package/src/commands/lifecycle/config.mjs +0 -385
  386. package/src/commands/lifecycle/doctor.mjs +0 -308
  387. package/src/commands/lifecycle/install.mjs +0 -1172
  388. package/src/commands/lifecycle/paths.mjs +0 -104
  389. package/src/commands/lifecycle/preflight.mjs +0 -125
  390. package/src/commands/lifecycle/setup.mjs +0 -333
  391. package/src/commands/lifecycle/uninstall.mjs +0 -279
  392. package/src/commands/memory/memory.mjs +0 -524
  393. package/src/commands/pr/pr.mjs +0 -261
  394. package/src/commands/report/finalize.mjs +0 -64
  395. package/src/lib/host-registry-client.mjs +0 -176
  396. package/src/lib/paths.mjs +0 -86
  397. package/src/lib/proc.mjs +0 -31
  398. package/src/lib/python-helper.mjs +0 -142
  399. package/src/lib/runtime-manifest.mjs +0 -70
  400. package/src/lib/skill-catalog.mjs +0 -53
  401. package/src/lib/version.mjs +0 -20
  402. /package/{src → dist}/commands/pr/default.md +0 -0
@@ -26,7 +26,7 @@ import tempfile
26
26
  from dataclasses import dataclass, field
27
27
  from datetime import datetime, timezone
28
28
  from pathlib import Path
29
- from typing import Sequence
29
+ from typing import Mapping, Sequence
30
30
 
31
31
  from okstra_project import project_json_path, upsert_project_json
32
32
  from okstra_project.state import slugify
@@ -68,19 +68,44 @@ from .material import (
68
68
  from .final_report_schema import load_schema_version
69
69
  from .final_report_paths import final_report_data_path as _final_report_data_path
70
70
  from .lead_events import LeadEvent, append_lead_event
71
- from .application.resolve_assignment import (
72
- resolve_assignment_runner,
73
- resolve_lead_provider,
71
+ from .assignment_environment import load_assignment_context
72
+ from .assignment_resolver import (
73
+ AssignmentContext,
74
+ AssignmentPlan,
75
+ AssignmentResolutionError,
76
+ ResolvedAssignment,
77
+ TerminalBackend,
78
+ resolve_assignments,
79
+ resolve_context_lead_provider,
80
+ resolve_dispatch_assignment,
81
+ resolve_model_selection,
74
82
  )
75
83
  from .domain.host import (
84
+ CurrentSessionModelAttestation,
76
85
  HostCapabilityMismatch,
77
86
  HostNotRegistered,
87
+ HostSessionContext,
78
88
  ProviderUnavailable,
79
89
  )
80
- from .model_discovery import normalize_execution_for_dispatch
90
+ from .domain.provider import ServedModelAttestation
91
+ from .execution_identity import (
92
+ ExecutionManifest,
93
+ ParticipantAssignment,
94
+ RoleExecution,
95
+ execution_label,
96
+ model_spec_digest,
97
+ )
98
+ from .execution_manifest import persist_static_execution_identity
99
+ from .legacy_model_selection import (
100
+ CanonicalModelSelection,
101
+ ModelSelectionInputError,
102
+ deserialize_host_session_context,
103
+ normalize_model_selection_inputs,
104
+ serialize_host_session_context,
105
+ )
106
+ from .model_defaults import ModelDefaultScopes
81
107
  from .worker_prompt_policy import ANALYSIS_DUTY_BY_TASK_TYPE
82
108
  from .models import (
83
- ModelAssignment,
84
109
  UnknownProviderError,
85
110
  default_model,
86
111
  lead_launch_argv,
@@ -89,12 +114,11 @@ from .models import (
89
114
  provider_ids,
90
115
  provider_spec,
91
116
  provider_supports_role,
92
- resolve_model_metadata,
93
117
  )
94
118
  from .schema_excerpt import build_schema_excerpt
95
119
  from .registry.host_registry import default_host_registry
96
120
  from .registry.provider_registry import default_provider_registry
97
- from .ports.host_model import HostModelBindingError, HostModelBindingRequest
121
+ from .role_requirements import RoleProfile, RoleProfileError, load_role_profile
98
122
  from .agent_invocation import (
99
123
  AgentInstruction,
100
124
  AgentInstructionSource,
@@ -103,6 +127,7 @@ from .agent_invocation import (
103
127
  AgentInvocationError,
104
128
  agent_model_assignment_from_payload,
105
129
  digest_duty_catalog,
130
+ invocation_execution_identity_from_manifest,
106
131
  prepare_agent_invocation,
107
132
  verify_agent_invocation,
108
133
  )
@@ -125,7 +150,11 @@ from .render import (
125
150
  )
126
151
  from okstra_project.dirs import okstra_home
127
152
 
128
- from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
153
+ from .dispatch_state import (
154
+ BACKEND_CMUX_PANE,
155
+ detect_terminal_backend,
156
+ generate_claude_session_id,
157
+ )
129
158
  from .run_context import (
130
159
  compute_and_write_run_context,
131
160
  refresh_run_context_snapshot,
@@ -139,7 +168,6 @@ from .seeding import (
139
168
  verify_installation,
140
169
  )
141
170
  from .session import (
142
- generate_claude_session_id,
143
171
  resolve_inproc_lead_session_id,
144
172
  write_claude_resume_command_file,
145
173
  )
@@ -296,6 +324,43 @@ def _validate_data_json_approval_consistency(
296
324
  )
297
325
 
298
326
 
327
+ def _validate_approved_plan_conformance(path: Path) -> None:
328
+ """승인 계획의 stage conformance 선언 형식을 승인 경계에서 판정한다.
329
+
330
+ 같은 판정이 지금까지는 구현 런의 마지막 `validate-run` 에서만 나왔다. 그때는
331
+ 워커 배치와 수렴이 이미 끝난 뒤라 런 하나를 통째로 버려야 했다 — 실측된
332
+ 실패에서 승인 계획의 stage 2~7 이 기계 형식이 아니라 산문이었다.
333
+
334
+ 선언이 없는 stage 는 건드리지 않는다(`conformance.malformed_conformance_stages`
335
+ 참조): XOR 부재는 계획 단계의 check S11 이 이미 막고, 구현 진입 게이트도
336
+ validate-run 이 계속 요구한다. 여기서 다시 하면 같은 규칙이 두 곳이 된다.
337
+ """
338
+ from .conformance import malformed_conformance_stages
339
+
340
+ loaded = _load_final_report_data_if_present(path)
341
+ if loaded is None:
342
+ return
343
+ data_path, data = loaded
344
+ bad = malformed_conformance_stages(data)
345
+ if not bad:
346
+ return
347
+ # 한 stage 씩 고치고 다시 막히는 왕복을 피하려면 전부 나열해야 한다 —
348
+ # 실패한 런에서 검증기는 stage 2 만 지목했지만 실제로는 2~7 전부였다.
349
+ stages = ", ".join(str(number) for number in bad)
350
+ raise PrepareError(
351
+ f"approved plan data.json has malformed conformanceTests for stage(s) "
352
+ f"{stages}: {data_path}\n"
353
+ " each declaring stage must read "
354
+ "`<task_root>/qa/scripts/stage-<N>.<ext> "
355
+ "(requires=[db|io|http|external,...])` after the "
356
+ "`Conformance tests: stage-<N> — ` prefix "
357
+ "(prompts/profiles/implementation-planning.md), or carry "
358
+ "`Conformance exemption: <reason>` instead.\n"
359
+ " re-run implementation-planning so the declaration is regenerated in "
360
+ "that form, then approve it."
361
+ )
362
+
363
+
299
364
  def _set_data_json_approved_true_if_present(path: Path) -> bool:
300
365
  loaded = _load_final_report_data_if_present(path)
301
366
  if loaded is None:
@@ -382,6 +447,17 @@ class PrepareInputs:
382
447
  evidence_inputs_raw: str = ""
383
448
  directive: str = ""
384
449
  workers_override: str = ""
450
+ role_counts_raw: tuple[str, ...] = ()
451
+ role_models_raw: tuple[str, ...] = ()
452
+ host_session_context: HostSessionContext = field(
453
+ default_factory=lambda: HostSessionContext(
454
+ host_id="",
455
+ entry_mode="spawn-process",
456
+ available_functions=frozenset(),
457
+ interaction_surface="unavailable",
458
+ current_model=CurrentSessionModelAttestation.unknown(""),
459
+ )
460
+ )
385
461
  lead_provider: str = ""
386
462
  lead_model: str = ""
387
463
  claude_model: str = ""
@@ -474,6 +550,7 @@ def _validate_approved_plan(path: str) -> None:
474
550
  )
475
551
  _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
476
552
  _validate_data_json_approval_consistency(p, markdown_approved=True)
553
+ _validate_approved_plan_conformance(p)
477
554
  # frontmatter approved == true 상태. §1 Clarification Items 의
478
555
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
479
556
  scan = scan_approval_gate(p)
@@ -665,6 +742,18 @@ def _ensure_task_directories(ctx: dict) -> None:
665
742
  Path(ctx[key]).mkdir(parents=True, exist_ok=True)
666
743
 
667
744
 
745
+ def _role_execution_refs_from_ctx(ctx: dict) -> list[str]:
746
+ try:
747
+ payload = json.loads(ctx.get("EXECUTION_IDENTITY_JSON") or "{}")
748
+ except (TypeError, ValueError):
749
+ return []
750
+ return [
751
+ str(row.get("roleExecutionRef"))
752
+ for row in payload.get("roleExecutions") or []
753
+ if isinstance(row, dict) and row.get("roleExecutionRef")
754
+ ]
755
+
756
+
668
757
  def _record_start(
669
758
  *,
670
759
  workspace_root: Path,
@@ -721,6 +810,8 @@ def _record_start(
721
810
  okstra_version=ctx.get("OKSTRA_VERSION", ""),
722
811
  initial_status=initial_status,
723
812
  brief_sha256=brief_sha256,
813
+ execution_manifest_path=ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
814
+ role_execution_refs=_role_execution_refs_from_ctx(ctx),
724
815
  )
725
816
 
726
817
 
@@ -785,7 +876,15 @@ def _record_artifact_runtime_render_only_event(inp: PrepareInputs, ctx: dict) ->
785
876
 
786
877
  def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
787
878
  """rerun 충실 재현을 위한 canonical argv 재구성."""
788
- workers = inp.workers_override or ctx.get("RECOMMENDED_ANALYSERS", "")
879
+ has_role_selection = bool(inp.role_counts_raw or inp.role_models_raw)
880
+ legacy_fallbacks = {} if has_role_selection else ctx
881
+ lead_fallbacks = (
882
+ {} if inp.host_session_context.entry_mode == "current-session"
883
+ else legacy_fallbacks
884
+ )
885
+ workers = inp.workers_override or (
886
+ legacy_fallbacks.get("RECOMMENDED_ANALYSERS", "")
887
+ )
789
888
  pairs = [
790
889
  ("--task-type", inp.task_type),
791
890
  ("--project-id", inp.project_id),
@@ -800,20 +899,44 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
800
899
  ("--clarification-response", inp.clarification_response_path),
801
900
  ("--selected-direction", inp.selected_direction_path),
802
901
  ("--workers", workers),
803
- ("--lead-provider", inp.lead_provider or ctx.get("LEAD_PROVIDER", "")),
804
- ("--lead-model", inp.lead_model or ctx.get("LEAD_MODEL", "")),
805
- ("--claude-model", inp.claude_model or ctx.get("CLAUDE_WORKER_MODEL", "")),
806
- ("--codex-model", inp.codex_model or ctx.get("CODEX_WORKER_MODEL", "")),
807
- ("--antigravity-model", inp.antigravity_model or ctx.get("ANTIGRAVITY_WORKER_MODEL", "")),
902
+ (
903
+ "--lead-provider",
904
+ inp.lead_provider or lead_fallbacks.get("LEAD_PROVIDER", ""),
905
+ ),
906
+ (
907
+ "--lead-model",
908
+ inp.lead_model or lead_fallbacks.get("LEAD_MODEL", ""),
909
+ ),
910
+ (
911
+ "--claude-model",
912
+ inp.claude_model or legacy_fallbacks.get("CLAUDE_WORKER_MODEL", ""),
913
+ ),
914
+ (
915
+ "--codex-model",
916
+ inp.codex_model or legacy_fallbacks.get("CODEX_WORKER_MODEL", ""),
917
+ ),
918
+ (
919
+ "--antigravity-model",
920
+ inp.antigravity_model
921
+ or legacy_fallbacks.get("ANTIGRAVITY_WORKER_MODEL", ""),
922
+ ),
808
923
  ("--worker-model", inp.worker_models_raw),
809
924
  (
810
925
  "--report-writer-provider",
811
- inp.report_writer_provider or ctx.get("REPORT_WRITER_PROVIDER", ""),
926
+ inp.report_writer_provider
927
+ or legacy_fallbacks.get("REPORT_WRITER_PROVIDER", ""),
928
+ ),
929
+ (
930
+ "--report-writer-model",
931
+ inp.report_writer_model
932
+ or legacy_fallbacks.get("REPORT_WRITER_MODEL", ""),
812
933
  ),
813
- ("--report-writer-model", inp.report_writer_model or ctx.get("REPORT_WRITER_MODEL", "")),
814
934
  ("--lead-runtime", inp.lead_runtime if inp.lead_runtime != "claude-code" else ""),
815
- ("--executor", inp.executor or ctx.get("EXECUTOR_PROVIDER", "")),
816
- ("--critic", inp.critic or ctx.get("CRITIC_CHOICE", "")),
935
+ (
936
+ "--executor",
937
+ inp.executor or legacy_fallbacks.get("EXECUTOR_PROVIDER", ""),
938
+ ),
939
+ ("--critic", inp.critic or legacy_fallbacks.get("CRITIC_CHOICE", "")),
817
940
  ("--related-tasks", inp.related_tasks_raw),
818
941
  ("--work-category", inp.work_category),
819
942
  ]
@@ -821,6 +944,15 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
821
944
  for flag, val in pairs:
822
945
  if val:
823
946
  argv.extend([flag, val])
947
+ for role_count in inp.role_counts_raw:
948
+ argv.extend(["--role-count", role_count])
949
+ for role_model in inp.role_models_raw:
950
+ argv.extend(["--role-model", role_model])
951
+ if inp.host_session_context.host_id:
952
+ argv.extend([
953
+ "--host-session-context-json",
954
+ serialize_host_session_context(inp.host_session_context),
955
+ ])
824
956
  if inp.render_only:
825
957
  argv.append("--render-only")
826
958
  if not inp.plan_verification_enabled:
@@ -1645,12 +1777,484 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1645
1777
  workers = normalize_workers(inp.workers_override or profile_workers_csv)
1646
1778
  if inp.workers_override.strip():
1647
1779
  validate_workers_against_profile(workers, profile_workers, optional_workers)
1780
+ try:
1781
+ role_profile = load_role_profile(profile_file)
1782
+ except RoleProfileError as exc:
1783
+ raise PrepareError(str(exc)) from exc
1784
+ requires_report_writer = any(
1785
+ row.role == "report-writer" and row.count == 1
1786
+ for row in role_profile.roles
1787
+ )
1788
+ if requires_report_writer and "report-writer" not in workers:
1789
+ workers.append("report-writer")
1648
1790
  if not workers:
1649
1791
  raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
1650
1792
  _validate_option_selection_roster(inp.task_type, workers)
1651
1793
  return workers, ",".join(workers)
1652
1794
 
1653
1795
 
1796
+ def _profile_requires_executor(profile_file: Path) -> bool:
1797
+ try:
1798
+ profile = load_role_profile(profile_file)
1799
+ except RoleProfileError as exc:
1800
+ raise PrepareError(str(exc)) from exc
1801
+ return any(
1802
+ requirement.role == "implementer" and requirement.count > 0
1803
+ for requirement in profile.roles
1804
+ )
1805
+
1806
+
1807
+ def _normalize_prepare_model_selection(
1808
+ inp: PrepareInputs,
1809
+ profile_file: Path,
1810
+ ) -> tuple[CanonicalModelSelection, RoleProfile]:
1811
+ try:
1812
+ _validate_critic_choice(inp.critic)
1813
+ profile = load_role_profile(profile_file)
1814
+ profile_workers = resolve_profile_workers(profile_file)
1815
+ has_worker_models = bool(
1816
+ inp.worker_models_raw.strip()
1817
+ or inp.claude_model.strip()
1818
+ or inp.codex_model.strip()
1819
+ or inp.antigravity_model.strip()
1820
+ )
1821
+ workers = inp.workers_override
1822
+ if not workers and has_worker_models:
1823
+ workers = ",".join(profile_workers)
1824
+ selection = normalize_model_selection_inputs(
1825
+ profile=profile,
1826
+ role_counts_raw=inp.role_counts_raw,
1827
+ role_models_raw=inp.role_models_raw,
1828
+ workers=workers,
1829
+ worker_model=inp.worker_models_raw,
1830
+ critic=inp.critic,
1831
+ executor=inp.executor,
1832
+ lead_provider=inp.lead_provider,
1833
+ lead_model=inp.lead_model,
1834
+ report_writer_provider=inp.report_writer_provider,
1835
+ report_writer_model=inp.report_writer_model,
1836
+ provider_models={
1837
+ "claude": inp.claude_model,
1838
+ "codex": inp.codex_model,
1839
+ "antigravity": inp.antigravity_model,
1840
+ },
1841
+ )
1842
+ except (ModelSelectionInputError, RoleProfileError) as exc:
1843
+ raise PrepareError(str(exc)) from exc
1844
+ return selection, profile
1845
+
1846
+
1847
+ def _effective_host_session_context(
1848
+ inp: PrepareInputs,
1849
+ lead_runtime: str,
1850
+ ) -> HostSessionContext:
1851
+ context = inp.host_session_context
1852
+ if context.host_id:
1853
+ return context
1854
+ descriptor = default_host_registry().resolve(lead_runtime).descriptor
1855
+ current_model = context.current_model
1856
+ if not current_model.provider_id:
1857
+ current_model = CurrentSessionModelAttestation.unknown(
1858
+ descriptor.native_provider_id
1859
+ )
1860
+ return HostSessionContext(
1861
+ host_id=descriptor.id,
1862
+ entry_mode=context.entry_mode,
1863
+ available_functions=context.available_functions,
1864
+ interaction_surface=context.interaction_surface,
1865
+ current_model=current_model,
1866
+ )
1867
+
1868
+
1869
+ def _resolve_prepare_assignments(
1870
+ *,
1871
+ inp: PrepareInputs,
1872
+ profile: RoleProfile,
1873
+ selection: CanonicalModelSelection,
1874
+ workers: list[str],
1875
+ lead_runtime: str,
1876
+ terminal_backend: TerminalBackend,
1877
+ requires_executor: bool,
1878
+ ) -> tuple[AssignmentContext, AssignmentPlan]:
1879
+ scopes = _model_default_scopes(Path(inp.project_root))
1880
+ provider_ids_for_snapshot = _selection_provider_ids(
1881
+ inp,
1882
+ profile,
1883
+ selection,
1884
+ workers,
1885
+ scopes,
1886
+ requires_executor,
1887
+ )
1888
+ context = load_assignment_context(
1889
+ host_runtime=lead_runtime,
1890
+ terminal_backend=terminal_backend,
1891
+ execution_provider_ids=provider_ids_for_snapshot,
1892
+ )
1893
+ host = _effective_host_session_context(inp, lead_runtime)
1894
+ role_models = _materialize_selection_models(selection, context, host)
1895
+ role_counts = _resolver_role_counts(profile, selection.role_counts)
1896
+ try:
1897
+ plan = resolve_assignments(
1898
+ profile=profile,
1899
+ role_counts=role_counts,
1900
+ role_models=role_models,
1901
+ scopes=scopes,
1902
+ pool=context.pool,
1903
+ host=host,
1904
+ environment=context.environment,
1905
+ )
1906
+ except AssignmentResolutionError as exc:
1907
+ raise PrepareError(_assignment_resolution_message(exc)) from exc
1908
+ except (ValueError, RuntimeError) as exc:
1909
+ raise PrepareError(str(exc)) from exc
1910
+ return context, plan
1911
+
1912
+
1913
+ def _build_static_execution_manifest(
1914
+ plan: AssignmentPlan,
1915
+ context: AssignmentContext,
1916
+ lead: ResolvedAssignment,
1917
+ ) -> ExecutionManifest:
1918
+ participants: list[ParticipantAssignment] = []
1919
+ roles: list[RoleExecution] = []
1920
+ assignments = (
1921
+ lead,
1922
+ *(row for row in plan.assignments if row.role != "leader"),
1923
+ )
1924
+ for index, assignment in enumerate(assignments, start=1):
1925
+ participant_ref = f"participant-{index:03d}"
1926
+ participants.append(_participant_assignment(assignment, participant_ref))
1927
+ roles.append(
1928
+ _static_role_execution(
1929
+ assignment,
1930
+ participant_ref,
1931
+ context,
1932
+ )
1933
+ )
1934
+ return ExecutionManifest(
1935
+ entry_mode=lead.entry_mode,
1936
+ participant_assignments=tuple(participants),
1937
+ role_executions=tuple(roles),
1938
+ )
1939
+
1940
+
1941
+ def _participant_assignment(
1942
+ assignment: ResolvedAssignment,
1943
+ participant_ref: str,
1944
+ ) -> ParticipantAssignment:
1945
+ binding = assignment.binding
1946
+ return ParticipantAssignment(
1947
+ participant_ref=participant_ref,
1948
+ provider=assignment.provider_id,
1949
+ model_ref=assignment.model_ref,
1950
+ model_id=assignment.model_id,
1951
+ runner=binding.runner if binding is not None else "current-session",
1952
+ host_runtime=assignment.host_runtime,
1953
+ host_model_value=binding.host_model_value if binding is not None else None,
1954
+ session_ref=None,
1955
+ window_ref=None,
1956
+ worker_write_capability=(
1957
+ binding.worker_write_capability if binding is not None else None
1958
+ ),
1959
+ entry_mode=assignment.entry_mode,
1960
+ status="prepared",
1961
+ )
1962
+
1963
+
1964
+ def _static_role_execution(
1965
+ assignment: ResolvedAssignment,
1966
+ participant_ref: str,
1967
+ context: AssignmentContext,
1968
+ ) -> RoleExecution:
1969
+ binding = assignment.binding
1970
+ digest = None
1971
+ if assignment.model_ref is not None and binding is not None:
1972
+ digest = model_spec_digest(context.pool.resolve(assignment.model_ref), binding)
1973
+ return RoleExecution(
1974
+ role_execution_ref=(
1975
+ f"role-exec-{assignment.role}-{assignment.ordinal:03d}"
1976
+ ),
1977
+ participant_ref=participant_ref,
1978
+ source_role_execution_ref=None,
1979
+ role=assignment.role,
1980
+ provider=assignment.provider_id,
1981
+ model_ref=assignment.model_ref,
1982
+ model_id=assignment.model_id,
1983
+ ordinal=assignment.ordinal,
1984
+ execution_label=execution_label(
1985
+ assignment.role,
1986
+ assignment.provider_id,
1987
+ assignment.model_id,
1988
+ assignment.ordinal,
1989
+ ),
1990
+ model_spec_digest=digest,
1991
+ binding=binding,
1992
+ served_model_attestation=ServedModelAttestation.unknown(),
1993
+ status="prepared",
1994
+ )
1995
+
1996
+
1997
+ def _assignment_resolution_message(exc: AssignmentResolutionError) -> str:
1998
+ lines = [str(exc)]
1999
+ for candidate in exc.candidates:
2000
+ if candidate.available:
2001
+ continue
2002
+ lines.append(
2003
+ f"- {candidate.role}#{candidate.ordinal} {candidate.model_ref}: "
2004
+ f"{candidate.reason}"
2005
+ )
2006
+ for constraint in exc.unsatisfied_constraints:
2007
+ lines.append(
2008
+ f"- {constraint.role} {constraint.constraint}: "
2009
+ f"required={constraint.required}, available={constraint.available}"
2010
+ )
2011
+ return "\n".join(lines)
2012
+
2013
+
2014
+ def _selection_provider_ids(
2015
+ inp: PrepareInputs,
2016
+ profile: RoleProfile,
2017
+ selection: CanonicalModelSelection,
2018
+ workers: list[str],
2019
+ scopes: ModelDefaultScopes,
2020
+ requires_executor: bool,
2021
+ ) -> tuple[str, ...]:
2022
+ default_roles = _roles_requiring_defaults(
2023
+ inp,
2024
+ profile,
2025
+ selection,
2026
+ )
2027
+ has_canonical_role_selection = bool(
2028
+ inp.role_counts_raw or inp.role_models_raw
2029
+ )
2030
+ if has_canonical_role_selection:
2031
+ selected = _canonical_selection_provider_ids(
2032
+ inp,
2033
+ profile,
2034
+ selection,
2035
+ workers,
2036
+ scopes,
2037
+ default_roles,
2038
+ requires_executor,
2039
+ )
2040
+ else:
2041
+ selected = list(
2042
+ _selected_execution_provider_ids(inp, workers, requires_executor)
2043
+ )
2044
+ selected.extend(
2045
+ provider
2046
+ for providers in selection.provider_constraints.values()
2047
+ for provider in providers
2048
+ if provider
2049
+ )
2050
+ selected.extend(_providers_from_model_refs(selection.role_models.values()))
2051
+ selected.extend(
2052
+ _providers_from_model_refs(
2053
+ _selected_scoped_default_groups(default_roles, scopes)
2054
+ )
2055
+ )
2056
+ current_provider = inp.host_session_context.current_model.provider_id
2057
+ if current_provider:
2058
+ selected.append(current_provider)
2059
+ return tuple(dict.fromkeys(selected))
2060
+
2061
+
2062
+ def _selected_scoped_default_groups(
2063
+ roles: Sequence[str],
2064
+ scopes: ModelDefaultScopes,
2065
+ ) -> tuple[tuple[str, ...], ...]:
2066
+ groups: list[tuple[str, ...]] = []
2067
+ for role in roles:
2068
+ if role in scopes.project:
2069
+ groups.append(scopes.project[role])
2070
+ elif role in scopes.global_:
2071
+ groups.append(scopes.global_[role])
2072
+ return tuple(groups)
2073
+
2074
+
2075
+ def _roles_requiring_defaults(
2076
+ inp: PrepareInputs,
2077
+ profile: RoleProfile,
2078
+ selection: CanonicalModelSelection,
2079
+ ) -> tuple[str, ...]:
2080
+ roles: list[str] = []
2081
+ if (
2082
+ inp.host_session_context.entry_mode != "current-session"
2083
+ and not _explicit_selection_count(selection, "leader")
2084
+ ):
2085
+ roles.append("leader")
2086
+ for requirement in profile.roles:
2087
+ if requirement.dynamic:
2088
+ continue
2089
+ desired = selection.role_counts.get(
2090
+ requirement.role,
2091
+ requirement.count,
2092
+ )
2093
+ explicit = _explicit_selection_count(selection, requirement.role)
2094
+ if explicit < desired:
2095
+ roles.append(requirement.role)
2096
+ return tuple(roles)
2097
+
2098
+
2099
+ def _explicit_selection_count(
2100
+ selection: CanonicalModelSelection,
2101
+ role: str,
2102
+ ) -> int:
2103
+ return max(
2104
+ len(selection.role_models.get(role, ())),
2105
+ len(selection.provider_constraints.get(role, ())),
2106
+ )
2107
+
2108
+
2109
+ def _canonical_selection_provider_ids(
2110
+ inp: PrepareInputs,
2111
+ profile: RoleProfile,
2112
+ selection: CanonicalModelSelection,
2113
+ workers: list[str],
2114
+ scopes: ModelDefaultScopes,
2115
+ default_roles: Sequence[str],
2116
+ requires_executor: bool,
2117
+ ) -> list[str]:
2118
+ selected = [inp.lead_provider]
2119
+ report_writer_uses_bundled_default = (
2120
+ "report-writer" in default_roles
2121
+ and "report-writer" not in scopes.project
2122
+ and "report-writer" not in scopes.global_
2123
+ )
2124
+ if report_writer_uses_bundled_default:
2125
+ selected.append("claude")
2126
+ implementer_uses_bundled_default = (
2127
+ requires_executor
2128
+ and "implementer" in default_roles
2129
+ and "implementer" not in scopes.project
2130
+ and "implementer" not in scopes.global_
2131
+ )
2132
+ if implementer_uses_bundled_default:
2133
+ selected.append(
2134
+ _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2135
+ )
2136
+ if _needs_profile_worker_candidates(profile, selection, scopes):
2137
+ selected.extend(
2138
+ worker for worker in workers if worker != "report-writer"
2139
+ )
2140
+ return selected
2141
+
2142
+
2143
+ def _needs_profile_worker_candidates(
2144
+ profile: RoleProfile,
2145
+ selection: CanonicalModelSelection,
2146
+ scopes: ModelDefaultScopes,
2147
+ ) -> bool:
2148
+ for requirement in profile.roles:
2149
+ if requirement.dynamic or requirement.role == "report-writer":
2150
+ continue
2151
+ desired = selection.role_counts.get(
2152
+ requirement.role,
2153
+ requirement.count,
2154
+ )
2155
+ explicit = _explicit_selection_count(selection, requirement.role)
2156
+ has_scoped_default = (
2157
+ requirement.role in scopes.project
2158
+ or requirement.role in scopes.global_
2159
+ )
2160
+ if explicit < desired and not has_scoped_default:
2161
+ return True
2162
+ return False
2163
+
2164
+
2165
+ def _providers_from_model_refs(
2166
+ model_groups: Sequence[Sequence[str]],
2167
+ ) -> tuple[str, ...]:
2168
+ return tuple(
2169
+ model_ref.split("/", 1)[0]
2170
+ for models in model_groups
2171
+ for model_ref in models
2172
+ if "/" in model_ref
2173
+ )
2174
+
2175
+
2176
+ def _materialize_selection_models(
2177
+ selection: CanonicalModelSelection,
2178
+ context: AssignmentContext,
2179
+ host: HostSessionContext,
2180
+ ) -> dict[str, tuple[str, ...]]:
2181
+ role_models = dict(selection.role_models)
2182
+ for role, providers in selection.provider_constraints.items():
2183
+ model_values = selection.legacy_model_values.get(role, {})
2184
+ resolved: list[str] = []
2185
+ for raw_provider in providers:
2186
+ provider = raw_provider or _fallback_legacy_provider(role, context, host)
2187
+ raw_model = model_values.get(provider, model_values.get("", ""))
2188
+ try:
2189
+ model = resolve_model_selection(
2190
+ context.pool,
2191
+ provider,
2192
+ role,
2193
+ raw_model,
2194
+ )
2195
+ except (ValueError, RuntimeError) as exc:
2196
+ raise PrepareError(str(exc)) from exc
2197
+ resolved.append(str(model.model_ref))
2198
+ role_models[role] = tuple(resolved)
2199
+ return role_models
2200
+
2201
+
2202
+ def _fallback_legacy_provider(
2203
+ role: str,
2204
+ context: AssignmentContext,
2205
+ host: HostSessionContext,
2206
+ ) -> str:
2207
+ if role == "leader":
2208
+ return (
2209
+ host.current_model.provider_id
2210
+ or context.environment.host_descriptor.native_provider_id
2211
+ )
2212
+ return "claude"
2213
+
2214
+
2215
+ def _resolver_role_counts(
2216
+ profile: RoleProfile,
2217
+ counts: Mapping[str, int],
2218
+ ) -> dict[str, int]:
2219
+ adjustable = {
2220
+ requirement.role
2221
+ for requirement in profile.roles
2222
+ if not requirement.dynamic and requirement.optional_count > 0
2223
+ }
2224
+ return {role: count for role, count in counts.items() if role in adjustable}
2225
+
2226
+
2227
+ def _model_default_scopes(project_root: Path) -> ModelDefaultScopes:
2228
+ return ModelDefaultScopes(
2229
+ project=_read_model_defaults(project_json_path(project_root)),
2230
+ global_=_read_model_defaults(okstra_home() / "config.json"),
2231
+ bundled={},
2232
+ )
2233
+
2234
+
2235
+ def _read_model_defaults(path: Path) -> dict[str, tuple[str, ...]]:
2236
+ if not path.is_file():
2237
+ return {}
2238
+ try:
2239
+ payload = json.loads(path.read_text(encoding="utf-8"))
2240
+ except (OSError, json.JSONDecodeError) as exc:
2241
+ raise PrepareError(f"model defaults file is invalid: {path}: {exc}") from exc
2242
+ raw_defaults = payload.get("modelDefaults", {}) if isinstance(payload, dict) else {}
2243
+ if not isinstance(raw_defaults, dict):
2244
+ raise PrepareError(f"modelDefaults must be an object: {path}")
2245
+ defaults: dict[str, tuple[str, ...]] = {}
2246
+ for role, models in raw_defaults.items():
2247
+ if (
2248
+ not isinstance(role, str)
2249
+ or not isinstance(models, list)
2250
+ or not models
2251
+ or not all(isinstance(model, str) and model for model in models)
2252
+ ):
2253
+ raise PrepareError(f"modelDefaults entries must be non-empty string arrays: {path}")
2254
+ defaults[role] = tuple(models)
2255
+ return defaults
2256
+
2257
+
1654
2258
  def _validate_option_selection_roster(
1655
2259
  task_type: str, workers: Sequence[str]
1656
2260
  ) -> None:
@@ -1715,23 +2319,43 @@ class _ModelBindings:
1715
2319
  해소한다 — prepare 가 run seq 를 할당하거나 worktree 를 만들기 전에.
1716
2320
  """
1717
2321
 
1718
- lead: ModelAssignment
1719
- cw: ModelAssignment
1720
- co: ModelAssignment
1721
- ge: ModelAssignment
1722
- rw: ModelAssignment
2322
+ lead: ResolvedAssignment
2323
+ cw: "_LegacyModelProjection"
2324
+ co: "_LegacyModelProjection"
2325
+ ge: "_LegacyModelProjection"
2326
+ rw: ResolvedAssignment
1723
2327
  critic_choice: str
1724
2328
  critic_model_execution: str
1725
2329
  executor_display_name: str
1726
2330
  codex_worker_execution: str
1727
2331
  antigravity_worker_execution: str
1728
- executor_assignment: RoleAssignment
2332
+ executor_assignment: RoleAssignment | None
1729
2333
  lead_assignment: RoleAssignment
1730
2334
  worker_assignments: tuple[RoleAssignment, ...]
1731
2335
  invocation_assignments: dict[str, dict[str, object]]
1732
2336
 
1733
2337
 
1734
- def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
2338
+ @dataclass(frozen=True)
2339
+ class _LegacyModelProjection:
2340
+ """Display/catalog values retained for the fixed v1 manifest fields."""
2341
+
2342
+ model_id: str
2343
+ display: str
2344
+ execution: str
2345
+
2346
+
2347
+ def _legacy_claude_model_override(provider: str, env_name: str) -> str:
2348
+ """Read a legacy Claude model override only for a Claude assignment."""
2349
+ if provider.strip().lower() != "claude":
2350
+ return ""
2351
+ return _default(env_name, "")
2352
+
2353
+
2354
+ def recommended_role_models(
2355
+ *,
2356
+ lead_provider: str = "",
2357
+ report_writer_provider: str = "",
2358
+ ) -> dict[str, str]:
1735
2359
  """역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
1736
2360
  wizard 의 안내 표기가 공유하는 단일 기준점.
1737
2361
 
@@ -1741,18 +2365,28 @@ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1741
2365
  role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
1742
2366
  화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-5.6-sol` 을 배정한다.
1743
2367
  """
1744
- lead_default = _default(
1745
- "OKSTRA_DEFAULT_LEAD_MODEL",
1746
- provider_default_model(lead_provider, "lead")
1747
- if lead_provider
1748
- else default_model("lead"),
2368
+ lead_provider_id = lead_provider or "claude"
2369
+ report_writer_provider_id = report_writer_provider or "claude"
2370
+ lead_default = (
2371
+ _legacy_claude_model_override(
2372
+ lead_provider_id,
2373
+ "OKSTRA_DEFAULT_LEAD_MODEL",
2374
+ )
2375
+ or provider_default_model(lead_provider_id, "lead")
2376
+ )
2377
+ report_writer_default = (
2378
+ _legacy_claude_model_override(
2379
+ report_writer_provider_id,
2380
+ "OKSTRA_DEFAULT_REPORT_WRITER_MODEL",
2381
+ )
2382
+ or provider_default_model(report_writer_provider_id, "report-writer")
1749
2383
  )
1750
2384
  recommendations = {
1751
2385
  "lead": lead_default,
1752
2386
  "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", default_model("claude")),
1753
2387
  "codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", default_model("codex")),
1754
2388
  "antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", default_model("antigravity")),
1755
- "report-writer": _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", default_model("report-writer")),
2389
+ "report-writer": report_writer_default,
1756
2390
  }
1757
2391
  for provider in provider_ids("analyser"):
1758
2392
  recommendations.setdefault(
@@ -1761,7 +2395,10 @@ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1761
2395
  return recommendations
1762
2396
 
1763
2397
 
1764
- def _parse_worker_model_overrides(raw_value: str) -> dict[str, str]:
2398
+ def _parse_worker_model_overrides(
2399
+ raw_value: str,
2400
+ context: AssignmentContext,
2401
+ ) -> dict[str, str]:
1765
2402
  overrides: dict[str, str] = {}
1766
2403
  for item in (raw_value or "").split(","):
1767
2404
  item = item.strip()
@@ -1776,65 +2413,71 @@ def _parse_worker_model_overrides(raw_value: str) -> dict[str, str]:
1776
2413
  )
1777
2414
  if provider in overrides:
1778
2415
  raise PrepareError(f"duplicate --worker-model provider: {provider}")
1779
- provider_spec(provider)
2416
+ context.pool.resolve_provider(provider)
1780
2417
  overrides[provider] = model
1781
2418
  return overrides
1782
2419
 
1783
2420
 
1784
2421
  def _model_for_role(
1785
- provider: str, raw_value: str, role: str, default_override: str = "",
1786
- ) -> ModelAssignment:
1787
- default_value = default_override or provider_default_model(provider, role)
1788
- if not default_value:
1789
- raise PrepareError(f"provider {provider!r} does not define a default for role {role!r}")
1790
- return resolve_model_metadata(
1791
- provider=provider,
1792
- raw_value=raw_value,
1793
- default_display=default_value,
1794
- default_execution=default_value,
1795
- )
1796
-
1797
-
1798
- def _assignment_runner(lead_runtime: str, provider: str, role: str) -> str:
1799
- host_registry = default_host_registry()
1800
- provider_registry = default_provider_registry()
2422
+ *,
2423
+ context: AssignmentContext,
2424
+ provider: str,
2425
+ raw_value: str,
2426
+ role: str,
2427
+ default_override: str = "",
2428
+ duty_id: str = "",
2429
+ ) -> ResolvedAssignment:
1801
2430
  try:
1802
- return resolve_assignment_runner(
1803
- host=host_registry.resolve(lead_runtime).descriptor,
1804
- provider=provider_registry.resolve(provider),
2431
+ return resolve_dispatch_assignment(
2432
+ context=context,
2433
+ host_runtime=context.environment.host_descriptor.id,
1805
2434
  role=role,
1806
- ).runner
1807
- except (
1808
- HostCapabilityMismatch,
1809
- HostNotRegistered,
1810
- ProviderUnavailable,
1811
- UnknownProviderError,
1812
- ) as exc:
2435
+ duty_id=duty_id or role,
2436
+ provider=provider,
2437
+ model=raw_value or default_override,
2438
+ )
2439
+ except (ValueError, RuntimeError) as exc:
1813
2440
  raise PrepareError(str(exc)) from exc
1814
2441
 
1815
2442
 
1816
- def _resolve_worker_models(inp: PrepareInputs) -> dict:
2443
+ def _resolve_worker_models(
2444
+ inp: PrepareInputs,
2445
+ workers: list[str],
2446
+ context: AssignmentContext,
2447
+ ) -> dict:
1817
2448
  """Resolve provider models while preserving legacy fixed-provider projections."""
1818
- rec = recommended_role_models()
1819
- overrides = _parse_worker_model_overrides(inp.worker_models_raw)
2449
+ overrides = _parse_worker_model_overrides(inp.worker_models_raw, context)
1820
2450
  legacy = {
1821
2451
  "claude": inp.claude_model,
1822
2452
  "codex": inp.codex_model,
1823
2453
  "antigravity": inp.antigravity_model,
1824
2454
  }
2455
+ legacy_default_env = {
2456
+ "claude": "OKSTRA_DEFAULT_CLAUDE_MODEL",
2457
+ "codex": "OKSTRA_DEFAULT_CODEX_MODEL",
2458
+ "antigravity": "OKSTRA_DEFAULT_ANTIGRAVITY_MODEL",
2459
+ }
2460
+
2461
+ def worker_model_input(provider: str) -> str:
2462
+ selected = overrides.get(provider, legacy.get(provider, ""))
2463
+ env_name = legacy_default_env.get(provider)
2464
+ return selected or (_default(env_name, "") if env_name else "")
2465
+
2466
+ selected_worker_providers = tuple(dict.fromkeys(
2467
+ worker for worker in workers if worker != "report-writer"
2468
+ ))
1825
2469
  provider_models = {}
1826
- for provider in provider_ids("analyser"):
1827
- default_override = rec.get(provider, "")
2470
+ for provider in selected_worker_providers:
1828
2471
  provider_models[provider] = _model_for_role(
1829
- provider, overrides.get(provider, legacy.get(provider, "")),
1830
- "analyser", default_override,
2472
+ context=context,
2473
+ provider=provider,
2474
+ raw_value=worker_model_input(provider),
2475
+ role="analyser",
1831
2476
  )
1832
2477
  try:
1833
- lead_assignment = resolve_lead_provider(
1834
- host_id=inp.lead_runtime,
2478
+ lead_assignment = resolve_context_lead_provider(
2479
+ context=context,
1835
2480
  requested_provider=inp.lead_provider,
1836
- host_registry=default_host_registry(),
1837
- provider_registry=default_provider_registry(),
1838
2481
  )
1839
2482
  except (
1840
2483
  HostCapabilityMismatch,
@@ -1845,119 +2488,127 @@ def _resolve_worker_models(inp: PrepareInputs) -> dict:
1845
2488
  raise PrepareError(str(exc)) from exc
1846
2489
  lead_provider = lead_assignment.provider
1847
2490
  report_writer_provider = inp.report_writer_provider.strip().lower() or "claude"
1848
- if not provider_supports_role(report_writer_provider, "report-writer"):
2491
+ if not context.pool.resolve_provider(report_writer_provider).supports_role(
2492
+ "report-writer"
2493
+ ):
1849
2494
  raise PrepareError(
1850
2495
  f"provider {report_writer_provider!r} does not support the report-writer role"
1851
2496
  )
2497
+
2498
+ def legacy_projection(provider: str) -> _LegacyModelProjection:
2499
+ assigned = provider_models.get(provider)
2500
+ if assigned is not None:
2501
+ return _LegacyModelProjection(
2502
+ model_id=assigned.model_id,
2503
+ display=assigned.display,
2504
+ execution=assigned.execution,
2505
+ )
2506
+ try:
2507
+ selected = resolve_model_selection(
2508
+ context.pool,
2509
+ provider,
2510
+ "analyser",
2511
+ worker_model_input(provider),
2512
+ )
2513
+ except (ValueError, RuntimeError) as exc:
2514
+ raise PrepareError(str(exc)) from exc
2515
+ return _LegacyModelProjection(
2516
+ model_id=selected.model_ref.model_id,
2517
+ display=selected.display_name,
2518
+ execution=selected.execution_value,
2519
+ )
2520
+
1852
2521
  return {
1853
2522
  "lead_provider": lead_provider,
1854
2523
  "report_writer_provider": report_writer_provider,
1855
2524
  "lead": _model_for_role(
1856
- lead_provider, inp.lead_model, "lead",
1857
- rec["lead"] if lead_provider == "claude" else "",
2525
+ context=context,
2526
+ provider=lead_provider,
2527
+ raw_value=inp.lead_model,
2528
+ role="leader",
2529
+ default_override=_legacy_claude_model_override(
2530
+ lead_provider,
2531
+ "OKSTRA_DEFAULT_LEAD_MODEL",
2532
+ ),
2533
+ duty_id="lead",
1858
2534
  ),
1859
- "cw": provider_models["claude"],
1860
- "co": provider_models["codex"],
1861
- "ge": provider_models["antigravity"],
2535
+ "cw": legacy_projection("claude"),
2536
+ "co": legacy_projection("codex"),
2537
+ "ge": legacy_projection("antigravity"),
1862
2538
  "rw": _model_for_role(
1863
- report_writer_provider, inp.report_writer_model, "report-writer",
1864
- rec["report-writer"] if report_writer_provider == "claude" else "",
2539
+ context=context,
2540
+ provider=report_writer_provider,
2541
+ raw_value=inp.report_writer_model,
2542
+ role="report-writer",
2543
+ default_override=_legacy_claude_model_override(
2544
+ report_writer_provider,
2545
+ "OKSTRA_DEFAULT_REPORT_WRITER_MODEL",
2546
+ ),
1865
2547
  ),
1866
2548
  "providers": provider_models,
1867
2549
  "worker_model_inputs": {
1868
- provider: overrides.get(provider, legacy.get(provider, ""))
1869
- for provider in provider_ids("analyser")
2550
+ provider: worker_model_input(provider)
2551
+ for provider in dict.fromkeys((
2552
+ *selected_worker_providers,
2553
+ *overrides,
2554
+ *legacy,
2555
+ ))
1870
2556
  },
1871
2557
  }
1872
2558
 
1873
2559
 
1874
- def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBindings:
2560
+ def _resolve_model_bindings(
2561
+ inp: PrepareInputs,
2562
+ workers: list[str],
2563
+ context: AssignmentContext,
2564
+ requires_executor: bool,
2565
+ ) -> _ModelBindings:
1875
2566
  """worker 모델 + critic 선택 + executor 바인딩을 한 묶음으로 해소·검증한다."""
1876
- m = _resolve_worker_models(inp)
1877
- critic_choice = (inp.critic or "").strip().lower()
1878
- allowed_critics = ["", "off", *provider_ids("critic")]
1879
- if critic_choice not in allowed_critics:
1880
- raise PrepareError(
1881
- f"--critic must be one of: {', '.join(allowed_critics[1:])} "
1882
- f"(got: {critic_choice!r})"
1883
- )
2567
+ m = _resolve_worker_models(inp, workers, context)
2568
+ critic_choice = _validate_critic_choice(inp.critic)
1884
2569
  critic_model_execution = ""
1885
2570
  critic_meta = None
1886
2571
  if critic_choice in provider_ids("critic"):
1887
2572
  critic_meta = _model_for_role(
1888
- critic_choice,
1889
- m["worker_model_inputs"].get(critic_choice, ""),
1890
- "critic",
1891
- )
1892
- critic_model_execution = _normalized_execution(
1893
- critic_meta, provider=critic_choice, role="critic",
1894
- )
1895
- executor_default = _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
1896
- executor_provider = (inp.executor or executor_default).strip().lower()
1897
- allowed_executors = provider_ids("executor")
1898
- if executor_provider not in allowed_executors:
1899
- raise PrepareError(
1900
- f"--executor must be one of: {', '.join(allowed_executors)} "
1901
- f"(got: {executor_provider!r})"
1902
- )
1903
- if inp.task_type == "implementation" and executor_provider not in workers:
1904
- raise PrepareError(
1905
- f"--executor {executor_provider} requires {executor_provider!r} in "
1906
- f"--workers, but resolved roster is {workers!r}. "
1907
- f"Add it explicitly, e.g. --workers {','.join(sorted(set(workers + [executor_provider])))}."
2573
+ context=context,
2574
+ provider=critic_choice,
2575
+ raw_value=m["worker_model_inputs"].get(critic_choice, ""),
2576
+ role="critic",
2577
+ duty_id="scope-critic",
1908
2578
  )
1909
- model_meta = m["providers"][executor_provider]
1910
- display_name = f"{provider_spec(executor_provider).display_label} executor"
1911
- lead_provider = m["lead_provider"]
1912
- lead_execution = m["lead"].execution
1913
- lead_runner = _assignment_runner(inp.lead_runtime, lead_provider, "lead")
1914
- if lead_runner == "cli-wrapper":
1915
- lead_execution = _normalized_execution(
1916
- m["lead"], provider=lead_provider, role="lead",
2579
+ critic_model_execution = critic_meta.execution
2580
+ executor_assignment = None
2581
+ display_name = ""
2582
+ if requires_executor:
2583
+ executor_assignment, display_name = _resolve_executor_assignment(
2584
+ inp,
2585
+ workers,
2586
+ context,
2587
+ m["worker_model_inputs"],
1917
2588
  )
1918
2589
  lead_assignment = _role_assignment(
1919
- host_runtime=inp.lead_runtime,
2590
+ resolved=m["lead"],
1920
2591
  role="lead",
1921
- provider=lead_provider,
1922
- model=m["lead"],
1923
- execution=lead_execution,
1924
- runner_role="lead",
1925
2592
  )
1926
- worker_assignments = _build_worker_assignments(inp, workers, m)
2593
+ worker_assignments = _build_worker_assignments(workers, m)
1927
2594
  critic_assignment = (
1928
2595
  _role_assignment(
1929
- host_runtime=inp.lead_runtime,
2596
+ resolved=critic_meta,
1930
2597
  role="critic",
1931
- provider=critic_choice,
1932
- model=critic_meta,
1933
- execution=critic_model_execution,
1934
- runner_role="critic",
1935
2598
  )
1936
2599
  if critic_meta is not None
1937
2600
  else None
1938
2601
  )
1939
- translator_assignment = _role_assignment(
1940
- host_runtime=inp.lead_runtime,
1941
- role="translator",
2602
+ translator_meta = _model_for_role(
2603
+ context=context,
1942
2604
  provider=m["report_writer_provider"],
1943
- model=m["rw"],
1944
- execution=_normalized_execution(
1945
- m["rw"],
1946
- provider=m["report_writer_provider"],
1947
- role="report-writer",
1948
- ),
1949
- runner_role="report-writer",
2605
+ raw_value=m["rw"].model_id,
2606
+ role="translator",
2607
+ duty_id="translator",
1950
2608
  )
1951
- executor_assignment = _role_assignment(
1952
- host_runtime=inp.lead_runtime,
1953
- role="executor",
1954
- provider=executor_provider,
1955
- model=model_meta,
1956
- execution=_normalized_execution(
1957
- model_meta, provider=executor_provider, role="executor"
1958
- ),
1959
- runner_role="executor",
1960
- worker_id=executor_provider,
2609
+ translator_assignment = _role_assignment(
2610
+ resolved=translator_meta,
2611
+ role="translator",
1961
2612
  )
1962
2613
  invocation_assignments = _build_invocation_assignments(
1963
2614
  lead_assignment,
@@ -1965,15 +2616,24 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1965
2616
  critic_assignment,
1966
2617
  translator_assignment,
1967
2618
  )
2619
+ worker_by_provider = {
2620
+ row.provider: row for row in worker_assignments
2621
+ }
1968
2622
  return _ModelBindings(
1969
2623
  lead=m["lead"], cw=m["cw"], co=m["co"], ge=m["ge"], rw=m["rw"],
1970
2624
  critic_choice=critic_choice,
1971
2625
  critic_model_execution=critic_model_execution,
1972
2626
  executor_display_name=display_name,
1973
- codex_worker_execution=_worker_execution_value(
1974
- m["co"], "codex", "worker", workers),
1975
- antigravity_worker_execution=_worker_execution_value(
1976
- m["ge"], "antigravity", "worker", workers),
2627
+ codex_worker_execution=(
2628
+ worker_by_provider["codex"].model_execution_value
2629
+ if "codex" in worker_by_provider
2630
+ else m["co"].execution
2631
+ ),
2632
+ antigravity_worker_execution=(
2633
+ worker_by_provider["antigravity"].model_execution_value
2634
+ if "antigravity" in worker_by_provider
2635
+ else m["ge"].execution
2636
+ ),
1977
2637
  executor_assignment=executor_assignment,
1978
2638
  lead_assignment=lead_assignment,
1979
2639
  worker_assignments=worker_assignments,
@@ -1981,8 +2641,245 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1981
2641
  )
1982
2642
 
1983
2643
 
2644
+ def _validate_critic_choice(raw_value: str) -> str:
2645
+ critic_choice = (raw_value or "").strip().lower()
2646
+ allowed_critics = ["", "off", *provider_ids("critic")]
2647
+ if critic_choice not in allowed_critics:
2648
+ raise PrepareError(
2649
+ f"--critic must be one of: {', '.join(allowed_critics[1:])} "
2650
+ f"(got: {critic_choice!r})"
2651
+ )
2652
+ return critic_choice
2653
+
2654
+
2655
+ def _uses_canonical_assignment_projection(inp: PrepareInputs) -> bool:
2656
+ return bool(
2657
+ inp.role_counts_raw
2658
+ or inp.role_models_raw
2659
+ or inp.host_session_context.entry_mode == "current-session"
2660
+ )
2661
+
2662
+
2663
+ def _project_assignment_plan(
2664
+ inp: PrepareInputs,
2665
+ plan: AssignmentPlan,
2666
+ context: AssignmentContext,
2667
+ ) -> tuple[list[str], _ModelBindings]:
2668
+ lead = plan.by_role("leader")[0]
2669
+ report_rows = plan.by_role("report-writer")
2670
+ report_writer = (
2671
+ report_rows[0]
2672
+ if report_rows
2673
+ else _model_for_role(
2674
+ context=context,
2675
+ provider=inp.report_writer_provider.strip().lower() or "claude",
2676
+ raw_value=inp.report_writer_model,
2677
+ role="report-writer",
2678
+ )
2679
+ )
2680
+ worker_rows = tuple(
2681
+ row for row in plan.assignments
2682
+ if row.role not in {"leader", "critic", "implementer", "report-writer"}
2683
+ )
2684
+ worker_assignments = tuple(
2685
+ _role_assignment(
2686
+ resolved=row,
2687
+ role=row.role,
2688
+ worker_id=_canonical_worker_id(row, worker_rows),
2689
+ )
2690
+ for row in worker_rows
2691
+ )
2692
+ if report_rows:
2693
+ worker_assignments = (
2694
+ *worker_assignments,
2695
+ _role_assignment(
2696
+ resolved=report_writer,
2697
+ role="report-writer",
2698
+ worker_id="report-writer",
2699
+ ),
2700
+ )
2701
+ models = _project_model_bindings(
2702
+ inp,
2703
+ plan,
2704
+ context,
2705
+ lead,
2706
+ report_writer,
2707
+ worker_assignments,
2708
+ )
2709
+ return [row.worker_id for row in worker_assignments], models
2710
+
2711
+
2712
+ def _canonical_worker_id(
2713
+ assignment: ResolvedAssignment,
2714
+ rows: Sequence[ResolvedAssignment],
2715
+ ) -> str:
2716
+ prior = sum(
2717
+ row.provider_id == assignment.provider_id and row.ordinal < assignment.ordinal
2718
+ for row in rows
2719
+ )
2720
+ return assignment.provider_id if prior == 0 else f"{assignment.provider_id}-{prior + 1}"
2721
+
2722
+
2723
+ def _project_model_bindings(
2724
+ inp: PrepareInputs,
2725
+ plan: AssignmentPlan,
2726
+ context: AssignmentContext,
2727
+ lead: ResolvedAssignment,
2728
+ report_writer: ResolvedAssignment,
2729
+ workers: tuple[RoleAssignment, ...],
2730
+ ) -> _ModelBindings:
2731
+ lead_assignment = _role_assignment(resolved=lead, role="lead")
2732
+ critic_rows = plan.by_role("critic")
2733
+ critic_assignment = (
2734
+ _role_assignment(resolved=critic_rows[0], role="critic")
2735
+ if critic_rows
2736
+ else None
2737
+ )
2738
+ executor_rows = plan.by_role("implementer")
2739
+ executor_assignment = (
2740
+ _role_assignment(
2741
+ resolved=executor_rows[0],
2742
+ role="executor",
2743
+ worker_id=executor_rows[0].provider_id,
2744
+ )
2745
+ if executor_rows
2746
+ else None
2747
+ )
2748
+ translator = _model_for_role(
2749
+ context=context,
2750
+ provider=report_writer.provider_id,
2751
+ raw_value=report_writer.model_id,
2752
+ role="translator",
2753
+ duty_id="translator",
2754
+ )
2755
+ projections = {
2756
+ provider: _legacy_projection_from_plan(provider, plan, context)
2757
+ for provider in ("claude", "codex", "antigravity")
2758
+ }
2759
+ invocation_assignments = _build_invocation_assignments(
2760
+ lead_assignment,
2761
+ workers,
2762
+ critic_assignment,
2763
+ _role_assignment(resolved=translator, role="translator"),
2764
+ )
2765
+ worker_by_provider = {row.provider: row for row in workers}
2766
+ return _ModelBindings(
2767
+ lead=lead,
2768
+ cw=projections["claude"],
2769
+ co=projections["codex"],
2770
+ ge=projections["antigravity"],
2771
+ rw=report_writer,
2772
+ critic_choice=(critic_rows[0].provider_id if critic_rows else "off"),
2773
+ critic_model_execution=(
2774
+ critic_assignment.model_execution_value if critic_assignment else ""
2775
+ ),
2776
+ executor_display_name=(
2777
+ f"{executor_rows[0].display_name} executor" if executor_rows else ""
2778
+ ),
2779
+ codex_worker_execution=(
2780
+ worker_by_provider["codex"].model_execution_value
2781
+ if "codex" in worker_by_provider
2782
+ else projections["codex"].execution
2783
+ ),
2784
+ antigravity_worker_execution=(
2785
+ worker_by_provider["antigravity"].model_execution_value
2786
+ if "antigravity" in worker_by_provider
2787
+ else projections["antigravity"].execution
2788
+ ),
2789
+ executor_assignment=executor_assignment,
2790
+ lead_assignment=lead_assignment,
2791
+ worker_assignments=workers,
2792
+ invocation_assignments=invocation_assignments,
2793
+ )
2794
+
2795
+
2796
+ def _legacy_projection_from_plan(
2797
+ provider: str,
2798
+ plan: AssignmentPlan,
2799
+ context: AssignmentContext,
2800
+ ) -> _LegacyModelProjection:
2801
+ selected = next(
2802
+ (
2803
+ row for row in plan.assignments
2804
+ if row.provider_id == provider and row.role != "leader"
2805
+ ),
2806
+ None,
2807
+ )
2808
+ if selected is not None:
2809
+ return _LegacyModelProjection(
2810
+ selected.model_id,
2811
+ selected.display_name,
2812
+ selected.execution,
2813
+ )
2814
+ model = resolve_model_selection(context.pool, provider, "analyser", "")
2815
+ return _LegacyModelProjection(
2816
+ model.model_ref.model_id,
2817
+ model.display_name,
2818
+ model.execution_value,
2819
+ )
2820
+
2821
+
2822
+ def _selected_execution_provider_ids(
2823
+ inp: PrepareInputs,
2824
+ workers: list[str],
2825
+ requires_executor: bool,
2826
+ ) -> tuple[str, ...]:
2827
+ selected = [
2828
+ inp.lead_provider,
2829
+ *(worker for worker in workers if worker != "report-writer"),
2830
+ inp.report_writer_provider.strip().lower() or "claude",
2831
+ ]
2832
+ critic = inp.critic.strip().lower()
2833
+ if critic not in {"", "off"}:
2834
+ selected.append(critic)
2835
+ if requires_executor:
2836
+ selected.append(
2837
+ inp.executor.strip().lower()
2838
+ or _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2839
+ )
2840
+ return tuple(dict.fromkeys(selected))
2841
+
2842
+
2843
+ def _resolve_executor_assignment(
2844
+ inp: PrepareInputs,
2845
+ workers: list[str],
2846
+ context: AssignmentContext,
2847
+ worker_model_inputs: dict[str, str],
2848
+ ) -> tuple[RoleAssignment, str]:
2849
+ executor_default = _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2850
+ executor_provider = (inp.executor or executor_default).strip().lower()
2851
+ allowed_executors = provider_ids("executor")
2852
+ if executor_provider not in allowed_executors:
2853
+ raise PrepareError(
2854
+ f"--executor must be one of: {', '.join(allowed_executors)} "
2855
+ f"(got: {executor_provider!r})"
2856
+ )
2857
+ if executor_provider not in workers:
2858
+ raise PrepareError(
2859
+ f"--executor {executor_provider} requires {executor_provider!r} in "
2860
+ f"--workers, but resolved roster is {workers!r}. "
2861
+ f"Add it explicitly, e.g. --workers {','.join(sorted(set(workers + [executor_provider])))}."
2862
+ )
2863
+ model = _model_for_role(
2864
+ context=context,
2865
+ provider=executor_provider,
2866
+ raw_value=worker_model_inputs.get(executor_provider, ""),
2867
+ role="implementer",
2868
+ duty_id="implementation-executor",
2869
+ )
2870
+ assignment = _role_assignment(
2871
+ resolved=model,
2872
+ role="executor",
2873
+ worker_id=executor_provider,
2874
+ )
2875
+ display_name = (
2876
+ f"{context.pool.resolve_provider(executor_provider).display_label} executor"
2877
+ )
2878
+ return assignment, display_name
2879
+
2880
+
1984
2881
  def _build_worker_assignments(
1985
- inp: PrepareInputs, workers: list[str], models: dict,
2882
+ workers: list[str], models: dict,
1986
2883
  ) -> tuple[RoleAssignment, ...]:
1987
2884
  assignments: list[RoleAssignment] = []
1988
2885
  for worker_id in workers:
@@ -1990,18 +2887,12 @@ def _build_worker_assignments(
1990
2887
  provider = (
1991
2888
  models["report_writer_provider"] if is_report_writer else worker_id
1992
2889
  )
1993
- role = "report-writer" if is_report_writer else "analyser"
1994
- meta = models["rw"] if is_report_writer else models["providers"][provider]
1995
- execution = _worker_execution_value(
1996
- meta, provider, "worker", [provider],
2890
+ selected = (
2891
+ models["rw"] if is_report_writer else models["providers"][provider]
1997
2892
  )
1998
2893
  assignments.append(_role_assignment(
1999
- host_runtime=inp.lead_runtime,
2000
- role=role,
2001
- provider=provider,
2002
- model=meta,
2003
- execution=execution,
2004
- runner_role=role,
2894
+ resolved=selected,
2895
+ role="report-writer" if is_report_writer else "analyser",
2005
2896
  worker_id=worker_id,
2006
2897
  ))
2007
2898
  return tuple(assignments)
@@ -2009,55 +2900,40 @@ def _build_worker_assignments(
2009
2900
 
2010
2901
  def _role_assignment(
2011
2902
  *,
2012
- host_runtime: str,
2903
+ resolved: ResolvedAssignment,
2013
2904
  role: str,
2014
- provider: str,
2015
- model: ModelAssignment,
2016
- execution: str,
2017
- runner_role: str,
2018
2905
  worker_id: str = "",
2019
2906
  ) -> RoleAssignment:
2020
- runner = _assignment_runner(host_runtime, provider, runner_role)
2021
- host_model_value = _resolve_host_model_value(
2022
- host_runtime=host_runtime,
2023
- provider=provider,
2024
- model=model,
2025
- execution=execution,
2026
- runner=runner,
2027
- )
2907
+ binding = resolved.binding
2908
+ if binding is None:
2909
+ if resolved.role != "leader" or resolved.entry_mode != "current-session":
2910
+ raise PrepareError("legacy assignment projection requires a model binding")
2911
+ return RoleAssignment(
2912
+ role=role,
2913
+ provider=resolved.provider_id,
2914
+ model_display=resolved.display_name,
2915
+ model_execution_value="unknown",
2916
+ runner="cli-wrapper",
2917
+ host_runtime=resolved.host_runtime,
2918
+ host_model_value=None,
2919
+ worker_id=worker_id,
2920
+ )
2028
2921
  return RoleAssignment(
2029
2922
  role=role,
2030
- provider=provider,
2031
- model_display=model.display,
2032
- model_execution_value=execution,
2033
- runner=runner,
2034
- host_runtime=host_runtime,
2035
- host_model_value=host_model_value,
2923
+ provider=resolved.provider_id,
2924
+ model_display=resolved.display_name,
2925
+ model_execution_value=binding.resolved_execution_value,
2926
+ runner=(
2927
+ "native-session"
2928
+ if binding.runner == "current-session"
2929
+ else binding.runner
2930
+ ),
2931
+ host_runtime=resolved.host_runtime,
2932
+ host_model_value=binding.host_model_value,
2036
2933
  worker_id=worker_id,
2037
2934
  )
2038
2935
 
2039
2936
 
2040
- def _resolve_host_model_value(
2041
- *,
2042
- host_runtime: str,
2043
- provider: str,
2044
- model: ModelAssignment,
2045
- execution: str,
2046
- runner: str,
2047
- ) -> str | None:
2048
- try:
2049
- port = default_host_registry().resolve(host_runtime).host_model()
2050
- return port.resolve(HostModelBindingRequest(
2051
- host_runtime=host_runtime,
2052
- provider=provider,
2053
- model=model.display,
2054
- model_execution_value=execution,
2055
- runner=runner,
2056
- ))
2057
- except (HostModelBindingError, HostNotRegistered) as exc:
2058
- raise PrepareError(str(exc)) from exc
2059
-
2060
-
2061
2937
  def _build_invocation_assignments(
2062
2938
  lead: RoleAssignment,
2063
2939
  workers: tuple[RoleAssignment, ...],
@@ -2079,27 +2955,6 @@ def _build_invocation_assignments(
2079
2955
  return assignments
2080
2956
 
2081
2957
 
2082
- def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> str:
2083
- """Route a role's catalog execution value through the CLI-identity normalizer
2084
- so the manifest carries the dispatch-correct spelling. SSOT for the mapping
2085
- lives in model_discovery; this is only the run.py call seam."""
2086
- return normalize_execution_for_dispatch(
2087
- provider=provider, execution=meta.execution, role=role,
2088
- )
2089
-
2090
-
2091
- def _worker_execution_value(
2092
- meta: ModelAssignment, provider: str, role: str, workers: list[str],
2093
- ) -> str:
2094
- """Normalize a worker role's execution value only when that provider's
2095
- worker is in the resolved roster; otherwise return the raw catalog value.
2096
- A worker that will not be dispatched must not probe its CLI or hard-fail
2097
- the whole prep (the CLI-identity check belongs only to workers that run)."""
2098
- if provider in workers:
2099
- return _normalized_execution(meta, provider=provider, role=role)
2100
- return meta.execution
2101
-
2102
-
2103
2958
  StageRunClaim = _implementation_stage.StageRunClaim
2104
2959
 
2105
2960
 
@@ -2488,11 +3343,18 @@ def _render_lead_prompt_and_snapshot(
2488
3343
  assignment = _agent_model_assignment(
2489
3344
  json.loads(ctx["INVOCATION_ASSIGNMENTS_JSON"])["lead"]
2490
3345
  )
3346
+ identity = invocation_execution_identity_from_manifest(
3347
+ json.loads(Path(ctx["RUN_MANIFEST_PATH"]).read_text(encoding="utf-8")),
3348
+ assignment=assignment,
3349
+ assignment_ref="lead",
3350
+ duty_id="lead",
3351
+ )
3352
+ if identity is None:
3353
+ raise PrepareError("lead v2 execution identity is missing")
3354
+ invocation_id = f"{ctx['TASK_TYPE_SEGMENT']}-{ctx['RUN_PROMPTS_SEQ']}-lead"
2491
3355
  prepared = prepare_agent_invocation(AgentInvocationRequest(
2492
- invocation_id=(
2493
- f"{ctx['TASK_TYPE_SEGMENT']}-{ctx['RUN_PROMPTS_SEQ']}-lead"
2494
- ),
2495
- worker_id="lead",
3356
+ invocation_id=invocation_id,
3357
+ worker_id=None,
2496
3358
  audience="lead",
2497
3359
  assignment_ref="lead",
2498
3360
  purpose=None,
@@ -2516,6 +3378,11 @@ def _render_lead_prompt_and_snapshot(
2516
3378
  prompt_path=Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]),
2517
3379
  metadata_path=Path(ctx["LEAD_PROMPT_METADATA_PATH"]),
2518
3380
  dispatch_kind="lead",
3381
+ participant_ref=identity.participant_ref,
3382
+ role_execution_ref=identity.role_execution_ref,
3383
+ duty_id=identity.duty_id,
3384
+ invocation_ref=invocation_id,
3385
+ attempt=1,
2519
3386
  ))
2520
3387
  return prepared.prompt_path.read_text(encoding="utf-8")
2521
3388
 
@@ -2540,12 +3407,7 @@ def _persist_run_inputs(
2540
3407
  if inp.approved_plan_path
2541
3408
  else ""
2542
3409
  )
2543
- run_inputs_path = write_run_inputs(
2544
- project_root=Path(inp.project_root),
2545
- run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
2546
- task_type_segment=ctx["TASK_TYPE_SEGMENT"],
2547
- seq=ctx["RUN_MANIFESTS_SEQ"],
2548
- inputs={
3410
+ inputs = {
2549
3411
  "taskBriefPath": brief_relative,
2550
3412
  "taskBriefAbsolutePath": str(inp.brief_path),
2551
3413
  "directive": inp.directive,
@@ -2567,7 +3429,11 @@ def _persist_run_inputs(
2567
3429
  inp.report_writer_provider or "claude",
2568
3430
  ),
2569
3431
  "reportWriterModel": models.rw.display,
2570
- "executor": models.executor_assignment.provider,
3432
+ "executor": (
3433
+ models.executor_assignment.provider
3434
+ if models.executor_assignment is not None
3435
+ else ""
3436
+ ),
2571
3437
  "relatedTasks": inp.related_tasks_raw,
2572
3438
  "approvedPlanPath": approved_plan_path,
2573
3439
  "clarificationResponsePath": inp.clarification_response_path,
@@ -2577,7 +3443,13 @@ def _persist_run_inputs(
2577
3443
  ),
2578
3444
  "evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
2579
3445
  "renderOnly": inp.render_only,
2580
- },
3446
+ }
3447
+ run_inputs_path = _write_run_inputs_once(
3448
+ project_root=Path(inp.project_root),
3449
+ run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
3450
+ task_type_segment=ctx["TASK_TYPE_SEGMENT"],
3451
+ seq=ctx["RUN_MANIFESTS_SEQ"],
3452
+ inputs=inputs,
2581
3453
  )
2582
3454
  ctx["RUN_INPUTS_PATH"] = str(run_inputs_path)
2583
3455
  ctx["RUN_INPUTS_RELATIVE_PATH"] = relative_to_project_root(
@@ -2585,6 +3457,29 @@ def _persist_run_inputs(
2585
3457
  )
2586
3458
 
2587
3459
 
3460
+ def _write_run_inputs_once(
3461
+ *,
3462
+ project_root: Path,
3463
+ run_manifests_dir: Path,
3464
+ task_type_segment: str,
3465
+ seq: str,
3466
+ inputs: dict,
3467
+ ) -> Path:
3468
+ path = run_manifests_dir / f"run-inputs-{task_type_segment}-{seq}.json"
3469
+ existing = _read_existing_manifest(path)
3470
+ if existing.get("schemaVersion") == "2.0":
3471
+ if existing.get("inputs") != inputs:
3472
+ raise PrepareError("input snapshot is immutable after v2 creation")
3473
+ return path
3474
+ return write_run_inputs(
3475
+ project_root=project_root,
3476
+ run_manifests_dir=run_manifests_dir,
3477
+ task_type_segment=task_type_segment,
3478
+ seq=seq,
3479
+ inputs=inputs,
3480
+ )
3481
+
3482
+
2588
3483
  def _persist_pre_dispatch_run_manifest(ctx: dict) -> None:
2589
3484
  """Publish and re-read the complete invocation authority before prompts."""
2590
3485
  render_run_manifest(ctx["RUN_MANIFEST_PATH"], ctx)
@@ -2982,24 +3877,38 @@ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
2982
3877
  ),
2983
3878
  "",
2984
3879
  ),
2985
- "EXECUTOR_WORKER_ID": models.executor_assignment.worker_id,
2986
- "EXECUTOR_PROVIDER": models.executor_assignment.provider,
3880
+ **_executor_model_ctx(models),
3881
+ "CRITIC_CHOICE": models.critic_choice,
3882
+ "CRITIC_MODEL_EXECUTION_VALUE": models.critic_model_execution,
3883
+ }
3884
+
3885
+
3886
+ def _executor_model_ctx(models: "_ModelBindings") -> dict[str, str]:
3887
+ assignment = models.executor_assignment
3888
+ if assignment is None:
3889
+ return {
3890
+ "EXECUTOR_WORKER_ID": "",
3891
+ "EXECUTOR_PROVIDER": "",
3892
+ "EXECUTOR_DISPLAY_NAME": "",
3893
+ "EXECUTOR_MODEL_DISPLAY": "",
3894
+ "EXECUTOR_MODEL_EXECUTION_VALUE": "",
3895
+ "EXECUTOR_HOST_MODEL_VALUE": "",
3896
+ "EXECUTOR_RUNNER": "",
3897
+ "EXECUTOR_DISPATCH_MODE": "",
3898
+ }
3899
+ return {
3900
+ "EXECUTOR_WORKER_ID": assignment.worker_id,
3901
+ "EXECUTOR_PROVIDER": assignment.provider,
2987
3902
  "EXECUTOR_DISPLAY_NAME": models.executor_display_name,
2988
- "EXECUTOR_MODEL_DISPLAY": models.executor_assignment.model_display,
2989
- "EXECUTOR_MODEL_EXECUTION_VALUE": (
2990
- models.executor_assignment.model_execution_value
2991
- ),
2992
- "EXECUTOR_HOST_MODEL_VALUE": (
2993
- models.executor_assignment.host_model_value or ""
2994
- ),
2995
- "EXECUTOR_RUNNER": models.executor_assignment.runner,
3903
+ "EXECUTOR_MODEL_DISPLAY": assignment.model_display,
3904
+ "EXECUTOR_MODEL_EXECUTION_VALUE": assignment.model_execution_value,
3905
+ "EXECUTOR_HOST_MODEL_VALUE": assignment.host_model_value or "",
3906
+ "EXECUTOR_RUNNER": assignment.runner,
2996
3907
  "EXECUTOR_DISPATCH_MODE": (
2997
3908
  "host-native"
2998
- if models.executor_assignment.runner == "native-session"
3909
+ if assignment.runner == "native-session"
2999
3910
  else "worker-dispatch"
3000
3911
  ),
3001
- "CRITIC_CHOICE": models.critic_choice,
3002
- "CRITIC_MODEL_EXECUTION_VALUE": models.critic_model_execution,
3003
3912
  }
3004
3913
 
3005
3914
 
@@ -3106,6 +4015,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3106
4015
  # 조립 코드가 단일 흐름으로 읽히도록 유지한다.
3107
4016
  assets = _resolve_runtime_assets(workspace_root, inp)
3108
4017
  profile_file = assets.profile_file
4018
+ requires_executor = _profile_requires_executor(profile_file)
4019
+ selection, role_profile = _normalize_prepare_model_selection(inp, profile_file)
3109
4020
  prompt_template = assets.prompt_template
3110
4021
  task_index_template = assets.task_index_template
3111
4022
  final_report_template = assets.final_report_template
@@ -3127,13 +4038,45 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3127
4038
  handoff_tokens = _materialize_release_handoff_input(
3128
4039
  workspace_root, project_root, inp)
3129
4040
 
3130
- verify_installation(workspace_root)
3131
- _register_and_check_project(project_root, inp)
3132
-
3133
4041
  workers, selected_reviewers = _resolve_roster(inp, profile_file)
3134
4042
  pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
3135
4043
 
3136
- models = _resolve_model_bindings(inp, workers)
4044
+ assignment_context, assignment_plan = _resolve_prepare_assignments(
4045
+ inp=inp,
4046
+ profile=role_profile,
4047
+ selection=selection,
4048
+ workers=workers,
4049
+ lead_runtime=lead_runtime,
4050
+ terminal_backend=terminal_backend,
4051
+ requires_executor=requires_executor,
4052
+ )
4053
+ if _uses_canonical_assignment_projection(inp):
4054
+ workers, models = _project_assignment_plan(
4055
+ inp,
4056
+ assignment_plan,
4057
+ assignment_context,
4058
+ )
4059
+ selected_reviewers = ",".join(workers)
4060
+ else:
4061
+ models = _resolve_model_bindings(
4062
+ inp,
4063
+ workers,
4064
+ assignment_context,
4065
+ requires_executor,
4066
+ )
4067
+ execution_manifest = _build_static_execution_manifest(
4068
+ assignment_plan,
4069
+ assignment_context,
4070
+ models.lead,
4071
+ )
4072
+ dynamic_roles = tuple(
4073
+ requirement.role
4074
+ for requirement in role_profile.roles
4075
+ if requirement.dynamic
4076
+ )
4077
+
4078
+ verify_installation(workspace_root)
4079
+ _register_and_check_project(project_root, inp)
3137
4080
 
3138
4081
  # ---- paths under per-task mutex (writes run-context-*.json) ----
3139
4082
  # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
@@ -3286,6 +4229,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3286
4229
  "LATEST_REPORT_RELATIVE_PATH": "",
3287
4230
  "RENDER_ONLY": "true" if inp.render_only else "false",
3288
4231
  "OKSTRA_VERSION": installed_version(),
4232
+ "EXECUTION_IDENTITY_JSON": json.dumps(
4233
+ execution_manifest.to_payload(), ensure_ascii=False,
4234
+ ),
4235
+ "DYNAMIC_EXECUTION_ROLES_JSON": json.dumps(
4236
+ dynamic_roles, ensure_ascii=False,
4237
+ ),
3289
4238
  })
3290
4239
  refresh_run_context_snapshot(ctx)
3291
4240
  if lead_runtime == "codex":
@@ -3325,6 +4274,15 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3325
4274
  _finalize_status_and_render_manifests(
3326
4275
  inp, ctx, task_index_template, forbidden_by_phase
3327
4276
  )
4277
+ persist_static_execution_identity(
4278
+ run_manifest_path=Path(ctx["RUN_MANIFEST_PATH"]),
4279
+ task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
4280
+ input_snapshot_path=Path(ctx["RUN_INPUTS_PATH"]),
4281
+ manifest=execution_manifest,
4282
+ task_key=ctx["TASK_KEY"],
4283
+ run_ref=ctx["RUN_MANIFEST_RELATIVE_PATH"],
4284
+ dynamic_roles=dynamic_roles,
4285
+ )
3328
4286
  _record_artifact_runtime_render_only_event(inp, ctx)
3329
4287
 
3330
4288
  _record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
@@ -3348,10 +4306,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3348
4306
  )
3349
4307
 
3350
4308
 
3351
- def main(argv: list[str]) -> int:
3352
- """CLI dispatcher for bash thin-wrapper use. Parses a flat list of argv
3353
- (the same flags `okstra.sh` accepts), runs prepare_task_bundle, prints
3354
- summary lines + prompt to stdout."""
4309
+ def build_prepare_argument_parser():
4310
+ """Build the shared flat-argument parser used by public prepare shims."""
3355
4311
  import argparse
3356
4312
 
3357
4313
  p = argparse.ArgumentParser()
@@ -3381,6 +4337,13 @@ def main(argv: list[str]) -> int:
3381
4337
  ),
3382
4338
  )
3383
4339
  p.add_argument("--workers", default="", dest="workers_override")
4340
+ p.add_argument("--role-count", action="append", default=[], dest="role_counts_raw")
4341
+ p.add_argument("--role-model", action="append", default=[], dest="role_models_raw")
4342
+ p.add_argument(
4343
+ "--host-session-context-json",
4344
+ default="",
4345
+ dest="host_session_context_json",
4346
+ )
3384
4347
  p.add_argument("--lead-provider", default="")
3385
4348
  p.add_argument("--lead-model", default="")
3386
4349
  p.add_argument("--claude-model", default="")
@@ -3524,7 +4487,12 @@ def main(argv: list[str]) -> int:
3524
4487
  "`main` / `dev` / `staging` / `preprod` / `prod` or any local ref."
3525
4488
  ),
3526
4489
  )
3527
- args = p.parse_args(argv)
4490
+ return p
4491
+
4492
+
4493
+ def main(argv: list[str]) -> int:
4494
+ """Parse one public prepare invocation and emit its rendered bundle."""
4495
+ args = build_prepare_argument_parser().parse_args(argv)
3528
4496
 
3529
4497
  project_root = Path(args.project_root).expanduser().resolve()
3530
4498
  if args.task_type == "release-handoff":
@@ -3568,6 +4536,25 @@ def main(argv: list[str]) -> int:
3568
4536
  return 1
3569
4537
  selected_direction_abs = str(selected)
3570
4538
 
4539
+ try:
4540
+ host_session_context = deserialize_host_session_context(
4541
+ args.host_session_context_json
4542
+ )
4543
+ except ModelSelectionInputError as exc:
4544
+ print(str(exc), file=__import__("sys").stderr)
4545
+ return 1
4546
+ if not args.host_session_context_json:
4547
+ adapter = default_host_registry().resolve(args.lead_runtime)
4548
+ host_session_context = HostSessionContext(
4549
+ host_id=adapter.descriptor.id,
4550
+ entry_mode="spawn-process",
4551
+ available_functions=frozenset(),
4552
+ interaction_surface="unavailable",
4553
+ current_model=CurrentSessionModelAttestation.unknown(
4554
+ adapter.descriptor.native_provider_id
4555
+ ),
4556
+ )
4557
+
3571
4558
  inputs = PrepareInputs(
3572
4559
  workspace_root=Path(args.workspace_root).resolve(),
3573
4560
  project_root=project_root,
@@ -3580,6 +4567,9 @@ def main(argv: list[str]) -> int:
3580
4567
  evidence_inputs_raw=args.evidence_inputs_raw,
3581
4568
  directive=args.directive,
3582
4569
  workers_override=args.workers_override,
4570
+ role_counts_raw=tuple(args.role_counts_raw),
4571
+ role_models_raw=tuple(args.role_models_raw),
4572
+ host_session_context=host_session_context,
3583
4573
  lead_provider=args.lead_provider,
3584
4574
  lead_model=args.lead_model,
3585
4575
  claude_model=args.claude_model,