okstra 0.174.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 (374) 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/{src → dist}/commands/execute/team.mjs +29 -36
  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/{src → dist}/commands/execute/worktree-status.mjs +52 -46
  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/{src → dist}/commands/report/finalize.mjs +25 -32
  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 +4 -0
  232. package/docs/architecture.md +7 -1
  233. package/docs/cli.md +26 -6
  234. package/docs/contributor-change-matrix.md +2 -2
  235. package/docs/project-structure-overview.md +16 -7
  236. package/docs/task-process/README.md +2 -0
  237. package/docs/task-process/implementation-planning.md +1 -1
  238. package/docs/task-process/implementation.md +1 -1
  239. package/package.json +11 -5
  240. package/runtime/BUILD.json +2 -2
  241. package/runtime/agents/workers/claude-worker.md +2 -1
  242. package/runtime/agents/workers/report-writer-worker.md +3 -1
  243. package/runtime/agents/workers/translator-worker.md +3 -1
  244. package/runtime/bin/lib/okstra/cli.sh +12 -0
  245. package/runtime/bin/lib/okstra/globals.sh +3 -0
  246. package/runtime/bin/lib/okstra/usage.sh +4 -4
  247. package/runtime/bin/okstra-error-log.py +27 -0
  248. package/runtime/bin/okstra-provider-exec.py +217 -5
  249. package/runtime/bin/okstra.sh +10 -0
  250. package/runtime/prompts/lead/convergence.md +27 -18
  251. package/runtime/prompts/lead/okstra-lead-contract.md +3 -1
  252. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  253. package/runtime/prompts/lead/team-contract.md +1 -1
  254. package/runtime/prompts/profiles/change-impact-analysis.md +16 -0
  255. package/runtime/prompts/profiles/error-analysis.md +20 -0
  256. package/runtime/prompts/profiles/feature-analysis.md +16 -0
  257. package/runtime/prompts/profiles/final-verification.md +16 -0
  258. package/runtime/prompts/profiles/implementation-option-selection.md +16 -0
  259. package/runtime/prompts/profiles/implementation-planning.md +20 -0
  260. package/runtime/prompts/profiles/implementation.md +15 -0
  261. package/runtime/prompts/profiles/improvement-discovery.md +16 -0
  262. package/runtime/prompts/profiles/project-analysis.md +16 -0
  263. package/runtime/prompts/profiles/release-handoff.md +4 -0
  264. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  265. package/runtime/prompts/wizard/prompts.ko.json +18 -0
  266. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +8 -0
  267. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +8 -0
  268. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  269. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +2 -2
  270. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +25 -5
  271. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  272. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +4 -1
  273. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  274. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +2 -2
  275. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +79 -11
  276. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +95 -25
  277. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +59 -14
  278. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +55 -9
  279. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +51 -12
  280. package/runtime/python/okstra_ctl/agent_activity.py +5 -1
  281. package/runtime/python/okstra_ctl/agent_invocation.py +608 -14
  282. package/runtime/python/okstra_ctl/agent_prompt_cli.py +249 -50
  283. package/runtime/python/okstra_ctl/application/resolve_assignment.py +53 -12
  284. package/runtime/python/okstra_ctl/assignment_environment.py +102 -0
  285. package/runtime/python/okstra_ctl/assignment_resolver.py +790 -0
  286. package/runtime/python/okstra_ctl/attempt_evidence.py +342 -0
  287. package/runtime/python/okstra_ctl/convergence.py +409 -3
  288. package/runtime/python/okstra_ctl/convergence_engine.py +248 -15
  289. package/runtime/python/okstra_ctl/convergence_store.py +170 -1
  290. package/runtime/python/okstra_ctl/dispatch_core.py +1092 -63
  291. package/runtime/python/okstra_ctl/dispatch_state.py +696 -11
  292. package/runtime/python/okstra_ctl/doctor.py +14 -0
  293. package/runtime/python/okstra_ctl/domain/host.py +32 -1
  294. package/runtime/python/okstra_ctl/domain/provider.py +212 -6
  295. package/runtime/python/okstra_ctl/domain/role.py +125 -0
  296. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +13 -0
  297. package/runtime/python/okstra_ctl/domain/worker_exec.py +34 -1
  298. package/runtime/python/okstra_ctl/domain/worker_role.py +13 -3
  299. package/runtime/python/okstra_ctl/domain/worker_stream.py +24 -0
  300. package/runtime/python/okstra_ctl/error_log_write.py +9 -0
  301. package/runtime/python/okstra_ctl/error_report.py +21 -1
  302. package/runtime/python/okstra_ctl/execution_identity.py +529 -0
  303. package/runtime/python/okstra_ctl/execution_manifest.py +1281 -0
  304. package/runtime/python/okstra_ctl/execution_mutation_audit.py +619 -0
  305. package/runtime/python/okstra_ctl/index.py +6 -2
  306. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +43 -3
  307. package/runtime/python/okstra_ctl/legacy_model_selection.py +490 -0
  308. package/runtime/python/okstra_ctl/model_cli.py +278 -0
  309. package/runtime/python/okstra_ctl/model_defaults.py +46 -0
  310. package/runtime/python/okstra_ctl/model_discovery.py +26 -7
  311. package/runtime/python/okstra_ctl/model_pool.py +501 -0
  312. package/runtime/python/okstra_ctl/models.py +47 -27
  313. package/runtime/python/okstra_ctl/mutation_recovery.py +293 -0
  314. package/runtime/python/okstra_ctl/pane_title.py +151 -0
  315. package/runtime/python/okstra_ctl/ports/host_model.py +48 -17
  316. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  317. package/runtime/python/okstra_ctl/registry/provider_registry.py +8 -4
  318. package/runtime/python/okstra_ctl/render.py +78 -10
  319. package/runtime/python/okstra_ctl/render_final_report.py +19 -0
  320. package/runtime/python/okstra_ctl/report_contract.py +39 -0
  321. package/runtime/python/okstra_ctl/report_finalize.py +16 -0
  322. package/runtime/python/okstra_ctl/report_html/render.py +1 -0
  323. package/runtime/python/okstra_ctl/role_requirements.py +217 -0
  324. package/runtime/python/okstra_ctl/run.py +1207 -258
  325. package/runtime/python/okstra_ctl/run_index_row.py +7 -1
  326. package/runtime/python/okstra_ctl/time_report.py +38 -4
  327. package/runtime/python/okstra_ctl/usage_identity.py +86 -0
  328. package/runtime/python/okstra_ctl/usage_report.py +15 -3
  329. package/runtime/python/okstra_ctl/wizard.py +1253 -62
  330. package/runtime/python/okstra_ctl/worker_audit_ledger.py +68 -65
  331. package/runtime/python/okstra_ctl/worker_request.py +7 -1
  332. package/runtime/python/okstra_ctl/worker_runner.py +130 -5
  333. package/runtime/python/okstra_ctl/workers.py +7 -5
  334. package/runtime/python/okstra_ctl/write_policy.py +571 -0
  335. package/runtime/schemas/convergence-groups-v2.0.schema.json +128 -0
  336. package/runtime/schemas/execution-manifest-v2.schema.json +340 -0
  337. package/runtime/schemas/final-report-v2.0.schema.json +25 -0
  338. package/runtime/skills/okstra-run/SKILL.md +9 -9
  339. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  340. package/runtime/templates/reports/final-report-v2.template.md +7 -1
  341. package/runtime/templates/reports/html/base.template.html +21 -0
  342. package/runtime/templates/reports/html/i18n/en.json +4 -0
  343. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  344. package/runtime/templates/worker-prompt-preamble.md +6 -2
  345. package/runtime/validators/lib/fixtures.sh +49 -16
  346. package/runtime/validators/validate-run.py +429 -35
  347. package/runtime/validators/validate_session_conformance.py +25 -7
  348. package/src/cli-registry.mjs +0 -539
  349. package/src/commands/execute/codex-dispatch.mjs +0 -10
  350. package/src/commands/execute/plan-items.mjs +0 -9
  351. package/src/commands/execute/plan-validate.mjs +0 -68
  352. package/src/commands/execute/run.mjs +0 -208
  353. package/src/commands/execute/wizard.mjs +0 -167
  354. package/src/commands/inspect/stage-map.mjs +0 -98
  355. package/src/commands/inspect/task-list.mjs +0 -97
  356. package/src/commands/inspect/task-show.mjs +0 -100
  357. package/src/commands/lifecycle/check-project.mjs +0 -187
  358. package/src/commands/lifecycle/config.mjs +0 -385
  359. package/src/commands/lifecycle/doctor.mjs +0 -308
  360. package/src/commands/lifecycle/install.mjs +0 -1170
  361. package/src/commands/lifecycle/paths.mjs +0 -104
  362. package/src/commands/lifecycle/preflight.mjs +0 -125
  363. package/src/commands/lifecycle/setup.mjs +0 -333
  364. package/src/commands/lifecycle/uninstall.mjs +0 -279
  365. package/src/commands/memory/memory.mjs +0 -524
  366. package/src/commands/pr/pr.mjs +0 -261
  367. package/src/lib/host-registry-client.mjs +0 -176
  368. package/src/lib/paths.mjs +0 -86
  369. package/src/lib/proc.mjs +0 -31
  370. package/src/lib/python-helper.mjs +0 -142
  371. package/src/lib/runtime-manifest.mjs +0 -70
  372. package/src/lib/skill-catalog.mjs +0 -53
  373. package/src/lib/version.mjs +0 -20
  374. /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
  )
