okstra 0.174.0 → 0.176.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) 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 +6 -2
  232. package/docs/architecture.md +29 -20
  233. package/docs/cli.md +40 -16
  234. package/docs/contributor-change-matrix.md +2 -2
  235. package/docs/for-ai/skills/okstra-inspect.md +2 -3
  236. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  237. package/docs/project-structure-overview.md +72 -62
  238. package/docs/task-process/README.md +12 -8
  239. package/docs/task-process/common-flow.md +13 -16
  240. package/docs/task-process/error-analysis.md +9 -10
  241. package/docs/task-process/final-verification.md +7 -7
  242. package/docs/task-process/implementation-planning.md +9 -9
  243. package/docs/task-process/implementation.md +6 -6
  244. package/docs/task-process/release-handoff.md +8 -7
  245. package/docs/task-process/requirements-discovery.md +8 -8
  246. package/package.json +11 -5
  247. package/runtime/BUILD.json +2 -2
  248. package/runtime/agents/workers/claude-worker.md +2 -1
  249. package/runtime/agents/workers/report-writer-worker.md +3 -1
  250. package/runtime/agents/workers/translator-worker.md +3 -1
  251. package/runtime/bin/lib/okstra/cli.sh +12 -0
  252. package/runtime/bin/lib/okstra/globals.sh +3 -0
  253. package/runtime/bin/lib/okstra/interactive.sh +12 -6
  254. package/runtime/bin/lib/okstra/usage.sh +7 -6
  255. package/runtime/bin/okstra-error-log.py +27 -0
  256. package/runtime/bin/okstra-provider-exec.py +217 -5
  257. package/runtime/bin/okstra-spawn-followups.py +4 -2
  258. package/runtime/bin/okstra.sh +10 -0
  259. package/runtime/prompts/launch.template.md +2 -2
  260. package/runtime/prompts/lead/context-loader.md +1 -2
  261. package/runtime/prompts/lead/convergence.md +27 -18
  262. package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
  263. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  264. package/runtime/prompts/lead/report-writer.md +15 -1
  265. package/runtime/prompts/lead/team-contract.md +17 -13
  266. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  267. package/runtime/prompts/profiles/_implementation-executor.md +0 -3
  268. package/runtime/prompts/profiles/_implementation-verifier.md +3 -7
  269. package/runtime/prompts/profiles/change-impact-analysis.md +20 -0
  270. package/runtime/prompts/profiles/error-analysis.md +27 -1
  271. package/runtime/prompts/profiles/feature-analysis.md +20 -0
  272. package/runtime/prompts/profiles/final-verification.md +20 -1
  273. package/runtime/prompts/profiles/implementation-option-selection.md +20 -0
  274. package/runtime/prompts/profiles/implementation-planning.md +26 -0
  275. package/runtime/prompts/profiles/implementation.md +19 -0
  276. package/runtime/prompts/profiles/improvement-discovery.md +21 -1
  277. package/runtime/prompts/profiles/project-analysis.md +20 -0
  278. package/runtime/prompts/profiles/release-handoff.md +4 -0
  279. package/runtime/prompts/profiles/requirements-discovery.md +27 -1
  280. package/runtime/prompts/wizard/prompts.ko.json +35 -0
  281. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +8 -0
  282. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +8 -0
  283. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  284. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +2 -2
  285. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +25 -5
  286. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  287. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +4 -1
  288. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  289. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +2 -2
  290. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +79 -11
  291. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +95 -25
  292. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +59 -14
  293. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +55 -9
  294. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +51 -12
  295. package/runtime/python/okstra_ctl/adapters/runtime/__init__.py +1 -0
  296. package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +27 -0
  297. package/runtime/python/okstra_ctl/adapters/runtime/cli_wrapper.py +49 -0
  298. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +74 -0
  299. package/runtime/python/okstra_ctl/agent_activity.py +5 -1
  300. package/runtime/python/okstra_ctl/agent_invocation.py +608 -14
  301. package/runtime/python/okstra_ctl/agent_prompt_cli.py +249 -50
  302. package/runtime/python/okstra_ctl/application/open_worker.py +29 -0
  303. package/runtime/python/okstra_ctl/application/resolve_assignment.py +53 -12
  304. package/runtime/python/okstra_ctl/assignment_environment.py +102 -0
  305. package/runtime/python/okstra_ctl/assignment_resolver.py +790 -0
  306. package/runtime/python/okstra_ctl/attempt_evidence.py +342 -0
  307. package/runtime/python/okstra_ctl/convergence.py +409 -3
  308. package/runtime/python/okstra_ctl/convergence_engine.py +248 -15
  309. package/runtime/python/okstra_ctl/convergence_store.py +170 -1
  310. package/runtime/python/okstra_ctl/dispatch_core.py +1204 -155
  311. package/runtime/python/okstra_ctl/dispatch_state.py +696 -11
  312. package/runtime/python/okstra_ctl/doctor.py +14 -0
  313. package/runtime/python/okstra_ctl/domain/host.py +34 -1
  314. package/runtime/python/okstra_ctl/domain/provider.py +212 -6
  315. package/runtime/python/okstra_ctl/domain/role.py +125 -0
  316. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +18 -0
  317. package/runtime/python/okstra_ctl/domain/worker_exec.py +34 -1
  318. package/runtime/python/okstra_ctl/domain/worker_role.py +13 -3
  319. package/runtime/python/okstra_ctl/domain/worker_runtime.py +44 -0
  320. package/runtime/python/okstra_ctl/domain/worker_stream.py +24 -0
  321. package/runtime/python/okstra_ctl/error_log_write.py +9 -0
  322. package/runtime/python/okstra_ctl/error_report.py +21 -1
  323. package/runtime/python/okstra_ctl/execution_identity.py +529 -0
  324. package/runtime/python/okstra_ctl/execution_manifest.py +1281 -0
  325. package/runtime/python/okstra_ctl/execution_mutation_audit.py +619 -0
  326. package/runtime/python/okstra_ctl/implementation_outcome.py +21 -5
  327. package/runtime/python/okstra_ctl/index.py +6 -2
  328. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +43 -3
  329. package/runtime/python/okstra_ctl/legacy_model_selection.py +578 -0
  330. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  331. package/runtime/python/okstra_ctl/model_cli.py +278 -0
  332. package/runtime/python/okstra_ctl/model_defaults.py +46 -0
  333. package/runtime/python/okstra_ctl/model_discovery.py +26 -7
  334. package/runtime/python/okstra_ctl/model_pool.py +501 -0
  335. package/runtime/python/okstra_ctl/models.py +47 -27
  336. package/runtime/python/okstra_ctl/mutation_recovery.py +293 -0
  337. package/runtime/python/okstra_ctl/next_phase.py +236 -0
  338. package/runtime/python/okstra_ctl/pane_title.py +151 -0
  339. package/runtime/python/okstra_ctl/ports/host_model.py +48 -17
  340. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  341. package/runtime/python/okstra_ctl/ports/worker_runtime.py +26 -0
  342. package/runtime/python/okstra_ctl/recap.py +4 -1
  343. package/runtime/python/okstra_ctl/registry/provider_registry.py +8 -4
  344. package/runtime/python/okstra_ctl/render.py +124 -57
  345. package/runtime/python/okstra_ctl/render_final_report.py +19 -0
  346. package/runtime/python/okstra_ctl/report_contract.py +39 -0
  347. package/runtime/python/okstra_ctl/report_finalize.py +16 -0
  348. package/runtime/python/okstra_ctl/report_html/render.py +1 -0
  349. package/runtime/python/okstra_ctl/role_requirements.py +210 -0
  350. package/runtime/python/okstra_ctl/rollup.py +4 -1
  351. package/runtime/python/okstra_ctl/run.py +1210 -258
  352. package/runtime/python/okstra_ctl/run_index_row.py +7 -1
  353. package/runtime/python/okstra_ctl/stage_fix_carry.py +17 -1
  354. package/runtime/python/okstra_ctl/team.py +14 -18
  355. package/runtime/python/okstra_ctl/time_report.py +38 -4
  356. package/runtime/python/okstra_ctl/usage_identity.py +86 -0
  357. package/runtime/python/okstra_ctl/usage_report.py +15 -3
  358. package/runtime/python/okstra_ctl/wizard.py +1449 -66
  359. package/runtime/python/okstra_ctl/worker_audit_ledger.py +68 -65
  360. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -2
  361. package/runtime/python/okstra_ctl/worker_prompt_contract.py +40 -9
  362. package/runtime/python/okstra_ctl/worker_prompt_headers.py +16 -1
  363. package/runtime/python/okstra_ctl/worker_request.py +7 -1
  364. package/runtime/python/okstra_ctl/worker_runner.py +130 -5
  365. package/runtime/python/okstra_ctl/workers.py +7 -5
  366. package/runtime/python/okstra_ctl/workflow.py +18 -32
  367. package/runtime/python/okstra_ctl/worktree.py +3 -3
  368. package/runtime/python/okstra_ctl/worktree_registry.py +5 -4
  369. package/runtime/python/okstra_ctl/write_policy.py +571 -0
  370. package/runtime/python/okstra_project/state.py +54 -6
  371. package/runtime/schemas/convergence-groups-v2.0.schema.json +128 -0
  372. package/runtime/schemas/execution-manifest-v2.schema.json +340 -0
  373. package/runtime/schemas/final-report-v2.0.schema.json +68 -5
  374. package/runtime/skills/okstra-inspect/facets/recap.md +2 -0
  375. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  376. package/runtime/skills/okstra-inspect/facets/status.md +15 -13
  377. package/runtime/skills/okstra-rollup/SKILL.md +1 -0
  378. package/runtime/skills/okstra-run/SKILL.md +14 -14
  379. package/runtime/templates/implementation-worker-preamble.md +3 -18
  380. package/runtime/templates/project-docs/task-index.template.md +0 -1
  381. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  382. package/runtime/templates/reports/final-report-v2.template.md +7 -1
  383. package/runtime/templates/reports/html/base.template.html +21 -0
  384. package/runtime/templates/reports/html/i18n/en.json +4 -0
  385. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  386. package/runtime/templates/reports/html/macros/forms.html +5 -4
  387. package/runtime/templates/reports/html/tasks/final-verification.template.html +1 -1
  388. package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
  389. package/runtime/templates/worker-prompt-preamble.md +6 -35
  390. package/runtime/validators/lib/fixtures.sh +49 -16
  391. package/runtime/validators/validate-run.py +486 -134
  392. package/runtime/validators/validate_session_conformance.py +25 -7
  393. package/src/cli-registry.mjs +0 -539
  394. package/src/commands/execute/codex-dispatch.mjs +0 -10
  395. package/src/commands/execute/plan-items.mjs +0 -9
  396. package/src/commands/execute/plan-validate.mjs +0 -68
  397. package/src/commands/execute/run.mjs +0 -208
  398. package/src/commands/execute/wizard.mjs +0 -167
  399. package/src/commands/inspect/stage-map.mjs +0 -98
  400. package/src/commands/inspect/task-list.mjs +0 -97
  401. package/src/commands/inspect/task-show.mjs +0 -100
  402. package/src/commands/lifecycle/check-project.mjs +0 -187
  403. package/src/commands/lifecycle/config.mjs +0 -385
  404. package/src/commands/lifecycle/doctor.mjs +0 -308
  405. package/src/commands/lifecycle/install.mjs +0 -1170
  406. package/src/commands/lifecycle/paths.mjs +0 -104
  407. package/src/commands/lifecycle/preflight.mjs +0 -125
  408. package/src/commands/lifecycle/setup.mjs +0 -333
  409. package/src/commands/lifecycle/uninstall.mjs +0 -279
  410. package/src/commands/memory/memory.mjs +0 -524
  411. package/src/commands/pr/pr.mjs +0 -261
  412. package/src/lib/host-registry-client.mjs +0 -176
  413. package/src/lib/paths.mjs +0 -86
  414. package/src/lib/proc.mjs +0 -31
  415. package/src/lib/python-helper.mjs +0 -142
  416. package/src/lib/runtime-manifest.mjs +0 -70
  417. package/src/lib/skill-catalog.mjs +0 -53
  418. package/src/lib/version.mjs +0 -20
  419. /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,487 @@ 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.min_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.min_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.recommended_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.recommended_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 (
2223
+ not requirement.dynamic
2224
+ and requirement.min_count < requirement.max_count
2225
+ )
2226
+ }
2227
+ return {role: count for role, count in counts.items() if role in adjustable}
2228
+
2229
+
2230
+ def _model_default_scopes(project_root: Path) -> ModelDefaultScopes:
2231
+ return ModelDefaultScopes(
2232
+ project=_read_model_defaults(project_json_path(project_root)),
2233
+ global_=_read_model_defaults(okstra_home() / "config.json"),
2234
+ bundled={},
2235
+ )
2236
+
2237
+
2238
+ def _read_model_defaults(path: Path) -> dict[str, tuple[str, ...]]:
2239
+ if not path.is_file():
2240
+ return {}
2241
+ try:
2242
+ payload = json.loads(path.read_text(encoding="utf-8"))
2243
+ except (OSError, json.JSONDecodeError) as exc:
2244
+ raise PrepareError(f"model defaults file is invalid: {path}: {exc}") from exc
2245
+ raw_defaults = payload.get("modelDefaults", {}) if isinstance(payload, dict) else {}
2246
+ if not isinstance(raw_defaults, dict):
2247
+ raise PrepareError(f"modelDefaults must be an object: {path}")
2248
+ defaults: dict[str, tuple[str, ...]] = {}
2249
+ for role, models in raw_defaults.items():
2250
+ if (
2251
+ not isinstance(role, str)
2252
+ or not isinstance(models, list)
2253
+ or not models
2254
+ or not all(isinstance(model, str) and model for model in models)
2255
+ ):
2256
+ raise PrepareError(f"modelDefaults entries must be non-empty string arrays: {path}")
2257
+ defaults[role] = tuple(models)
2258
+ return defaults
2259
+
2260
+
1695
2261
  def _validate_option_selection_roster(
1696
2262
  task_type: str, workers: Sequence[str]
1697
2263
  ) -> None:
@@ -1756,23 +2322,43 @@ class _ModelBindings:
1756
2322
  해소한다 — prepare 가 run seq 를 할당하거나 worktree 를 만들기 전에.
1757
2323
  """
1758
2324
 
1759
- lead: ModelAssignment
1760
- cw: ModelAssignment
1761
- co: ModelAssignment
1762
- ge: ModelAssignment
1763
- rw: ModelAssignment
2325
+ lead: ResolvedAssignment
2326
+ cw: "_LegacyModelProjection"
2327
+ co: "_LegacyModelProjection"
2328
+ ge: "_LegacyModelProjection"
2329
+ rw: ResolvedAssignment
1764
2330
  critic_choice: str
1765
2331
  critic_model_execution: str
1766
2332
  executor_display_name: str
1767
2333
  codex_worker_execution: str
1768
2334
  antigravity_worker_execution: str
1769
- executor_assignment: RoleAssignment
2335
+ executor_assignment: RoleAssignment | None
1770
2336
  lead_assignment: RoleAssignment
1771
2337
  worker_assignments: tuple[RoleAssignment, ...]
1772
2338
  invocation_assignments: dict[str, dict[str, object]]
1773
2339
 
1774
2340
 
1775
- def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
2341
+ @dataclass(frozen=True)
2342
+ class _LegacyModelProjection:
2343
+ """Display/catalog values retained for the fixed v1 manifest fields."""
2344
+
2345
+ model_id: str
2346
+ display: str
2347
+ execution: str
2348
+
2349
+
2350
+ def _legacy_claude_model_override(provider: str, env_name: str) -> str:
2351
+ """Read a legacy Claude model override only for a Claude assignment."""
2352
+ if provider.strip().lower() != "claude":
2353
+ return ""
2354
+ return _default(env_name, "")
2355
+
2356
+
2357
+ def recommended_role_models(
2358
+ *,
2359
+ lead_provider: str = "",
2360
+ report_writer_provider: str = "",
2361
+ ) -> dict[str, str]:
1776
2362
  """역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