@@ -422,6 +447,17 @@ class PrepareInputs:
422
447
  evidence_inputs_raw: str = ""
423
448
  directive: str = ""
424
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
+ )
425
461
  lead_provider: str = ""
426
462
  lead_model: str = ""
427
463
  claude_model: str = ""
@@ -706,6 +742,18 @@ def _ensure_task_directories(ctx: dict) -> None:
706
742
  Path(ctx[key]).mkdir(parents=True, exist_ok=True)
707
743
 
708
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
+
709
757
  def _record_start(
710
758
  *,
711
759
  workspace_root: Path,
@@ -762,6 +810,8 @@ def _record_start(
762
810
  okstra_version=ctx.get("OKSTRA_VERSION", ""),
763
811
  initial_status=initial_status,
764
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),
765
815
  )
766
816
 
767
817
 
@@ -826,7 +876,15 @@ def _record_artifact_runtime_render_only_event(inp: PrepareInputs, ctx: dict) ->
826
876
 
827
877
  def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
828
878
  """rerun 충실 재현을 위한 canonical argv 재구성."""
829
- 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
+ )
830
888
  pairs = [
831
889
  ("--task-type", inp.task_type),
832
890
  ("--project-id", inp.project_id),
@@ -841,20 +899,44 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
841
899
  ("--clarification-response", inp.clarification_response_path),
842
900
  ("--selected-direction", inp.selected_direction_path),
843
901
  ("--workers", workers),
844
- ("--lead-provider", inp.lead_provider or ctx.get("LEAD_PROVIDER", "")),
845
- ("--lead-model", inp.lead_model or ctx.get("LEAD_MODEL", "")),
846
- ("--claude-model", inp.claude_model or ctx.get("CLAUDE_WORKER_MODEL", "")),
847
- ("--codex-model", inp.codex_model or ctx.get("CODEX_WORKER_MODEL", "")),
848
- ("--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
+ ),
849
923
  ("--worker-model", inp.worker_models_raw),
850
924
  (
851
925
  "--report-writer-provider",
852
- 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", ""),
853
933
  ),
854
- ("--report-writer-model", inp.report_writer_model or ctx.get("REPORT_WRITER_MODEL", "")),
855
934
  ("--lead-runtime", inp.lead_runtime if inp.lead_runtime != "claude-code" else ""),
856
- ("--executor", inp.executor or ctx.get("EXECUTOR_PROVIDER", "")),
857
- ("--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", "")),
858
940
  ("--related-tasks", inp.related_tasks_raw),