1777
2363
  wizard 의 안내 표기가 공유하는 단일 기준점.
1778
2364
 
@@ -1782,18 +2368,28 @@ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1782
2368
  role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
1783
2369
  화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-5.6-sol` 을 배정한다.
1784
2370
  """
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"),
2371
+ lead_provider_id = lead_provider or "claude"
2372
+ report_writer_provider_id = report_writer_provider or "claude"
2373
+ lead_default = (
2374
+ _legacy_claude_model_override(
2375
+ lead_provider_id,
2376
+ "OKSTRA_DEFAULT_LEAD_MODEL",
2377
+ )
2378
+ or provider_default_model(lead_provider_id, "lead")
2379
+ )
2380
+ report_writer_default = (
2381
+ _legacy_claude_model_override(
2382
+ report_writer_provider_id,
2383
+ "OKSTRA_DEFAULT_REPORT_WRITER_MODEL",
2384
+ )
2385
+ or provider_default_model(report_writer_provider_id, "report-writer")
1790
2386
  )
1791
2387
  recommendations = {
1792
2388
  "lead": lead_default,
1793
2389
  "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", default_model("claude")),
1794
2390
  "codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", default_model("codex")),
1795
2391
  "antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", default_model("antigravity")),
1796
- "report-writer": _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", default_model("report-writer")),
2392
+ "report-writer": report_writer_default,
1797
2393
  }
1798
2394
  for provider in provider_ids("analyser"):
1799
2395
  recommendations.setdefault(
@@ -1802,7 +2398,10 @@ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1802
2398
  return recommendations
1803
2399
 
1804
2400
 
1805
- def _parse_worker_model_overrides(raw_value: str) -> dict[str, str]:
2401
+ def _parse_worker_model_overrides(
2402
+ raw_value: str,
2403
+ context: AssignmentContext,
2404
+ ) -> dict[str, str]:
1806
2405
  overrides: dict[str, str] = {}
1807
2406
  for item in (raw_value or "").split(","):
1808
2407
  item = item.strip()
@@ -1817,65 +2416,71 @@ def _parse_worker_model_overrides(raw_value: str) -> dict[str, str]:
1817
2416
  )
1818
2417
  if provider in overrides:
1819
2418
  raise PrepareError(f"duplicate --worker-model provider: {provider}")
1820
- provider_spec(provider)
2419
+ context.pool.resolve_provider(provider)
1821
2420
  overrides[provider] = model
1822
2421
  return overrides
1823
2422
 
1824
2423
 
1825
2424
  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()
2425
+ *,
2426
+ context: AssignmentContext,
2427
+ provider: str,
2428
+ raw_value: str,
2429
+ role: str,
2430
+ default_override: str = "",
2431
+ duty_id: str = "",
2432
+ ) -> ResolvedAssignment:
1842
2433
  try:
1843
- return resolve_assignment_runner(
1844
- host=host_registry.resolve(lead_runtime).descriptor,
1845
- provider=provider_registry.resolve(provider),
2434
+ return resolve_dispatch_assignment(
2435
+ context=context,
2436
+ host_runtime=context.environment.host_descriptor.id,
1846
2437
  role=role,
1847
- ).runner
1848
- except (
1849
- HostCapabilityMismatch,
1850
- HostNotRegistered,
1851
- ProviderUnavailable,
1852
- UnknownProviderError,
1853
- ) as exc:
2438
+ duty_id=duty_id or role,
2439
+ provider=provider,
2440
+ model=raw_value or default_override,
2441
+ )
2442
+ except (ValueError, RuntimeError) as exc:
1854
2443
  raise PrepareError(str(exc)) from exc
1855
2444
 
1856
2445
 
1857
- def _resolve_worker_models(inp: PrepareInputs) -> dict:
2446
+ def _resolve_worker_models(
2447
+ inp: PrepareInputs,
2448
+ workers: list[str],
2449
+ context: AssignmentContext,
2450
+ ) -> dict:
1858
2451
  """Resolve provider models while preserving legacy fixed-provider projections."""
1859
- rec = recommended_role_models()
1860
- overrides = _parse_worker_model_overrides(inp.worker_models_raw)
2452
+ overrides = _parse_worker_model_overrides(inp.worker_models_raw, context)
1861
2453
  legacy = {
1862
2454
  "claude": inp.claude_model,
1863
2455
  "codex": inp.codex_model,
1864
2456
  "antigravity": inp.antigravity_model,
1865
2457
  }
2458
+ legacy_default_env = {
2459
+ "claude": "OKSTRA_DEFAULT_CLAUDE_MODEL",
2460
+ "codex": "OKSTRA_DEFAULT_CODEX_MODEL",
2461
+ "antigravity": "OKSTRA_DEFAULT_ANTIGRAVITY_MODEL",
2462
+ }
2463
+
2464
+ def worker_model_input(provider: str) -> str:
2465
+ selected = overrides.get(provider, legacy.get(provider, ""))
2466
+ env_name = legacy_default_env.get(provider)
2467
+ return selected or (_default(env_name, "") if env_name else "")
2468
+
2469
+ selected_worker_providers = tuple(dict.fromkeys(
2470
+ worker for worker in workers if worker != "report-writer"
2471
+ ))
1866
2472
  provider_models = {}
1867
- for provider in provider_ids("analyser"):
1868
- default_override = rec.get(provider, "")
2473
+ for provider in selected_worker_providers:
1869
2474
  provider_models[provider] = _model_for_role(
1870
- provider, overrides.get(provider, legacy.get(provider, "")),
1871
- "analyser", default_override,
2475
+ context=context,
2476
+ provider=provider,
2477
+ raw_value=worker_model_input(provider),
2478
+ role="analyser",
1872
2479
  )
1873
2480
  try:
1874
- lead_assignment = resolve_lead_provider(
1875
- host_id=inp.lead_runtime,
2481
+ lead_assignment = resolve_context_lead_provider(
2482
+ context=context,
1876
2483
  requested_provider=inp.lead_provider,
1877
- host_registry=default_host_registry(),
1878
- provider_registry=default_provider_registry(),
1879
2484
  )
1880
2485
  except (
1881
2486
  HostCapabilityMismatch,
@@ -1886,119 +2491,127 @@ def _resolve_worker_models(inp: PrepareInputs) -> dict:
1886
2491
  raise PrepareError(str(exc)) from exc
1887
2492
  lead_provider = lead_assignment.provider
1888
2493
  report_writer_provider = inp.report_writer_provider.strip().lower() or "claude"
1889
- if not provider_supports_role(report_writer_provider, "report-writer"):
2494
+ if not context.pool.resolve_provider(report_writer_provider).supports_role(
2495
+ "report-writer"
2496
+ ):
1890
2497
  raise PrepareError(
1891
2498
  f"provider {report_writer_provider!r} does not support the report-writer role"
1892
2499
  )
2500
+
2501
+ def legacy_projection(provider: str) -> _LegacyModelProjection:
2502
+ assigned = provider_models.get(provider)
2503
+ if assigned is not None:
2504
+ return _LegacyModelProjection(
2505
+ model_id=assigned.model_id,
2506
+ display=assigned.display,
2507
+ execution=assigned.execution,
2508
+ )
2509
+ try:
2510
+ selected = resolve_model_selection(
2511
+ context.pool,
2512
+ provider,
2513
+ "analyser",
2514
+ worker_model_input(provider),
2515
+ )
2516
+ except (ValueError, RuntimeError) as exc:
2517
+ raise PrepareError(str(exc)) from exc
2518
+ return _LegacyModelProjection(
2519
+ model_id=selected.model_ref.model_id,
2520
+ display=selected.display_name,
2521
+ execution=selected.execution_value,
2522
+ )
2523
+
1893
2524
  return {
1894
2525
  "lead_provider": lead_provider,
1895
2526
  "report_writer_provider": report_writer_provider,
1896
2527
  "lead": _model_for_role(
1897
- lead_provider, inp.lead_model, "lead",
1898
- rec["lead"] if lead_provider == "claude" else "",
2528
+ context=context,
2529
+ provider=lead_provider,
2530
+ raw_value=inp.lead_model,
2531
+ role="leader",
2532
+ default_override=_legacy_claude_model_override(
2533
+ lead_provider,
2534
+ "OKSTRA_DEFAULT_LEAD_MODEL",
2535
+ ),
2536
+ duty_id="lead",
1899
2537
  ),
1900
- "cw": provider_models["claude"],
1901
- "co": provider_models["codex"],
1902
- "ge": provider_models["antigravity"],
2538
+ "cw": legacy_projection("claude"),
2539
+ "co": legacy_projection("codex"),
2540
+ "ge": legacy_projection("antigravity"),
1903
2541
  "rw": _model_for_role(
1904
- report_writer_provider, inp.report_writer_model, "report-writer",
1905
- rec["report-writer"] if report_writer_provider == "claude" else "",
2542
+ context=context,
2543
+ provider=report_writer_provider,
2544
+ raw_value=inp.report_writer_model,
2545
+ role="report-writer",
2546
+ default_override=_legacy_claude_model_override(
2547
+ report_writer_provider,
2548
+ "OKSTRA_DEFAULT_REPORT_WRITER_MODEL",
2549
+ ),
1906
2550
  ),
1907
2551
  "providers": provider_models,
1908
2552
  "worker_model_inputs": {
1909
- provider: overrides.get(provider, legacy.get(provider, ""))
1910
- for provider in provider_ids("analyser")
2553
+ provider: worker_model_input(provider)
2554
+ for provider in dict.fromkeys((
2555
+ *selected_worker_providers,
2556
+ *overrides,
2557
+ *legacy,
2558
+ ))
1911
2559
  },
1912
2560
  }
1913
2561
 
1914
2562
 
1915
- def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBindings:
2563
+ def _resolve_model_bindings(
2564
+ inp: PrepareInputs,
2565
+ workers: list[str],
2566
+ context: AssignmentContext,
2567
+ requires_executor: bool,
2568
+ ) -> _ModelBindings:
1916
2569
  """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
- )
2570
+ m = _resolve_worker_models(inp, workers, context)
2571
+ critic_choice = _validate_critic_choice(inp.critic)
1925
2572
  critic_model_execution = ""
1926
2573
  critic_meta = None
1927
2574
  if critic_choice in provider_ids("critic"):
1928
2575
  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])))}."
2576
+ context=context,
2577
+ provider=critic_choice,
2578
+ raw_value=m["worker_model_inputs"].get(critic_choice, ""),
2579
+ role="critic",
2580
+ duty_id="scope-critic",
1949
2581
  )
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",
2582
+ critic_model_execution = critic_meta.execution
2583
+ executor_assignment = None
2584
+ display_name = ""
2585
+ if requires_executor:
2586
+ executor_assignment, display_name = _resolve_executor_assignment(
2587
+ inp,
2588
+ workers,
2589
+ context,
2590
+ m["worker_model_inputs"],
1958
2591
  )
1959
2592
  lead_assignment = _role_assignment(
1960
- host_runtime=inp.lead_runtime,
2593
+ resolved=m["lead"],
1961
2594
  role="lead",
1962
- provider=lead_provider,
1963
- model=m["lead"],
1964
- execution=lead_execution,
1965
- runner_role="lead",
1966
2595
  )
1967
- worker_assignments = _build_worker_assignments(inp, workers, m)
2596
+ worker_assignments = _build_worker_assignments(workers, m)
1968
2597
  critic_assignment = (
1969
2598
  _role_assignment(
1970
- host_runtime=inp.lead_runtime,
2599
+ resolved=critic_meta,
1971
2600
  role="critic",
1972
- provider=critic_choice,
1973
- model=critic_meta,
1974
- execution=critic_model_execution,
1975
- runner_role="critic",
1976
2601
  )
1977
2602
  if critic_meta is not None
1978
2603
  else None
1979
2604
  )
1980
- translator_assignment = _role_assignment(
1981
- host_runtime=inp.lead_runtime,
1982
- role="translator",
2605
+ translator_meta = _model_for_role(
2606
+ context=context,
1983
2607
  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",
2608
+ raw_value=m["rw"].model_id,
2609
+ role="translator",
2610
+ duty_id="translator",
1991
2611
  )
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,
2612
+ translator_assignment = _role_assignment(
2613
+ resolved=translator_meta,
2614
+ role="translator",
2002
2615
  )
2003
2616
  invocation_assignments = _build_invocation_assignments(
2004
2617
  lead_assignment,
@@ -2006,15 +2619,24 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
2006
2619
  critic_assignment,
2007
2620
  translator_assignment,
2008
2621
  )
2622
+ worker_by_provider = {
2623
+ row.provider: row for row in worker_assignments
2624
+ }
2009
2625
  return _ModelBindings(
2010
2626
  lead=m["lead"], cw=m["cw"], co=m["co"], ge=m["ge"], rw=m["rw"],
2011
2627
  critic_choice=critic_choice,
2012
2628
  critic_model_execution=critic_model_execution,
2013
2629
  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),
2630
+ codex_worker_execution=(
2631
+ worker_by_provider["codex"].model_execution_value
2632
+ if "codex" in worker_by_provider
2633
+ else m["co"].execution
2634
+ ),
2635
+ antigravity_worker_execution=(
2636
+ worker_by_provider["antigravity"].model_execution_value
2637
+ if "antigravity" in worker_by_provider
2638
+ else m["ge"].execution
2639
+ ),
2018
2640
  executor_assignment=executor_assignment,
2019
2641
  lead_assignment=lead_assignment,
2020
2642
  worker_assignments=worker_assignments,
@@ -2022,8 +2644,245 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
2022
2644
  )
2023
2645
 
2024
2646
 
2647
+ def _validate_critic_choice(raw_value: str) -> str:
2648
+ critic_choice = (raw_value or "").strip().lower()
2649
+ allowed_critics = ["", "off", *provider_ids("critic")]
2650
+ if critic_choice not in allowed_critics:
2651
+ raise PrepareError(
2652
+ f"--critic must be one of: {', '.join(allowed_critics[1:])} "
2653
+ f"(got: {critic_choice!r})"
2654
+ )
2655
+ return critic_choice
2656
+
2657
+
2658
+ def _uses_canonical_assignment_projection(inp: PrepareInputs) -> bool:
2659
+ return bool(
2660
+ inp.role_counts_raw
2661
+ or inp.role_models_raw
2662
+ or inp.host_session_context.entry_mode == "current-session"
2663
+ )
2664
+
2665
+
2666
+ def _project_assignment_plan(
2667
+ inp: PrepareInputs,
2668
+ plan: AssignmentPlan,
2669
+ context: AssignmentContext,
2670
+ ) -> tuple[list[str], _ModelBindings]:
2671
+ lead = plan.by_role("leader")[0]
2672
+ report_rows = plan.by_role("report-writer")
2673
+ report_writer = (
2674
+ report_rows[0]
2675
+ if report_rows
2676
+ else _model_for_role(
2677
+ context=context,
2678
+ provider=inp.report_writer_provider.strip().lower() or "claude",
2679
+ raw_value=inp.report_writer_model,
2680
+ role="report-writer",
2681
+ )
2682
+ )
2683
+ worker_rows = tuple(
2684
+ row for row in plan.assignments
2685
+ if row.role not in {"leader", "critic", "implementer", "report-writer"}
2686
+ )
2687
+ worker_assignments = tuple(
2688
+ _role_assignment(
2689
+ resolved=row,
2690
+ role=row.role,
2691
+ worker_id=_canonical_worker_id(row, worker_rows),
2692
+ )
2693
+ for row in worker_rows
2694
+ )
2695
+ if report_rows:
2696
+ worker_assignments = (
2697
+ *worker_assignments,
2698
+ _role_assignment(
2699
+ resolved=report_writer,
2700
+ role="report-writer",
2701
+ worker_id="report-writer",
2702
+ ),
2703
+ )
2704
+ models = _project_model_bindings(
2705
+ inp,
2706
+ plan,
2707
+ context,
2708
+ lead,
2709
+ report_writer,
2710
+ worker_assignments,
2711
+ )
2712
+ return [row.worker_id for row in worker_assignments], models
2713
+
2714
+
2715
+ def _canonical_worker_id(
2716
+ assignment: ResolvedAssignment,
2717
+ rows: Sequence[ResolvedAssignment],
2718
+ ) -> str:
2719
+ prior = sum(
2720
+ row.provider_id == assignment.provider_id and row.ordinal < assignment.ordinal
2721
+ for row in rows
2722
+ )
2723
+ return assignment.provider_id if prior == 0 else f"{assignment.provider_id}-{prior + 1}"
2724
+
2725
+
2726
+ def _project_model_bindings(
2727
+ inp: PrepareInputs,
2728
+ plan: AssignmentPlan,
2729
+ context: AssignmentContext,
2730
+ lead: ResolvedAssignment,
2731
+ report_writer: ResolvedAssignment,
2732
+ workers: tuple[RoleAssignment, ...],
2733
+ ) -> _ModelBindings:
2734
+ lead_assignment = _role_assignment(resolved=lead, role="lead")
2735
+ critic_rows = plan.by_role("critic")
2736
+ critic_assignment = (
2737
+ _role_assignment(resolved=critic_rows[0], role="critic")
2738
+ if critic_rows
2739
+ else None
2740
+ )
2741
+ executor_rows = plan.by_role("implementer")
2742
+ executor_assignment = (
2743
+ _role_assignment(
2744
+ resolved=executor_rows[0],
2745
+ role="executor",
2746
+ worker_id=executor_rows[0].provider_id,
2747
+ )
2748
+ if executor_rows
2749
+ else None
2750
+ )
2751
+ translator = _model_for_role(
2752
+ context=context,
2753
+ provider=report_writer.provider_id,
2754
+ raw_value=report_writer.model_id,
2755
+ role="translator",
2756
+ duty_id="translator",
2757
+ )
2758
+ projections = {
2759
+ provider: _legacy_projection_from_plan(provider, plan, context)
2760
+ for provider in ("claude", "codex", "antigravity")
2761
+ }
2762
+ invocation_assignments = _build_invocation_assignments(
2763
+ lead_assignment,
2764
+ workers,
2765
+ critic_assignment,
2766
+ _role_assignment(resolved=translator, role="translator"),
2767
+ )
2768
+ worker_by_provider = {row.provider: row for row in workers}
2769
+ return _ModelBindings(
2770
+ lead=lead,
2771
+ cw=projections["claude"],
2772
+ co=projections["codex"],
2773
+ ge=projections["antigravity"],
2774
+ rw=report_writer,
2775
+ critic_choice=(critic_rows[0].provider_id if critic_rows else "off"),
2776
+ critic_model_execution=(
2777
+ critic_assignment.model_execution_value if critic_assignment else ""
2778
+ ),
2779
+ executor_display_name=(
2780
+ f"{executor_rows[0].display_name} executor" if executor_rows else ""
2781
+ ),
2782
+ codex_worker_execution=(
2783
+ worker_by_provider["codex"].model_execution_value
2784
+ if "codex" in worker_by_provider
2785
+ else projections["codex"].execution
2786
+ ),
2787
+ antigravity_worker_execution=(
2788
+ worker_by_provider["antigravity"].model_execution_value
2789
+ if "antigravity" in worker_by_provider
2790
+ else projections["antigravity"].execution
2791
+ ),
2792
+ executor_assignment=executor_assignment,
2793
+ lead_assignment=lead_assignment,
2794
+ worker_assignments=workers,
2795
+ invocation_assignments=invocation_assignments,
2796
+ )
2797
+
2798
+
2799
+ def _legacy_projection_from_plan(
2800
+ provider: str,
2801
+ plan: AssignmentPlan,
2802
+ context: AssignmentContext,
2803
+ ) -> _LegacyModelProjection:
2804
+ selected = next(
2805
+ (
2806
+ row for row in plan.assignments
2807
+ if row.provider_id == provider and row.role != "leader"
2808
+ ),
2809
+ None,
2810
+ )
2811
+ if selected is not None:
2812
+ return _LegacyModelProjection(
2813
+ selected.model_id,
2814
+ selected.display_name,
2815
+ selected.execution,
2816
+ )
2817
+ model = resolve_model_selection(context.pool, provider, "analyser", "")
2818
+ return _LegacyModelProjection(
2819
+ model.model_ref.model_id,
2820
+ model.display_name,
2821
+ model.execution_value,
2822
+ )
2823
+
2824
+
2825
+ def _selected_execution_provider_ids(
2826
+ inp: PrepareInputs,
2827
+ workers: list[str],
2828
+ requires_executor: bool,
2829
+ ) -> tuple[str, ...]:
2830
+ selected = [
2831
+ inp.lead_provider,
2832
+ *(worker for worker in workers if worker != "report-writer"),
2833
+ inp.report_writer_provider.strip().lower() or "claude",
2834
+ ]
2835
+ critic = inp.critic.strip().lower()
2836
+ if critic not in {"", "off"}:
2837
+ selected.append(critic)
2838
+ if requires_executor:
2839
+ selected.append(
2840
+ inp.executor.strip().lower()
2841
+ or _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2842
+ )
2843
+ return tuple(dict.fromkeys(selected))
2844
+
2845
+
2846
+ def _resolve_executor_assignment(
2847
+ inp: PrepareInputs,
2848
+ workers: list[str],
2849
+ context: AssignmentContext,
2850
+ worker_model_inputs: dict[str, str],
2851
+ ) -> tuple[RoleAssignment, str]:
2852
+ executor_default = _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2853
+ executor_provider = (inp.executor or executor_default).strip().lower()
2854
+ allowed_executors = provider_ids("executor")
2855
+ if executor_provider not in allowed_executors:
2856
+ raise PrepareError(
2857
+ f"--executor must be one of: {', '.join(allowed_executors)} "
2858
+ f"(got: {executor_provider!r})"
2859
+ )
2860
+ if executor_provider not in workers:
2861
+ raise PrepareError(
2862
+ f"--executor {executor_provider} requires {executor_provider!r} in "
2863
+ f"--workers, but resolved roster is {workers!r}. "
2864
+ f"Add it explicitly, e.g. --workers {','.join(sorted(set(workers + [executor_provider])))}."
2865
+ )
2866
+ model = _model_for_role(
2867
+ context=context,
2868
+ provider=executor_provider,
2869
+ raw_value=worker_model_inputs.get(executor_provider, ""),
2870
+ role="implementer",
2871
+ duty_id="implementation-executor",
2872
+ )
2873
+ assignment = _role_assignment(
2874
+ resolved=model,
2875
+ role="executor",
2876
+ worker_id=executor_provider,
2877
+ )
2878
+ display_name = (
2879
+ f"{context.pool.resolve_provider(executor_provider).display_label} executor"
2880
+ )
2881
+ return assignment, display_name
2882
+
2883
+
2025
2884
  def _build_worker_assignments(
2026
- inp: PrepareInputs, workers: list[str], models: dict,
2885
+ workers: list[str], models: dict,
2027
2886
  ) -> tuple[RoleAssignment, ...]:
2028
2887
  assignments: list[RoleAssignment] = []
2029
2888
  for worker_id in workers:
@@ -2031,18 +2890,12 @@ def _build_worker_assignments(
2031
2890
  provider = (
2032
2891
  models["report_writer_provider"] if is_report_writer else worker_id
2033
2892
  )
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],
2893
+ selected = (
2894
+ models["rw"] if is_report_writer else models["providers"][provider]
2038
2895
  )
2039
2896
  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,
2897
+ resolved=selected,
2898
+ role="report-writer" if is_report_writer else "analyser",
2046
2899
  worker_id=worker_id,
2047
2900
  ))
2048
2901
  return tuple(assignments)
@@ -2050,55 +2903,40 @@ def _build_worker_assignments(
2050
2903
 
2051
2904
  def _role_assignment(
2052
2905
  *,
2053
- host_runtime: str,
2906
+ resolved: ResolvedAssignment,
2054
2907
  role: str,
2055
- provider: str,
2056
- model: ModelAssignment,
2057
- execution: str,
2058
- runner_role: str,
2059
2908
  worker_id: str = "",
2060
2909
  ) -> 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
- )
2910
+ binding = resolved.binding
2911
+ if binding is None:
2912
+ if resolved.role != "leader" or resolved.entry_mode != "current-session":
2913
+ raise PrepareError("legacy assignment projection requires a model binding")
2914
+ return RoleAssignment(
2915
+ role=role,
2916
+ provider=resolved.provider_id,
2917
+ model_display=resolved.display_name,
2918
+ model_execution_value="unknown",
2919
+ runner="cli-wrapper",
2920
+ host_runtime=resolved.host_runtime,
2921
+ host_model_value=None,
2922
+ worker_id=worker_id,
2923
+ )
2069
2924
  return RoleAssignment(
2070
2925
  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,
2926
+ provider=resolved.provider_id,
2927
+ model_display=resolved.display_name,
2928
+ model_execution_value=binding.resolved_execution_value,
2929
+ runner=(
2930
+ "native-session"
2931
+ if binding.runner == "current-session"
2932
+ else binding.runner
2933
+ ),
2934
+ host_runtime=resolved.host_runtime,
2935
+ host_model_value=binding.host_model_value,
2077
2936
  worker_id=worker_id,
2078
2937
  )
2079
2938
 
2080
2939
 
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
2940
  def _build_invocation_assignments(
2103
2941
  lead: RoleAssignment,
2104
2942
  workers: tuple[RoleAssignment, ...],
@@ -2120,27 +2958,6 @@ def _build_invocation_assignments(
2120
2958
  return assignments
2121
2959
 
2122
2960
 
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
2961
  StageRunClaim = _implementation_stage.StageRunClaim
2145
2962
 
2146
2963
 
@@ -2529,11 +3346,18 @@ def _render_lead_prompt_and_snapshot(
2529
3346
  assignment = _agent_model_assignment(
2530
3347
  json.loads(ctx["INVOCATION_ASSIGNMENTS_JSON"])["lead"]
2531
3348
  )
3349
+ identity = invocation_execution_identity_from_manifest(
3350
+ json.loads(Path(ctx["RUN_MANIFEST_PATH"]).read_text(encoding="utf-8")),
3351
+ assignment=assignment,
3352
+ assignment_ref="lead",
3353
+ duty_id="lead",
3354
+ )
3355
+ if identity is None:
3356
+ raise PrepareError("lead v2 execution identity is missing")
3357
+ invocation_id = f"{ctx['TASK_TYPE_SEGMENT']}-{ctx['RUN_PROMPTS_SEQ']}-lead"
2532
3358
  prepared = prepare_agent_invocation(AgentInvocationRequest(
2533
- invocation_id=(
2534
- f"{ctx['TASK_TYPE_SEGMENT']}-{ctx['RUN_PROMPTS_SEQ']}-lead"
2535
- ),
2536
- worker_id="lead",
3359
+ invocation_id=invocation_id,
3360
+ worker_id=None,
2537
3361
  audience="lead",
2538
3362
  assignment_ref="lead",
2539
3363
  purpose=None,
@@ -2557,6 +3381,11 @@ def _render_lead_prompt_and_snapshot(
2557
3381
  prompt_path=Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]),
2558
3382
  metadata_path=Path(ctx["LEAD_PROMPT_METADATA_PATH"]),
2559
3383
  dispatch_kind="lead",
3384
+ participant_ref=identity.participant_ref,
3385
+ role_execution_ref=identity.role_execution_ref,
3386
+ duty_id=identity.duty_id,
3387
+ invocation_ref=invocation_id,
3388
+ attempt=1,
2560
3389
  ))
2561
3390
  return prepared.prompt_path.read_text(encoding="utf-8")
2562
3391
 
@@ -2581,12 +3410,7 @@ def _persist_run_inputs(
2581
3410
  if inp.approved_plan_path
2582
3411
  else ""
2583
3412
  )
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={
3413
+ inputs = {
2590
3414
  "taskBriefPath": brief_relative,
2591
3415
  "taskBriefAbsolutePath": str(inp.brief_path),
2592
3416
  "directive": inp.directive,
@@ -2608,7 +3432,11 @@ def _persist_run_inputs(
2608
3432
  inp.report_writer_provider or "claude",
2609
3433
  ),
2610
3434
  "reportWriterModel": models.rw.display,
2611
- "executor": models.executor_assignment.provider,
3435
+ "executor": (
3436
+ models.executor_assignment.provider
3437
+ if models.executor_assignment is not None
3438
+ else ""
3439
+ ),
2612
3440
  "relatedTasks": inp.related_tasks_raw,
2613
3441
  "approvedPlanPath": approved_plan_path,
2614
3442
  "clarificationResponsePath": inp.clarification_response_path,
@@ -2618,7 +3446,13 @@ def _persist_run_inputs(
2618
3446
  ),
2619
3447
  "evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
2620
3448
  "renderOnly": inp.render_only,
2621
- },
3449
+ }
3450
+ run_inputs_path = _write_run_inputs_once(
3451
+ project_root=Path(inp.project_root),
3452
+ run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
3453
+ task_type_segment=ctx["TASK_TYPE_SEGMENT"],
3454
+ seq=ctx["RUN_MANIFESTS_SEQ"],
3455
+ inputs=inputs,
2622
3456
  )
2623
3457
  ctx["RUN_INPUTS_PATH"] = str(run_inputs_path)
2624
3458
  ctx["RUN_INPUTS_RELATIVE_PATH"] = relative_to_project_root(
@@ -2626,6 +3460,29 @@ def _persist_run_inputs(
2626
3460
  )
2627
3461
 
2628
3462
 
3463
+ def _write_run_inputs_once(
3464
+ *,
3465
+ project_root: Path,
3466
+ run_manifests_dir: Path,
3467
+ task_type_segment: str,
3468
+ seq: str,
3469
+ inputs: dict,
3470
+ ) -> Path:
3471
+ path = run_manifests_dir / f"run-inputs-{task_type_segment}-{seq}.json"
3472
+ existing = _read_existing_manifest(path)
3473
+ if existing.get("schemaVersion") == "2.0":
3474
+ if existing.get("inputs") != inputs:
3475
+ raise PrepareError("input snapshot is immutable after v2 creation")
3476
+ return path
3477
+ return write_run_inputs(
3478
+ project_root=project_root,
3479
+ run_manifests_dir=run_manifests_dir,
3480
+ task_type_segment=task_type_segment,
3481
+ seq=seq,
3482
+ inputs=inputs,
3483
+ )
3484
+
3485
+
2629
3486
  def _persist_pre_dispatch_run_manifest(ctx: dict) -> None:
2630
3487
  """Publish and re-read the complete invocation authority before prompts."""
2631
3488
  render_run_manifest(ctx["RUN_MANIFEST_PATH"], ctx)
@@ -3023,24 +3880,38 @@ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
3023
3880
  ),
3024
3881
  "",
3025
3882
  ),
3026
- "EXECUTOR_WORKER_ID": models.executor_assignment.worker_id,
3027
- "EXECUTOR_PROVIDER": models.executor_assignment.provider,
3883
+ **_executor_model_ctx(models),
3884
+ "CRITIC_CHOICE": models.critic_choice,
3885
+ "CRITIC_MODEL_EXECUTION_VALUE": models.critic_model_execution,
3886
+ }
3887
+
3888
+
3889
+ def _executor_model_ctx(models: "_ModelBindings") -> dict[str, str]:
3890
+ assignment = models.executor_assignment
3891
+ if assignment is None:
3892
+ return {
3893
+ "EXECUTOR_WORKER_ID": "",
3894
+ "EXECUTOR_PROVIDER": "",
3895
+ "EXECUTOR_DISPLAY_NAME": "",
3896
+ "EXECUTOR_MODEL_DISPLAY": "",
3897
+ "EXECUTOR_MODEL_EXECUTION_VALUE": "",
3898
+ "EXECUTOR_HOST_MODEL_VALUE": "",
3899
+ "EXECUTOR_RUNNER": "",
3900
+ "EXECUTOR_DISPATCH_MODE": "",
3901
+ }
3902
+ return {
3903
+ "EXECUTOR_WORKER_ID": assignment.worker_id,
3904
+ "EXECUTOR_PROVIDER": assignment.provider,
3028
3905
  "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,
3906
+ "EXECUTOR_MODEL_DISPLAY": assignment.model_display,
3907
+ "EXECUTOR_MODEL_EXECUTION_VALUE": assignment.model_execution_value,
3908
+ "EXECUTOR_HOST_MODEL_VALUE": assignment.host_model_value or "",
3909
+ "EXECUTOR_RUNNER": assignment.runner,
3037
3910
  "EXECUTOR_DISPATCH_MODE": (
3038
3911
  "host-native"
3039
- if models.executor_assignment.runner == "native-session"
3912
+ if assignment.runner == "native-session"
3040
3913
  else "worker-dispatch"
3041
3914
  ),
3042
- "CRITIC_CHOICE": models.critic_choice,
3043
- "CRITIC_MODEL_EXECUTION_VALUE": models.critic_model_execution,
3044
3915
  }
3045
3916
 
3046
3917
 
@@ -3147,6 +4018,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3147
4018
  # 조립 코드가 단일 흐름으로 읽히도록 유지한다.
3148
4019
  assets = _resolve_runtime_assets(workspace_root, inp)
3149
4020
  profile_file = assets.profile_file
4021
+ requires_executor = _profile_requires_executor(profile_file)
4022
+ selection, role_profile = _normalize_prepare_model_selection(inp, profile_file)
3150
4023
  prompt_template = assets.prompt_template
3151
4024
  task_index_template = assets.task_index_template
3152
4025
  final_report_template = assets.final_report_template
@@ -3168,13 +4041,45 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3168
4041
  handoff_tokens = _materialize_release_handoff_input(
3169
4042
  workspace_root, project_root, inp)
3170
4043
 
3171
- verify_installation(workspace_root)
3172
- _register_and_check_project(project_root, inp)
3173
-
3174
4044
  workers, selected_reviewers = _resolve_roster(inp, profile_file)
3175
4045
  pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
3176
4046
 
3177
- models = _resolve_model_bindings(inp, workers)
4047
+ assignment_context, assignment_plan = _resolve_prepare_assignments(
4048
+ inp=inp,
4049
+ profile=role_profile,
4050
+ selection=selection,
4051
+ workers=workers,
4052
+ lead_runtime=lead_runtime,
4053
+ terminal_backend=terminal_backend,
4054
+ requires_executor=requires_executor,
4055
+ )
4056
+ if _uses_canonical_assignment_projection(inp):
4057
+ workers, models = _project_assignment_plan(
4058
+ inp,
4059
+ assignment_plan,
4060
+ assignment_context,
4061
+ )
4062
+ selected_reviewers = ",".join(workers)
4063
+ else:
4064
+ models = _resolve_model_bindings(
4065
+ inp,
4066
+ workers,
4067
+ assignment_context,
4068
+ requires_executor,
4069
+ )
4070
+ execution_manifest = _build_static_execution_manifest(
4071
+ assignment_plan,
4072
+ assignment_context,
4073
+ models.lead,
4074
+ )
4075
+ dynamic_roles = tuple(
4076
+ requirement.role
4077
+ for requirement in role_profile.roles
4078
+ if requirement.dynamic
4079
+ )
4080
+
4081
+ verify_installation(workspace_root)
4082
+ _register_and_check_project(project_root, inp)
3178
4083
 
3179
4084
  # ---- paths under per-task mutex (writes run-context-*.json) ----
3180
4085
  # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
@@ -3327,6 +4232,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3327
4232
  "LATEST_REPORT_RELATIVE_PATH": "",
3328
4233
  "RENDER_ONLY": "true" if inp.render_only else "false",
3329
4234
  "OKSTRA_VERSION": installed_version(),
4235
+ "EXECUTION_IDENTITY_JSON": json.dumps(
4236
+ execution_manifest.to_payload(), ensure_ascii=False,
4237
+ ),
4238
+ "DYNAMIC_EXECUTION_ROLES_JSON": json.dumps(
4239
+ dynamic_roles, ensure_ascii=False,
4240
+ ),
3330
4241
  })
3331
4242
  refresh_run_context_snapshot(ctx)
3332
4243
  if lead_runtime == "codex":
@@ -3366,6 +4277,15 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3366
4277
  _finalize_status_and_render_manifests(
3367
4278
  inp, ctx, task_index_template, forbidden_by_phase
3368
4279
  )
4280
+ persist_static_execution_identity(
4281
+ run_manifest_path=Path(ctx["RUN_MANIFEST_PATH"]),
4282
+ task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
4283
+ input_snapshot_path=Path(ctx["RUN_INPUTS_PATH"]),
4284
+ manifest=execution_manifest,
4285
+ task_key=ctx["TASK_KEY"],
4286
+ run_ref=ctx["RUN_MANIFEST_RELATIVE_PATH"],
4287
+ dynamic_roles=dynamic_roles,
4288
+ )
3369
4289
  _record_artifact_runtime_render_only_event(inp, ctx)
3370
4290
 
3371
4291
  _record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
@@ -3389,10 +4309,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
3389
4309
  )
3390
4310
 
3391
4311
 
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."""
4312
+ def build_prepare_argument_parser():
4313
+ """Build the shared flat-argument parser used by public prepare shims."""
3396
4314
  import argparse
3397
4315
 
3398
4316
  p = argparse.ArgumentParser()
@@ -3422,6 +4340,13 @@ def main(argv: list[str]) -> int:
3422
4340
  ),
3423
4341
  )
3424
4342
  p.add_argument("--workers", default="", dest="workers_override")
4343
+ p.add_argument("--role-count", action="append", default=[], dest="role_counts_raw")
4344
+ p.add_argument("--role-model", action="append", default=[], dest="role_models_raw")
4345
+ p.add_argument(
4346
+ "--host-session-context-json",
4347
+ default="",
4348
+ dest="host_session_context_json",
4349
+ )
3425
4350
  p.add_argument("--lead-provider", default="")
3426
4351
  p.add_argument("--lead-model", default="")
3427
4352
  p.add_argument("--claude-model", default="")
@@ -3565,7 +4490,12 @@ def main(argv: list[str]) -> int:
3565
4490
  "`main` / `dev` / `staging` / `preprod` / `prod` or any local ref."
3566
4491
  ),
3567
4492
  )
3568
- args = p.parse_args(argv)
4493
+ return p
4494
+
4495
+
4496
+ def main(argv: list[str]) -> int:
4497
+ """Parse one public prepare invocation and emit its rendered bundle."""
4498
+ args = build_prepare_argument_parser().parse_args(argv)
3569
4499
 
3570
4500
  project_root = Path(args.project_root).expanduser().resolve()
3571
4501
  if args.task_type == "release-handoff":
@@ -3609,6 +4539,25 @@ def main(argv: list[str]) -> int:
3609
4539
  return 1
3610
4540
  selected_direction_abs = str(selected)
3611
4541
 
4542
+ try:
4543
+ host_session_context = deserialize_host_session_context(
4544
+ args.host_session_context_json
4545
+ )
4546
+ except ModelSelectionInputError as exc:
4547
+ print(str(exc), file=__import__("sys").stderr)
4548
+ return 1
4549
+ if not args.host_session_context_json:
4550
+ adapter = default_host_registry().resolve(args.lead_runtime)
4551
+ host_session_context = HostSessionContext(
4552
+ host_id=adapter.descriptor.id,
4553
+ entry_mode="spawn-process",
4554
+ available_functions=frozenset(),
4555
+ interaction_surface="unavailable",
4556
+ current_model=CurrentSessionModelAttestation.unknown(
4557
+ adapter.descriptor.native_provider_id
4558
+ ),
4559
+ )
4560
+
3612
4561
  inputs = PrepareInputs(
3613
4562
  workspace_root=Path(args.workspace_root).resolve(),
3614
4563
  project_root=project_root,
@@ -3621,6 +4570,9 @@ def main(argv: list[str]) -> int:
3621
4570
  evidence_inputs_raw=args.evidence_inputs_raw,
3622
4571
  directive=args.directive,
3623
4572
  workers_override=args.workers_override,
4573
+ role_counts_raw=tuple(args.role_counts_raw),
4574
+ role_models_raw=tuple(args.role_models_raw),
4575
+ host_session_context=host_session_context,
3624
4576
  lead_provider=args.lead_provider,
3625
4577
  lead_model=args.lead_model,
3626
4578
  claude_model=args.claude_model,