859
941
  ("--work-category", inp.work_category),
860
942
  ]
@@ -862,6 +944,15 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
862
944
  for flag, val in pairs:
863
945
  if val:
864
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
+ ])
865
956
  if inp.render_only:
866
957
  argv.append("--render-only")
867
958
  if not inp.plan_verification_enabled:
@@ -1686,12 +1777,484 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1686
1777
  workers = normalize_workers(inp.workers_override or profile_workers_csv)
1687
1778
  if inp.workers_override.strip():
1688
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")
1689
1790
  if not workers:
1690
1791
  raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
1691
1792
  _validate_option_selection_roster(inp.task_type, workers)
1692
1793
  return workers, ",".join(workers)
1693
1794
 
1694
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
+
1695
2258
  def _validate_option_selection_roster(
1696
2259
  task_type: str, workers: Sequence[str]
1697
2260
  ) -> None:
@@ -1756,23 +2319,43 @@ class _ModelBindings:
1756
2319
  해소한다 — prepare 가 run seq 를 할당하거나 worktree 를 만들기 전에.
1757
2320
  """
1758
2321
 
1759
- lead: ModelAssignment
1760
- cw: ModelAssignment
1761
- co: ModelAssignment
1762
- ge: ModelAssignment
1763
- rw: ModelAssignment
2322
+ lead: ResolvedAssignment
2323
+ cw: "_LegacyModelProjection"
2324
+ co: "_LegacyModelProjection"
2325
+ ge: "_LegacyModelProjection"
2326
+ rw: ResolvedAssignment
1764
2327
  critic_choice: str
1765
2328
  critic_model_execution: str
1766
2329
  executor_display_name: str
1767
2330
  codex_worker_execution: str
1768
2331
  antigravity_worker_execution: str
1769
- executor_assignment: RoleAssignment
2332
+ executor_assignment: RoleAssignment | None
1770
2333
  lead_assignment: RoleAssignment
1771
2334
  worker_assignments: tuple[RoleAssignment, ...]
1772
2335
  invocation_assignments: dict[str, dict[str, object]]
1773
2336
 
1774
2337
 
1775
- 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]:
1776
2359
  """역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
1777
2360
  wizard 의 안내 표기가 공유하는 단일 기준점.
1778
2361
 
@@ -1782,18 +2365,28 @@ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1782
2365
  role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
1783
2366
  화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-5.6-sol` 을 배정한다.
1784
2367
  """
1785
- lead_default = _default(
1786
- "OKSTRA_DEFAULT_LEAD_MODEL",
1787
- provider_default_model(lead_provider, "lead")
1788
- if lead_provider
1789
- 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")
1790
2383
  )
1791
2384
  recommendations = {
1792
2385
  "lead": lead_default,
1793
2386
  "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", default_model("claude")),
1794
2387
  "codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", default_model("codex")),
1795
2388
  "antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", default_model("antigravity")),
1796
- "report-writer": _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", default_model("report-writer")),
2389
+ "report-writer": report_writer_default,
1797
2390
  }
1798
2391
  for provider in provider_ids("analyser"):
1799
2392
  recommendations.setdefault(
@@ -1802,7 +2395,10 @@ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1802
2395
  return recommendations
1803
2396
 
1804
2397
 
1805
- 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]:
1806
2402
  overrides: dict[str, str] = {}
1807
2403
  for item in (raw_value or "").split(","):
1808
2404
  item = item.strip()
@@ -1817,65 +2413,71 @@ def _parse_worker_model_overrides(raw_value: str) -> dict[str, str]:
1817
2413
  )
1818
2414
  if provider in overrides:
1819
2415
  raise PrepareError(f"duplicate --worker-model provider: {provider}")
1820
- provider_spec(provider)
2416
+ context.pool.resolve_provider(provider)
1821
2417
  overrides[provider] = model
1822
2418
  return overrides
1823
2419
 
1824
2420
 
1825
2421
  def _model_for_role(
1826
- provider: str, raw_value: str, role: str, default_override: str = "",
1827
- ) -> ModelAssignment:
1828
- default_value = default_override or provider_default_model(provider, role)
1829
- if not default_value:
1830
- raise PrepareError(f"provider {provider!r} does not define a default for role {role!r}")
1831
- return resolve_model_metadata(
1832
- provider=provider,
1833
- raw_value=raw_value,
1834
- default_display=default_value,
1835
- default_execution=default_value,
1836
- )
1837
-
1838
-
1839
- def _assignment_runner(lead_runtime: str, provider: str, role: str) -> str:
1840
- host_registry = default_host_registry()
1841
- 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:
1842
2430
  try:
1843
- return resolve_assignment_runner(
1844
- host=host_registry.resolve(lead_runtime).descriptor,
1845
- provider=provider_registry.resolve(provider),
2431
+ return resolve_dispatch_assignment(
2432
+ context=context,
2433
+ host_runtime=context.environment.host_descriptor.id,
1846
2434
  role=role,
1847
- ).runner
1848
- except (
1849
- HostCapabilityMismatch,
1850
- HostNotRegistered,
1851
- ProviderUnavailable,
1852
- UnknownProviderError,
1853
- ) 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:
1854
2440
  raise PrepareError(str(exc)) from exc
1855
2441
 
1856
2442
 
1857
- def _resolve_worker_models(inp: PrepareInputs) -> dict:
2443
+ def _resolve_worker_models(
2444
+ inp: PrepareInputs,
2445
+ workers: list[str],
2446
+ context: AssignmentContext,
2447
+ ) -> dict:
1858
2448
  """Resolve provider models while preserving legacy fixed-provider projections."""
1859
- rec = recommended_role_models()
1860
- overrides = _parse_worker_model_overrides(inp.worker_models_raw)
2449
+ overrides = _parse_worker_model_overrides(inp.worker_models_raw, context)
1861
2450
  legacy = {
1862
2451
  "claude": inp.claude_model,
1863
2452
  "codex": inp.codex_model,
1864
2453
  "antigravity": inp.antigravity_model,
1865
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
+ ))
1866
2469
  provider_models = {}
1867
- for provider in provider_ids("analyser"):
1868
- default_override = rec.get(provider, "")
2470
+ for provider in selected_worker_providers:
1869
2471
  provider_models[provider] = _model_for_role(
1870
- provider, overrides.get(provider, legacy.get(provider, "")),
1871
- "analyser", default_override,
2472
+ context=context,
2473
+ provider=provider,
2474
+ raw_value=worker_model_input(provider),
2475
+ role="analyser",
1872
2476
  )
1873
2477
  try:
1874
- lead_assignment = resolve_lead_provider(
1875
- host_id=inp.lead_runtime,
2478
+ lead_assignment = resolve_context_lead_provider(
2479
+ context=context,
1876
2480
  requested_provider=inp.lead_provider,
1877
- host_registry=default_host_registry(),
1878
- provider_registry=default_provider_registry(),
1879
2481
  )
1880
2482
  except (
1881
2483
  HostCapabilityMismatch,
@@ -1886,119 +2488,127 @@ def _resolve_worker_models(inp: PrepareInputs) -> dict:
1886
2488
  raise PrepareError(str(exc)) from exc
1887
2489
  lead_provider = lead_assignment.provider
1888
2490
  report_writer_provider = inp.report_writer_provider.strip().lower() or "claude"
1889
- 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
+ ):
1890
2494
  raise PrepareError(
1891
2495
  f"provider {report_writer_provider!r} does not support the report-writer role"
1892
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
+
1893
2521
  return {
1894
2522
  "lead_provider": lead_provider,
1895
2523
  "report_writer_provider": report_writer_provider,
1896
2524
  "lead": _model_for_role(
1897
- lead_provider, inp.lead_model, "lead",
1898
- 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",
1899
2534
  ),
1900
- "cw": provider_models["claude"],
1901
- "co": provider_models["codex"],
1902
- "ge": provider_models["antigravity"],
2535
+ "cw": legacy_projection("claude"),
2536
+ "co": legacy_projection("codex"),
2537
+ "ge": legacy_projection("antigravity"),
1903
2538
  "rw": _model_for_role(
1904
- report_writer_provider, inp.report_writer_model, "report-writer",
1905
- 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
+ ),
1906
2547
  ),
1907
2548
  "providers": provider_models,
1908
2549
  "worker_model_inputs": {
1909
- provider: overrides.get(provider, legacy.get(provider, ""))
1910
- 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
+ ))
1911
2556
  },
1912
2557
  }
1913
2558
 
1914
2559
 
1915
- 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:
1916
2566
  """worker 모델 + critic 선택 + executor 바인딩을 한 묶음으로 해소·검증한다."""
1917
- m = _resolve_worker_models(inp)
1918
- critic_choice = (inp.critic or "").strip().lower()
1919
- allowed_critics = ["", "off", *provider_ids("critic")]
1920
- if critic_choice not in allowed_critics:
1921
- raise PrepareError(
1922
- f"--critic must be one of: {', '.join(allowed_critics[1:])} "
1923
- f"(got: {critic_choice!r})"
1924
- )
2567
+ m = _resolve_worker_models(inp, workers, context)
2568
+ critic_choice = _validate_critic_choice(inp.critic)
1925
2569
  critic_model_execution = ""
1926
2570
  critic_meta = None
1927
2571
  if critic_choice in provider_ids("critic"):
1928
2572
  critic_meta = _model_for_role(
1929
- critic_choice,
1930
- m["worker_model_inputs"].get(critic_choice, ""),
1931
- "critic",
1932
- )
1933
- critic_model_execution = _normalized_execution(
1934
- critic_meta, provider=critic_choice, role="critic",
1935
- )
1936
- executor_default = _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
1937
- executor_provider = (inp.executor or executor_default).strip().lower()
1938
- allowed_executors = provider_ids("executor")
1939
- if executor_provider not in allowed_executors:
1940
- raise PrepareError(
1941
- f"--executor must be one of: {', '.join(allowed_executors)} "
1942
- f"(got: {executor_provider!r})"
1943
- )
1944
- if inp.task_type == "implementation" and executor_provider not in workers:
1945
- raise PrepareError(
1946
- f"--executor {executor_provider} requires {executor_provider!r} in "
1947
- f"--workers, but resolved roster is {workers!r}. "
1948
- 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",
1949
2578
  )
1950
- model_meta = m["providers"][executor_provider]
1951
- display_name = f"{provider_spec(executor_provider).display_label} executor"
1952
- lead_provider = m["lead_provider"]
1953
- lead_execution = m["lead"].execution
1954
- lead_runner = _assignment_runner(inp.lead_runtime, lead_provider, "lead")
1955
- if lead_runner == "cli-wrapper":
1956
- lead_execution = _normalized_execution(
1957
- 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"],
1958
2588
  )
1959
2589
  lead_assignment = _role_assignment(
1960
- host_runtime=inp.lead_runtime,
2590
+ resolved=m["lead"],
1961
2591
  role="lead",
1962
- provider=lead_provider,
1963
- model=m["lead"],
1964
- execution=lead_execution,
1965
- runner_role="lead",
1966
2592
  )
1967
- worker_assignments = _build_worker_assignments(inp, workers, m)
2593
+ worker_assignments = _build_worker_assignments(workers, m)
1968
2594
  critic_assignment = (
1969
2595
  _role_assignment(
1970
- host_runtime=inp.lead_runtime,
2596
+ resolved=critic_meta,
1971
2597
  role="critic",
1972
- provider=critic_choice,
1973
- model=critic_meta,
1974
- execution=critic_model_execution,
1975
- runner_role="critic",
1976
2598
  )
1977
2599
  if critic_meta is not None
1978
2600
  else None
1979
2601
  )
1980
- translator_assignment = _role_assignment(
1981
- host_runtime=inp.lead_runtime,
1982
- role="translator",
2602
+ translator_meta = _model_for_role(
2603
+ context=context,
1983
2604
  provider=m["report_writer_provider"],
1984
- model=m["rw"],
1985
- execution=_normalized_execution(
1986
- m["rw"],
1987
- provider=m["report_writer_provider"],
1988
- role="report-writer",
1989
- ),
1990
- runner_role="report-writer",
2605
+ raw_value=m["rw"].model_id,
2606
+ role="translator",
2607
+ duty_id="translator",
1991
2608
  )
1992
- executor_assignment = _role_assignment(
1993
- host_runtime=inp.lead_runtime,
1994
- role="executor",
1995
- provider=executor_provider,
1996
- model=model_meta,
1997
- execution=_normalized_execution(
1998
- model_meta, provider=executor_provider, role="executor"
1999
- ),
2000
- runner_role="executor",
2001
- worker_id=executor_provider,
2609
+ translator_assignment = _role_assignment(
2610
+ resolved=translator_meta,
2611
+ role="translator",
2002
2612
  )
2003
2613
  invocation_assignments = _build_invocation_assignments(
2004
2614
  lead_assignment,
@@ -2006,15 +2616,24 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
2006
2616
  critic_assignment,
2007
2617
  translator_assignment,
2008
2618
  )
2619
+ worker_by_provider = {
2620
+ row.provider: row for row in worker_assignments
2621
+ }
2009
2622
  return _ModelBindings(
2010
2623
  lead=m["lead"], cw=m["cw"], co=m["co"], ge=m["ge"], rw=m["rw"],
2011
2624
  critic_choice=critic_choice,
2012
2625
  critic_model_execution=critic_model_execution,
2013
2626
  executor_display_name=display_name,
2014
- codex_worker_execution=_worker_execution_value(
2015
- m["co"], "codex", "worker", workers),
2016
- antigravity_worker_execution=_worker_execution_value(
2017
- 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
+ ),
2018
2637
  executor_assignment=executor_assignment,
2019
2638
  lead_assignment=lead_assignment,
2020
2639
  worker_assignments=worker_assignments,
@@ -2022,8 +2641,245 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
2022
2641
  )
2023
2642
 
2024
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
+
2025
2881
  def _build_worker_assignments(
2026
- inp: PrepareInputs, workers: list[str], models: dict,
2882
+ workers: list[str], models: dict,
2027
2883
  ) -> tuple[RoleAssignment, ...]:
2028
2884
  assignments: list[RoleAssignment] = []
2029
2885
  for worker_id in workers:
@@ -2031,18 +2887,12 @@ def _build_worker_assignments(
2031
2887
  provider = (
2032
2888
  models["report_writer_provider"] if is_report_writer else worker_id
2033
2889
  )
2034
- role = "report-writer" if is_report_writer else "analyser"
2035
- meta = models["rw"] if is_report_writer else models["providers"][provider]
2036
- execution = _worker_execution_value(
2037
- meta, provider, "worker", [provider],
2890
+ selected = (
2891
+ models["rw"] if is_report_writer else models["providers"][provider]
2038
2892
  )
2039
2893
  assignments.append(_role_assignment(
2040
- host_runtime=inp.lead_runtime,
2041
- role=role,
2042
- provider=provider,
2043
- model=meta,
2044
- execution=execution,
2045
- runner_role=role,
2894
+ resolved=selected,
2895
+ role="report-writer" if is_report_writer else "analyser",
2046
2896
  worker_id=worker_id,
2047
2897
  ))
2048
2898
  return tuple(assignments)
@@ -2050,55 +2900,40 @@ def _build_worker_assignments(
2050
2900
 
2051
2901
  def _role_assignment(
2052
2902
  *,
2053
- host_runtime: str,
2903
+ resolved: ResolvedAssignment,
2054
2904
  role: str,
2055
- provider: str,
2056
- model: ModelAssignment,
2057
- execution: str,
2058
- runner_role: str,
2059
2905
  worker_id: str = "",
2060
2906
  ) -> RoleAssignment:
2061
- runner = _assignment_runner(host_runtime, provider, runner_role)
2062
- host_model_value = _resolve_host_model_value(
2063
- host_runtime=host_runtime,
2064
- provider=provider,
2065
- model=model,
2066
- execution=execution,
2067
- runner=runner,
2068
- )
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
+ )
2069
2921
  return RoleAssignment(
2070
2922
  role=role,
2071
- provider=provider,
2072
- model_display=model.display,
2073
- model_execution_value=execution,
2074
- runner=runner,
2075
- host_runtime=host_runtime,
2076
- 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,
2077
2933
  worker_id=worker_id,
2078
2934
  )
2079
2935
 
2080
2936
 
2081
- def _resolve_host_model_value(
2082
- *,
2083
- host_runtime: str,
2084
- provider: str,
2085
- model: ModelAssignment,
2086
- execution: str,
2087
- runner: str,
2088
- ) -> str | None:
2089
- try:
2090
- port = default_host_registry().resolve(host_runtime).host_model()
2091
- return port.resolve(HostModelBindingRequest(
2092
- host_runtime=host_runtime,
2093
- provider=provider,
2094
- model=model.display,
2095
- model_execution_value=execution,
2096
- runner=runner,
2097
- ))
2098
- except (HostModelBindingError, HostNotRegistered) as exc:
2099
- raise PrepareError(str(exc)) from exc
2100
-
2101
-
2102
2937
  def _build_invocation_assignments(
2103
2938
  lead: RoleAssignment,
2104
2939
  workers: tuple[RoleAssignment, ...],
@@ -2120,27 +2955,6 @@ def _build_invocation_assignments(
2120
2955
  return assignments
2121
2956
 
2122
2957
 
2123
- def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> str:
2124
- """Route a role's catalog execution value through the CLI-identity normalizer
2125
- so the manifest carries the dispatch-correct spelling. SSOT for the mapping
2126
- lives in model_discovery; this is only the run.py call seam."""
2127
- return normalize_execution_for_dispatch(
2128
- provider=provider, execution=meta.execution, role=role,
2129
- )
2130
-
2131
-
2132
- def _worker_execution_value(
2133
- meta: ModelAssignment, provider: str, role: str, workers: list[str],
2134
- ) -> str:
2135
- """Normalize a worker role's execution value only when that provider's
2136
- worker is in the resolved roster; otherwise return the raw catalog value.
2137
- A worker that will not be dispatched must not probe its CLI or hard-fail
2138
- the whole prep (the CLI-identity check belongs only to workers that run)."""
2139
- if provider in workers:
2140
- return _normalized_execution(meta, provider=provider, role=role)
2141
- return meta.execution
2142
-
2143
-
2144
2958
  StageRunClaim = _implementation_stage.StageRunClaim
2145
2959
 
2146
2960
 
@@ -2529,11 +3343,18 @@ def _render_lead_prompt_and_snapshot(
2529
3343
  assignment = _agent_model_assignment(
2530
3344
  json.loads(ctx["INVOCATION_ASSIGNMENTS_JSON"])["lead"]
2531
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"
2532
3355
  prepared = prepare_agent_invocation(AgentInvocationRequest(
2533
- invocation_id=(
2534
- f"{ctx['TASK_TYPE_SEGMENT']}-{ctx['RUN_PROMPTS_SEQ']}-lead"
2535
- ),
2536
- worker_id="lead",
3356
+ invocation_id=invocation_id,
3357
+ worker_id=None,
2537
3358
  audience="lead",
2538
3359
  assignment_ref="lead",
2539
3360
  purpose=None,
@@ -2557,6 +3378,11 @@ def _render_lead_prompt_and_snapshot(
2557
3378
  prompt_path=Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]),
2558
3379
  metadata_path=Path(ctx["LEAD_PROMPT_METADATA_PATH"]),
2559
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,
2560
3386
  ))
2561
3387
  return prepared.prompt_path.read_text(encoding="utf-8")
2562
3388
 
@@ -2581,12 +3407,7 @@ def _persist_run_inputs(
2581
3407
  if inp.approved_plan_path
2582
3408
  else ""
2583
3409
  )
2584
- run_inputs_path = write_run_inputs(
2585
- project_root=Path(inp.project_root),
2586
- run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
2587
- task_type_segment=ctx["TASK_TYPE_SEGMENT"],
2588
- seq=ctx["RUN_MANIFESTS_SEQ"],
2589
- inputs={
3410
+ inputs = {
2590
3411
  "taskBriefPath": brief_relative,
2591
3412
  "taskBriefAbsolutePath": str(inp.brief_path),
2592
3413
  "directive": inp.directive,
@@ -2608,7 +3429,11 @@ def _persist_run_inputs(
2608
3429
  inp.report_writer_provider or "claude",
2609
3430
  ),
2610
3431
  "reportWriterModel": models.rw.display,
2611
- "executor": models.executor_assignment.provider,
3432
+ "executor": (
3433
+ models.executor_assignment.provider
3434
+ if models.executor_assignment is not None
3435
+ else ""
3436
+ ),
2612
3437
  "relatedTasks": inp.related_tasks_raw,
2613
3438
  "approvedPlanPath": approved_plan_path,
2614
3439
  "clarificationResponsePath": inp.clarification_response_path,
@@ -2618,7 +3443,13 @@ def _persist_run_inputs(
2618
3443
  ),
2619
3444
  "evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
2620
3445
  "renderOnly": inp.render_only,
2621
- },
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,
2622
3453
  )
2623
3454
  ctx["RUN_INPUTS_PATH"] = str(run_inputs_path)
2624
3455
  ctx["RUN_INPUTS_RELATIVE_PATH"] = relative_to_project_root(
@@ -2626,6 +3457,29 @@ def _persist_run_inputs(
2626
3457
  )
2627
3458
 
2628
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
+
2629
3483
  def _persist_pre_dispatch_run_manifest(ctx: dict) -> None:
2630
3484
  """Publish and re-read the complete invocation authority before prompts."""
2631
3485
  render_run_manifest(ctx["RUN_MANIFEST_PATH"], ctx)
@@ -3023,24 +3877,38 @@ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
3023
3877
  ),
3024
3878
  "",
3025
3879
  ),
3026
- "EXECUTOR_WORKER_ID": models.executor_assignment.worker_id,
3027
- "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,
3028
3902
  "EXECUTOR_DISPLAY_NAME": models.executor_display_name,
3029
- "EXECUTOR_MODEL_DISPLAY": models.executor_assignment.model_display,
3030
- "EXECUTOR_MODEL_EXECUTION_VALUE": (
3031
- models.executor_assignment.model_execution_value
3032
- ),
3033
- "EXECUTOR_HOST_MODEL_VALUE": (
3034
- models.executor_assignment.host_model_value or ""
3035
- ),
3036
- "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,
3037
3907
  "EXECUTOR_DISPATCH_MODE": (
3038
3908
  "host-native"
3039
- if models.executor_assignment.runner == "native-session"
3909
+ if assignment.runner == "native-session"
3040
3910
  else "worker-dispatch"
3041
3911
  ),
3042
- "CRITIC_CHOICE": models.critic_choice,
3043
- "CRITIC_MODEL_EXECUTION_VALUE": models.critic_model_execution,
3044
3912
  }
3045
3913
 
3046
3914
 
@@ -3147,6 +4015,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3147
4015
  # 조립 코드가 단일 흐름으로 읽히도록 유지한다.
3148
4016
  assets = _resolve_runtime_assets(workspace_root, inp)
3149
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)
3150
4020
  prompt_template = assets.prompt_template
3151
4021
  task_index_template = assets.task_index_template
3152
4022
  final_report_template = assets.final_report_template
@@ -3168,13 +4038,45 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3168
4038
  handoff_tokens = _materialize_release_handoff_input(
3169
4039
  workspace_root, project_root, inp)
3170
4040
 
3171
- verify_installation(workspace_root)
3172
- _register_and_check_project(project_root, inp)
3173
-
3174
4041
  workers, selected_reviewers = _resolve_roster(inp, profile_file)
3175
4042
  pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
3176
4043
 
3177
- 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)
3178
4080
 
3179
4081
  # ---- paths under per-task mutex (writes run-context-*.json) ----
3180
4082
  # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
@@ -3327,6 +4229,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3327
4229
  "LATEST_REPORT_RELATIVE_PATH": "",
3328
4230
  "RENDER_ONLY": "true" if inp.render_only else "false",
3329
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
+ ),
3330
4238
  })
3331
4239
  refresh_run_context_snapshot(ctx)
3332
4240
  if lead_runtime == "codex":
@@ -3366,6 +4274,15 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3366
4274
  _finalize_status_and_render_manifests(
3367
4275
  inp, ctx, task_index_template, forbidden_by_phase
3368
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
+ )
3369
4286
  _record_artifact_runtime_render_only_event(inp, ctx)
3370
4287
 
3371
4288
  _record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
@@ -3389,10 +4306,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3389
4306
  )
3390
4307
 
3391
4308
 
3392
- def main(argv: list[str]) -> int:
3393
- """CLI dispatcher for bash thin-wrapper use. Parses a flat list of argv
3394
- (the same flags `okstra.sh` accepts), runs prepare_task_bundle, prints
3395
- summary lines + prompt to stdout."""
4309
+ def build_prepare_argument_parser():
4310
+ """Build the shared flat-argument parser used by public prepare shims."""
3396
4311
  import argparse
3397
4312
 
3398
4313
  p = argparse.ArgumentParser()
@@ -3422,6 +4337,13 @@ def main(argv: list[str]) -> int:
3422
4337
  ),
3423
4338
  )
3424
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
+ )
3425
4347
  p.add_argument("--lead-provider", default="")
3426
4348
  p.add_argument("--lead-model", default="")
3427
4349
  p.add_argument("--claude-model", default="")
@@ -3565,7 +4487,12 @@ def main(argv: list[str]) -> int:
3565
4487
  "`main` / `dev` / `staging` / `preprod` / `prod` or any local ref."
3566
4488
  ),
3567
4489
  )
3568
- 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)
3569
4496
 
3570
4497
  project_root = Path(args.project_root).expanduser().resolve()
3571
4498
  if args.task_type == "release-handoff":
@@ -3609,6 +4536,25 @@ def main(argv: list[str]) -> int:
3609
4536
  return 1
3610
4537
  selected_direction_abs = str(selected)
3611
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
+
3612
4558
  inputs = PrepareInputs(
3613
4559
  workspace_root=Path(args.workspace_root).resolve(),
3614
4560
  project_root=project_root,
@@ -3621,6 +4567,9 @@ def main(argv: list[str]) -> int:
3621
4567
  evidence_inputs_raw=args.evidence_inputs_raw,
3622
4568
  directive=args.directive,
3623
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,
3624
4573
  lead_provider=args.lead_provider,
3625
4574
  lead_model=args.lead_model,
3626
4575
  claude_model=args.claude_model,