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
@@ -20,6 +20,7 @@ imports this module directly.
20
20
  from __future__ import annotations
21
21
 
22
22
  import copy
23
+ import hashlib
23
24
  import json
24
25
  import math
25
26
  import re
@@ -31,7 +32,16 @@ from typing import Any, Callable, Optional
31
32
 
32
33
  from okstra_ctl.application.advance_wizard import plan_prompt
33
34
  from okstra_ctl.application.resolve_assignment import resolve_lead_provider
35
+ from okstra_ctl.assignment_environment import load_assignment_context
36
+ from okstra_ctl.assignment_resolver import (
37
+ AssignmentContext,
38
+ AssignmentResolutionError,
39
+ RoleInstance,
40
+ resolve_assignments,
41
+ resolve_model_assignment,
42
+ )
34
43
  from okstra_ctl.domain.host import (
44
+ CurrentSessionModelAttestation,
35
45
  HostCapabilityMismatch,
36
46
  HostNotRegistered,
37
47
  HostSessionContext,
@@ -44,8 +54,19 @@ from okstra_ctl.domain.wizard.interaction import (
44
54
  WizardPrompt,
45
55
  normalize_planned_answer,
46
56
  )
57
+ from okstra_ctl.dispatch_state import detect_terminal_backend
47
58
  from okstra_ctl.registry.host_registry import default_host_registry
48
59
  from okstra_ctl.registry.provider_registry import default_provider_registry
60
+ from okstra_ctl.model_defaults import ModelDefaultScopes, default_candidates
61
+ from okstra_ctl.model_pool import ModelPool
62
+ from okstra_ctl import next_phase
63
+ from okstra_ctl.legacy_model_selection import serialize_host_session_context
64
+ from okstra_ctl.role_requirements import (
65
+ RoleProfile,
66
+ RoleProfileError,
67
+ RoleRequirement,
68
+ load_role_profile,
69
+ )
49
70
  from okstra_ctl.ids import slugify_task_segment
50
71
  from okstra_ctl.brief_frontmatter import read_brief_frontmatter
51
72
  from okstra_ctl.analysis_inputs import (
@@ -72,6 +93,7 @@ from okstra_ctl.clarification_items import (
72
93
  sidecar_answers,
73
94
  user_response_sidecars,
74
95
  )
96
+ from okstra_ctl.convergence_store import write_json_atomic
75
97
  from okstra_ctl.incremental_scope import (
76
98
  parse_stage_graph,
77
99
  preview_link_availability_for_report,
@@ -96,8 +118,10 @@ from okstra_ctl.run import (
96
118
  _apply_cli_implementation_option,
97
119
  _extract_frontmatter_block,
98
120
  _load_final_report_data_if_present,
121
+ _assignment_resolution_message,
99
122
  _reject_blocking_plan_body_gate,
100
123
  _set_data_json_approved_true_if_present,
124
+ _model_default_scopes,
101
125
  recommended_role_models,
102
126
  )
103
127
  from okstra_ctl.stage_map import (
@@ -406,6 +430,12 @@ _STEP_TO_GROUP: dict[str, str] = {
406
430
 
407
431
  @dataclass
408
432
  class WizardState:
433
+ # execution identity v2: role-first canonical model selections
434
+ execution_identity_version: int = 1
435
+ role_counts: dict[str, int] = field(default_factory=dict)
436
+ role_models: dict[str, list[str]] = field(default_factory=dict)
437
+ role_selection_order: list[str] = field(default_factory=list)
438
+
409
439
  # bootstrap
410
440
  workspace_root: str = ""
411
441
  project_root: str = ""
@@ -524,11 +554,19 @@ class WizardState:
524
554
  answered: list[str] = field(default_factory=list)
525
555
 
526
556
  def to_json(self) -> dict[str, Any]:
527
- return asdict(self)
557
+ payload = asdict(self)
558
+ payload.pop("execution_identity_version")
559
+ payload["executionIdentityVersion"] = 2
560
+ payload["roleCounts"] = payload.pop("role_counts")
561
+ payload["roleModels"] = payload.pop("role_models")
562
+ payload["roleSelectionOrder"] = payload.pop("role_selection_order")
563
+ for field_name in _V1_PROVIDER_STATE_FIELDS:
564
+ payload.pop(field_name, None)
565
+ return payload
528
566
 
529
567
  @classmethod
530
568
  def from_json(cls, d: dict[str, Any]) -> "WizardState":
531
- return cls(**d)
569
+ return _wizard_state_from_json(d)
532
570
 
533
571
 
534
572
  @dataclass
@@ -550,8 +588,14 @@ class Prompt:
550
588
  # only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
551
589
  questions: list["Prompt"] = field(default_factory=list)
552
590
 
591
+ @property
592
+ def id(self) -> str:
593
+ """Stable public identifier shared by static and role-instance prompts."""
594
+ return self.step
595
+
553
596
  def to_json(self) -> dict[str, Any]:
554
597
  out = {
598
+ "id": self.step,
555
599
  "step": self.step,
556
600
  "kind": self.kind,
557
601
  "label": self.label,
@@ -959,6 +1003,981 @@ def _profile_path(workspace_root: Path, task_type: str) -> Path:
959
1003
  return workspace_root / "prompts" / "profiles" / f"{task_type}.md"
960
1004
 
961
1005
 
1006
+ _V2_STATE_FIELDS = (
1007
+ "roleCounts",
1008
+ "roleModels",
1009
+ "roleSelectionOrder",
1010
+ )
1011
+ _V1_PROVIDER_STATE_FIELDS = (
1012
+ "workers_override",
1013
+ "lead_provider",
1014
+ "lead_model",
1015
+ "claude_model",
1016
+ "codex_model",
1017
+ "antigravity_model",
1018
+ "grok_model",
1019
+ "kimi_model",
1020
+ "report_writer_provider",
1021
+ "report_writer_model",
1022
+ "executor",
1023
+ "critic",
1024
+ )
1025
+ _V1_PROVIDER_STATE_JSON_FIELDS = frozenset({
1026
+ *_V1_PROVIDER_STATE_FIELDS,
1027
+ "workers",
1028
+ "leadProvider",
1029
+ "leadModel",
1030
+ "claudeModel",
1031
+ "codexModel",
1032
+ "antigravityModel",
1033
+ "grokModel",
1034
+ "kimiModel",
1035
+ "reportWriterProvider",
1036
+ "reportWriterModel",
1037
+ })
1038
+
1039
+
1040
+ def _load_role_profile_for_state(state: WizardState) -> RoleProfile:
1041
+ try:
1042
+ return load_role_profile(
1043
+ _profile_path(Path(state.workspace_root), state.task_type)
1044
+ )
1045
+ except RoleProfileError as exc:
1046
+ raise WizardError(str(exc)) from exc
1047
+
1048
+
1049
+ def _role_selection_enabled(state: WizardState) -> bool:
1050
+ if (
1051
+ state.execution_identity_version != 2
1052
+ or not state.workspace_root
1053
+ or not state.task_type
1054
+ ):
1055
+ return False
1056
+ try:
1057
+ load_role_profile(_profile_path(Path(state.workspace_root), state.task_type))
1058
+ except (OSError, RoleProfileError):
1059
+ return False
1060
+ return True
1061
+
1062
+
1063
+ def _legacy_value(payload: dict[str, Any], snake: str, camel: str) -> Any:
1064
+ return payload.get(snake, payload.get(camel, ""))
1065
+
1066
+
1067
+ def _legacy_model_ref(
1068
+ pool: ModelPool,
1069
+ provider: str,
1070
+ role: str,
1071
+ raw_model: Any,
1072
+ ) -> str | None:
1073
+ provider_id = provider.strip().lower()
1074
+ if not provider_id:
1075
+ return None
1076
+ model_value = raw_model.strip() if isinstance(raw_model, str) else ""
1077
+ try:
1078
+ model = (
1079
+ pool.resolve_alias(provider_id, model_value)
1080
+ if model_value
1081
+ else pool.default_candidate(provider_id, role)
1082
+ )
1083
+ except (ValueError, RuntimeError):
1084
+ return None
1085
+ if model is None:
1086
+ return None
1087
+ availability = pool.availability(str(model.model_ref), role)
1088
+ return str(model.model_ref) if availability.available else None
1089
+
1090
+
1091
+ def _legacy_worker_providers(payload: dict[str, Any]) -> list[str]:
1092
+ raw = _legacy_value(payload, "workers_override", "workers")
1093
+ if isinstance(raw, list):
1094
+ values = raw
1095
+ elif isinstance(raw, str):
1096
+ values = raw.split(",")
1097
+ else:
1098
+ values = []
1099
+ return [
1100
+ value.strip().lower()
1101
+ for value in values
1102
+ if isinstance(value, str)
1103
+ and value.strip()
1104
+ and value.strip().lower() != "report-writer"
1105
+ ]
1106
+
1107
+
1108
+ def _initial_cross_verification_requirement(
1109
+ profile: RoleProfile,
1110
+ ) -> RoleRequirement | None:
1111
+ return next((
1112
+ requirement
1113
+ for requirement in profile.roles
1114
+ if not requirement.dynamic
1115
+ and requirement.role in {
1116
+ "analyser",
1117
+ "designer",
1118
+ "planner",
1119
+ "verifier",
1120
+ }
1121
+ ), None)
1122
+
1123
+
1124
+ def _convert_v1_provider_selections(
1125
+ state: WizardState,
1126
+ payload: dict[str, Any],
1127
+ ) -> None:
1128
+ if not _role_selection_enabled(state):
1129
+ return
1130
+ profile = _load_role_profile_for_state(state)
1131
+ pool = ModelPool.from_registry(default_provider_registry())
1132
+ cross_requirement = _initial_cross_verification_requirement(profile)
1133
+ providers = _legacy_worker_providers(payload)
1134
+ if cross_requirement is not None:
1135
+ selected_count = cross_requirement.recommended_count
1136
+ if providers and cross_requirement.min_count < cross_requirement.max_count:
1137
+ selected_count = min(
1138
+ cross_requirement.max_count,
1139
+ max(cross_requirement.min_count, len(providers)),
1140
+ )
1141
+ if cross_requirement.min_count < cross_requirement.max_count:
1142
+ state.role_counts[cross_requirement.role] = selected_count
1143
+ count_id = f"role-count:{cross_requirement.role}"
1144
+ state.role_selection_order.append(count_id)
1145
+ if not providers:
1146
+ providers = [
1147
+ model.provider_id
1148
+ for model in pool.default_candidates(cross_requirement.role)
1149
+ ]
1150
+ for ordinal, provider in enumerate(providers[:selected_count], start=1):
1151
+ raw_model = _legacy_value(
1152
+ payload,
1153
+ f"{provider}_model",
1154
+ f"{provider}Model",
1155
+ )
1156
+ model_ref = _legacy_model_ref(
1157
+ pool,
1158
+ provider,
1159
+ cross_requirement.role,
1160
+ raw_model,
1161
+ )
1162
+ if model_ref is None:
1163
+ break
1164
+ state.role_models.setdefault(cross_requirement.role, []).append(model_ref)
1165
+ state.role_selection_order.append(
1166
+ f"role-model:{cross_requirement.role}:{ordinal}"
1167
+ )
1168
+
1169
+ provider_rows = (
1170
+ (
1171
+ "leader",
1172
+ _legacy_value(payload, "lead_provider", "leadProvider") or "claude",
1173
+ _legacy_value(payload, "lead_model", "leadModel"),
1174
+ ),
1175
+ (
1176
+ "report-writer",
1177
+ _legacy_value(
1178
+ payload,
1179
+ "report_writer_provider",
1180
+ "reportWriterProvider",
1181
+ ) or "claude",
1182
+ _legacy_value(
1183
+ payload,
1184
+ "report_writer_model",
1185
+ "reportWriterModel",
1186
+ ),
1187
+ ),
1188
+ (
1189
+ "implementer",
1190
+ _legacy_value(payload, "executor", "executor") or "claude",
1191
+ _legacy_value(
1192
+ payload,
1193
+ f"{_legacy_value(payload, 'executor', 'executor') or 'claude'}_model",
1194
+ f"{_legacy_value(payload, 'executor', 'executor') or 'claude'}Model",
1195
+ ),
1196
+ ),
1197
+ (
1198
+ "critic",
1199
+ _legacy_value(payload, "critic", "critic"),
1200
+ _legacy_value(
1201
+ payload,
1202
+ f"{_legacy_value(payload, 'critic', 'critic')}_model",
1203
+ f"{_legacy_value(payload, 'critic', 'critic')}Model",
1204
+ ),
1205
+ ),
1206
+ )
1207
+ static_roles = {
1208
+ requirement.role
1209
+ for requirement in profile.roles
1210
+ if not requirement.dynamic and requirement.min_count > 0
1211
+ }
1212
+ if state.host_entry_mode == "spawn-process":
1213
+ static_roles.add("leader")
1214
+ for role, provider, raw_model in provider_rows:
1215
+ if role not in static_roles or not isinstance(provider, str) or not provider:
1216
+ continue
1217
+ model_ref = _legacy_model_ref(pool, provider, role, raw_model)
1218
+ if model_ref is None:
1219
+ continue
1220
+ state.role_models[role] = [model_ref]
1221
+ state.role_selection_order.append(f"role-model:{role}:1")
1222
+
1223
+
1224
+ def _wizard_state_from_json(payload: dict[str, Any]) -> WizardState:
1225
+ if not isinstance(payload, dict):
1226
+ raise WizardError("wizard state must be a JSON object")
1227
+ data = dict(payload)
1228
+ version = data.pop("executionIdentityVersion", None)
1229
+ has_v2_fields = any(field_name in data for field_name in _V2_STATE_FIELDS)
1230
+ has_v1_fields = any(
1231
+ field_name in data for field_name in _V1_PROVIDER_STATE_JSON_FIELDS
1232
+ )
1233
+ if version == 2:
1234
+ if has_v1_fields or not all(field_name in data for field_name in _V2_STATE_FIELDS):
1235
+ raise WizardError("mixed wizard state versions are not allowed")
1236
+ data["role_counts"] = data.pop("roleCounts")
1237
+ data["role_models"] = data.pop("roleModels")
1238
+ data["role_selection_order"] = data.pop("roleSelectionOrder")
1239
+ data["execution_identity_version"] = 2
1240
+ _validate_v2_state_fields(data)
1241
+ elif version in (None, 1):
1242
+ if has_v2_fields:
1243
+ raise WizardError("mixed wizard state versions are not allowed")
1244
+ data.pop("execution_identity_version", None)
1245
+ else:
1246
+ raise WizardError(f"unsupported wizard state version: {version!r}")
1247
+ allowed = WizardState.__dataclass_fields__.keys()
1248
+ state = WizardState(**{key: value for key, value in data.items() if key in allowed})
1249
+ if version != 2:
1250
+ state.execution_identity_version = 2
1251
+ _convert_v1_provider_selections(state, payload)
1252
+ # v1 은 리더 확인 칸이 없었다. 변환 재개 시 다시 묻지 않는다.
1253
+ if (
1254
+ state.host_entry_mode == "current-session"
1255
+ and _LEADER_SESSION_STEP not in state.answered
1256
+ ):
1257
+ state.answered.append(_LEADER_SESSION_STEP)
1258
+ if (
1259
+ state.host_entry_mode == "current-session"
1260
+ and _LEADER_SESSION_STEP not in state.role_selection_order
1261
+ ):
1262
+ state.role_selection_order.insert(0, _LEADER_SESSION_STEP)
1263
+ return state
1264
+
1265
+
1266
+ def _validate_v2_state_fields(data: dict[str, Any]) -> None:
1267
+ role_counts = data.get("role_counts")
1268
+ role_models = data.get("role_models")
1269
+ selection_order = data.get("role_selection_order")
1270
+ if not isinstance(role_counts, dict) or not all(
1271
+ isinstance(role, str)
1272
+ and isinstance(count, int)
1273
+ and not isinstance(count, bool)
1274
+ for role, count in role_counts.items()
1275
+ ):
1276
+ raise WizardError("wizard state roleCounts must map roles to integers")
1277
+ if not isinstance(role_models, dict) or not all(
1278
+ isinstance(role, str)
1279
+ and isinstance(models, list)
1280
+ and all(isinstance(model_ref, str) for model_ref in models)
1281
+ for role, models in role_models.items()
1282
+ ):
1283
+ raise WizardError("wizard state roleModels must map roles to modelRef arrays")
1284
+ if not isinstance(selection_order, list) or not all(
1285
+ isinstance(step_id, str) for step_id in selection_order
1286
+ ):
1287
+ raise WizardError("wizard state roleSelectionOrder must be a string array")
1288
+
1289
+
1290
+ def _role_count_prompt_id(role: str) -> str:
1291
+ return f"role-count:{role}"
1292
+
1293
+
1294
+ def _role_add_prompt_id(role: str) -> str:
1295
+ return f"role-add:{role}"
1296
+
1297
+
1298
+ def _role_model_prompt_id(role: str, ordinal: int) -> str:
1299
+ return f"role-model:{role}:{ordinal}"
1300
+
1301
+
1302
+ _LEADER_SESSION_STEP = "leader-session"
1303
+
1304
+
1305
+ def _is_role_selection_step(step_id: str) -> bool:
1306
+ return step_id == _LEADER_SESSION_STEP or step_id.startswith(
1307
+ ("role-count:", "role-model:", "role-add:")
1308
+ )
1309
+
1310
+
1311
+ def _selected_role_count(
1312
+ state: WizardState,
1313
+ requirement: RoleRequirement,
1314
+ ) -> int:
1315
+ if requirement.min_count == requirement.max_count:
1316
+ return requirement.min_count
1317
+ if requirement.role in state.role_counts:
1318
+ return state.role_counts[requirement.role]
1319
+ # min=0 선택 역할(예: critic)은 사용자가 열기 전까지 기본 0
1320
+ if requirement.min_count == 0:
1321
+ return 0
1322
+ return requirement.recommended_count
1323
+
1324
+
1325
+ def _selectable_static_requirements(
1326
+ state: WizardState,
1327
+ profile: RoleProfile,
1328
+ ) -> tuple[RoleRequirement, ...]:
1329
+ requirements: list[RoleRequirement] = []
1330
+ if state.host_entry_mode == "spawn-process":
1331
+ requirements.append(RoleRequirement("leader", 1, 1, 1, "lead"))
1332
+ requirements.extend(
1333
+ requirement
1334
+ for requirement in profile.roles
1335
+ if not requirement.dynamic
1336
+ and _selected_role_count(state, requirement) > 0
1337
+ )
1338
+ return tuple(requirements)
1339
+
1340
+
1341
+ def _leader_session_prompt(state: WizardState) -> Prompt:
1342
+ """current-session 리더 칸: 모델/effort 표시만 하고 role_models 에 쓰지 않는다."""
1343
+ attestation = _host_session_context(state).current_model
1344
+ model_ref = (
1345
+ attestation.normalized_model_ref
1346
+ or attestation.observed_model
1347
+ or "current-session"
1348
+ )
1349
+ steps = _load_wizard_root(state.workspace_root)["steps"]
1350
+ raw = steps.get("leader_session") or {}
1351
+ effort_suffix = ""
1352
+ if attestation.effort:
1353
+ effort_template = raw.get("effort_suffix", " · effort {effort}")
1354
+ effort_suffix = effort_template.format(effort=attestation.effort)
1355
+ prompt = _p(
1356
+ state.workspace_root,
1357
+ "leader_session",
1358
+ model_ref=model_ref,
1359
+ effort_suffix=effort_suffix,
1360
+ )
1361
+ continue_label = prompt["options"].get("continue", "계속")
1362
+ return Prompt(
1363
+ step=_LEADER_SESSION_STEP,
1364
+ kind="pick",
1365
+ label=prompt["label"],
1366
+ options=[_opt("continue", continue_label)],
1367
+ echo_template=prompt["echo_template"],
1368
+ )
1369
+
1370
+
1371
+ def _count_prompt(
1372
+ state: WizardState,
1373
+ requirement: RoleRequirement,
1374
+ ) -> Prompt:
1375
+ prompt = _p(
1376
+ state.workspace_root,
1377
+ "role_count",
1378
+ role=requirement.role,
1379
+ minimum=str(requirement.min_count),
1380
+ maximum=str(requirement.max_count),
1381
+ default=str(requirement.recommended_count),
1382
+ )
1383
+ return Prompt(
1384
+ step=_role_count_prompt_id(requirement.role),
1385
+ kind="pick",
1386
+ label=prompt["label"],
1387
+ options=[
1388
+ _opt(
1389
+ str(count),
1390
+ prompt["options"]["count"].format(
1391
+ count=count,
1392
+ default_suffix=(
1393
+ prompt["options"].get("default_suffix", "")
1394
+ if count == requirement.recommended_count
1395
+ else ""
1396
+ ),
1397
+ ),
1398
+ )
1399
+ for count in range(requirement.min_count, requirement.max_count + 1)
1400
+ ],
1401
+ echo_template=prompt["echo_template"],
1402
+ )
1403
+
1404
+
1405
+ def _role_add_prompt(
1406
+ state: WizardState,
1407
+ requirement: RoleRequirement,
1408
+ ) -> Prompt:
1409
+ """min=0 선택 역할: 기본은 추가 안 함, 추가 시 1..max 수량을 이 스텝에서 고른다."""
1410
+ prompt = _p(
1411
+ state.workspace_root,
1412
+ "role_add",
1413
+ role=requirement.role,
1414
+ maximum=str(requirement.max_count),
1415
+ )
1416
+ options = [
1417
+ _opt(
1418
+ "0",
1419
+ prompt["options"]["skip"].format(
1420
+ default_suffix=prompt["options"].get("default_suffix", ""),
1421
+ ),
1422
+ ),
1423
+ ]
1424
+ for count in range(1, requirement.max_count + 1):
1425
+ options.append(
1426
+ _opt(
1427
+ str(count),
1428
+ prompt["options"]["add"].format(count=count),
1429
+ )
1430
+ )
1431
+ return Prompt(
1432
+ step=_role_add_prompt_id(requirement.role),
1433
+ kind="pick",
1434
+ label=prompt["label"],
1435
+ options=options,
1436
+ echo_template=prompt["echo_template"],
1437
+ )
1438
+
1439
+
1440
+ def _role_default_candidates(
1441
+ state: WizardState,
1442
+ role: str,
1443
+ pool: ModelPool,
1444
+ scopes: ModelDefaultScopes | None = None,
1445
+ ) -> tuple[str, ...]:
1446
+ try:
1447
+ selected_scopes = scopes or _model_default_scopes(Path(state.project_root))
1448
+ scoped = default_candidates(role, selected_scopes)
1449
+ except (PrepareError, ValueError) as exc:
1450
+ raise WizardError(str(exc)) from exc
1451
+ if scoped:
1452
+ for model_ref in scoped:
1453
+ availability = pool.availability(
1454
+ model_ref,
1455
+ role,
1456
+ state.host_runtime,
1457
+ "new-session",
1458
+ )
1459
+ if not availability.available:
1460
+ raise WizardError(
1461
+ f"configured default model {model_ref!r} is unavailable for "
1462
+ f"role {role!r}: {availability.reason}"
1463
+ )
1464
+ return tuple(scoped)
1465
+ return tuple(
1466
+ str(model.model_ref) for model in pool.default_candidates(role)
1467
+ )
1468
+
1469
+
1470
+ def _role_model_options(
1471
+ state: WizardState,
1472
+ profile: RoleProfile,
1473
+ role: str,
1474
+ ordinal: int,
1475
+ context: AssignmentContext,
1476
+ scopes: ModelDefaultScopes,
1477
+ ) -> list[Option]:
1478
+ pool = context.pool
1479
+ defaults = _role_default_candidates(state, role, pool, scopes)
1480
+ if defaults:
1481
+ offset = (ordinal - 1) % len(defaults)
1482
+ defaults = (*defaults[offset:], *defaults[:offset])
1483
+ candidate_refs = [
1484
+ *defaults,
1485
+ *(str(model.model_ref) for model in pool.list(role=role)),
1486
+ ]
1487
+ ordered_refs = tuple(dict.fromkeys(candidate_refs))
1488
+ default_refs = frozenset(defaults)
1489
+ prompt = _p(
1490
+ state.workspace_root,
1491
+ "role_model",
1492
+ role=role,
1493
+ ordinal="1",
1494
+ count="1",
1495
+ )
1496
+ options: list[Option] = []
1497
+ for model_ref in ordered_refs:
1498
+ availability = pool.availability(
1499
+ model_ref,
1500
+ role,
1501
+ state.host_runtime,
1502
+ "new-session",
1503
+ )
1504
+ if not availability.available:
1505
+ continue
1506
+ candidate_models = {
1507
+ selected_role: tuple(models)
1508
+ for selected_role, models in state.role_models.items()
1509
+ }
1510
+ selected = list(candidate_models.get(role, ()))[: ordinal - 1]
1511
+ selected.append(model_ref)
1512
+ candidate_models[role] = tuple(selected)
1513
+ if not _role_selection_can_complete(
1514
+ state,
1515
+ profile,
1516
+ context,
1517
+ candidate_models,
1518
+ scopes,
1519
+ ):
1520
+ continue
1521
+ model = pool.resolve(model_ref)
1522
+ options.append(_opt(
1523
+ model_ref,
1524
+ prompt["options"]["model"].format(
1525
+ model_ref=model_ref,
1526
+ display=model.display_name,
1527
+ default_suffix=(
1528
+ prompt["options"].get("default_suffix", "")
1529
+ if model_ref in default_refs
1530
+ else ""
1531
+ ),
1532
+ ),
1533
+ ))
1534
+ if not options:
1535
+ _validate_role_selection_feasibility(state, profile, context, scopes)
1536
+ raise WizardError(f"role {role!r} has no executable model candidates")
1537
+ return options
1538
+
1539
+
1540
+ def _model_prompt(
1541
+ state: WizardState,
1542
+ profile: RoleProfile,
1543
+ requirement: RoleRequirement,
1544
+ ordinal: int,
1545
+ context: AssignmentContext,
1546
+ scopes: ModelDefaultScopes,
1547
+ ) -> Prompt:
1548
+ prompt = _p(
1549
+ state.workspace_root,
1550
+ "role_model",
1551
+ role=requirement.role,
1552
+ ordinal=str(ordinal),
1553
+ count=str(_selected_role_count(state, requirement)),
1554
+ )
1555
+ return Prompt(
1556
+ step=_role_model_prompt_id(requirement.role, ordinal),
1557
+ kind="pick",
1558
+ label=prompt["label"],
1559
+ options=_role_model_options(
1560
+ state,
1561
+ profile,
1562
+ requirement.role,
1563
+ ordinal,
1564
+ context,
1565
+ scopes,
1566
+ ),
1567
+ echo_template=prompt["echo_template"],
1568
+ )
1569
+
1570
+
1571
+ def _rewind_invalid_role_models(
1572
+ state: WizardState,
1573
+ requirements: tuple[RoleRequirement, ...],
1574
+ requirement_index: int,
1575
+ model_index: int,
1576
+ ) -> None:
1577
+ requirement = requirements[requirement_index]
1578
+ del state.role_models[requirement.role][model_index:]
1579
+ invalid_ids: set[str] = set()
1580
+ for later_index, later_requirement in enumerate(
1581
+ requirements[requirement_index:],
1582
+ start=requirement_index,
1583
+ ):
1584
+ first_ordinal = model_index + 1 if later_index == requirement_index else 1
1585
+ later_count = _selected_role_count(state, later_requirement)
1586
+ invalid_ids.update(
1587
+ _role_model_prompt_id(later_requirement.role, ordinal)
1588
+ for ordinal in range(first_ordinal, later_count + 1)
1589
+ )
1590
+ if later_index != requirement_index:
1591
+ state.role_models.pop(later_requirement.role, None)
1592
+ state.role_selection_order = [
1593
+ step_id for step_id in state.role_selection_order
1594
+ if step_id not in invalid_ids
1595
+ ]
1596
+ state.answered = [
1597
+ step_id for step_id in state.answered if step_id not in invalid_ids
1598
+ ]
1599
+
1600
+
1601
+ def _discard_invalid_previous_models(
1602
+ state: WizardState,
1603
+ profile: RoleProfile,
1604
+ requirements: tuple[RoleRequirement, ...],
1605
+ context: AssignmentContext,
1606
+ scopes: ModelDefaultScopes,
1607
+ ) -> None:
1608
+ selected_models = {
1609
+ role: tuple(models) for role, models in state.role_models.items()
1610
+ }
1611
+ if _role_selection_error(
1612
+ state,
1613
+ profile,
1614
+ context,
1615
+ selected_models,
1616
+ scopes,
1617
+ ) is None:
1618
+ return
1619
+ selected_prefix: dict[str, tuple[str, ...]] = {}
1620
+ for requirement_index, requirement in enumerate(requirements):
1621
+ count = _selected_role_count(state, requirement)
1622
+ selected = state.role_models.get(requirement.role, [])
1623
+ if len(selected) > count:
1624
+ raise WizardError(
1625
+ f"role {requirement.role!r} has {len(selected)} models for "
1626
+ f"{count} instances"
1627
+ )
1628
+ for index, model_ref in enumerate(selected):
1629
+ candidate_prefix = dict(selected_prefix)
1630
+ candidate_prefix[requirement.role] = tuple(selected[: index + 1])
1631
+ if _role_selection_can_complete(
1632
+ state,
1633
+ profile,
1634
+ context,
1635
+ candidate_prefix,
1636
+ scopes,
1637
+ ):
1638
+ continue
1639
+ _rewind_invalid_role_models(
1640
+ state,
1641
+ requirements,
1642
+ requirement_index,
1643
+ index,
1644
+ )
1645
+ return
1646
+ if selected:
1647
+ selected_prefix[requirement.role] = tuple(selected)
1648
+
1649
+
1650
+ def _host_session_context(state: WizardState) -> HostSessionContext:
1651
+ native_provider = default_host_registry().resolve(
1652
+ state.host_runtime
1653
+ ).descriptor.native_provider_id
1654
+ return HostSessionContext(
1655
+ host_id=state.host_runtime,
1656
+ entry_mode=state.host_entry_mode,
1657
+ available_functions=frozenset(state.available_functions),
1658
+ interaction_surface="wizard",
1659
+ current_model=CurrentSessionModelAttestation.unknown(native_provider),
1660
+ )
1661
+
1662
+
1663
+ def _role_selection_error(
1664
+ state: WizardState,
1665
+ profile: RoleProfile,
1666
+ context: AssignmentContext,
1667
+ role_models: dict[str, tuple[str, ...]],
1668
+ scopes: ModelDefaultScopes,
1669
+ ) -> str | None:
1670
+ try:
1671
+ resolve_assignments(
1672
+ profile=profile,
1673
+ role_counts=dict(state.role_counts),
1674
+ role_models=role_models,
1675
+ scopes=scopes,
1676
+ pool=context.pool,
1677
+ host=_host_session_context(state),
1678
+ environment=context.environment,
1679
+ )
1680
+ except AssignmentResolutionError as exc:
1681
+ return _assignment_resolution_message(exc)
1682
+ except (PrepareError, ValueError, RuntimeError) as exc:
1683
+ return str(exc)
1684
+ return None
1685
+
1686
+
1687
+ def _executable_role_models(
1688
+ state: WizardState,
1689
+ profile: RoleProfile,
1690
+ requirement: RoleRequirement,
1691
+ context: AssignmentContext,
1692
+ ) -> tuple[str, ...]:
1693
+ host = _host_session_context(state)
1694
+ candidates: list[str] = []
1695
+ instance = RoleInstance(requirement.role, requirement.duty, 1)
1696
+ for model in context.pool.list(role=requirement.role):
1697
+ model_ref = str(model.model_ref)
1698
+ try:
1699
+ resolve_model_assignment(
1700
+ instance=instance,
1701
+ model_ref=model_ref,
1702
+ pool=context.pool,
1703
+ host=host,
1704
+ environment=context.environment,
1705
+ )
1706
+ except (AssignmentResolutionError, ValueError, RuntimeError):
1707
+ continue
1708
+ candidates.append(model_ref)
1709
+ return tuple(candidates)
1710
+
1711
+
1712
+ def _completed_role_models(
1713
+ state: WizardState,
1714
+ profile: RoleProfile,
1715
+ context: AssignmentContext,
1716
+ role_models: dict[str, tuple[str, ...]],
1717
+ ) -> dict[str, tuple[str, ...]] | None:
1718
+ completed = {role: list(models) for role, models in role_models.items()}
1719
+ for requirement in _selectable_static_requirements(state, profile):
1720
+ count = _selected_role_count(state, requirement)
1721
+ selected = completed.setdefault(requirement.role, [])
1722
+ if len(selected) > count:
1723
+ return None
1724
+ candidates = _executable_role_models(state, profile, requirement, context)
1725
+ if not candidates or any(model not in candidates for model in selected):
1726
+ return None
1727
+ # 같은 역할 패널의 model_ref 는 서로 달라야 한다.
1728
+ if len(set(selected)) < len(selected):
1729
+ return None
1730
+ used = set(selected)
1731
+ remaining = [model for model in candidates if model not in used]
1732
+ if len(remaining) < count - len(selected):
1733
+ return None
1734
+ while len(selected) < count:
1735
+ model_ref = next(
1736
+ model for model in candidates if model not in used
1737
+ )
1738
+ selected.append(model_ref)
1739
+ used.add(model_ref)
1740
+ return {role: tuple(models) for role, models in completed.items()}
1741
+
1742
+
1743
+ def _role_selection_can_complete(
1744
+ state: WizardState,
1745
+ profile: RoleProfile,
1746
+ context: AssignmentContext,
1747
+ role_models: dict[str, tuple[str, ...]],
1748
+ scopes: ModelDefaultScopes,
1749
+ ) -> bool:
1750
+ completed = _completed_role_models(state, profile, context, role_models)
1751
+ return completed is not None and _role_selection_error(
1752
+ state,
1753
+ profile,
1754
+ context,
1755
+ completed,
1756
+ scopes,
1757
+ ) is None
1758
+
1759
+
1760
+ def _validate_role_selection_feasibility(
1761
+ state: WizardState,
1762
+ profile: RoleProfile,
1763
+ context: AssignmentContext | None = None,
1764
+ scopes: ModelDefaultScopes | None = None,
1765
+ ) -> None:
1766
+ assignment_context = context or load_assignment_context(
1767
+ host_runtime=state.host_runtime,
1768
+ terminal_backend=detect_terminal_backend(),
1769
+ )
1770
+ selected_scopes = scopes or _model_default_scopes(Path(state.project_root))
1771
+ error = _role_selection_error(
1772
+ state,
1773
+ profile,
1774
+ assignment_context,
1775
+ {
1776
+ role: tuple(models) for role, models in state.role_models.items()
1777
+ },
1778
+ selected_scopes,
1779
+ )
1780
+ if error is not None:
1781
+ raise WizardError(error)
1782
+
1783
+
1784
+ def next_role_prompt(state: WizardState) -> Prompt | None:
1785
+ """Ask every adjustable count before one model question per role instance."""
1786
+ if not _role_selection_enabled(state) or not _identity_ready(state):
1787
+ return None
1788
+ state.use_defaults = False
1789
+ # current-session: 리더는 읽기 전용 확인 칸만 먼저 보여 준다.
1790
+ if (
1791
+ state.host_entry_mode == "current-session"
1792
+ and _LEADER_SESSION_STEP not in state.answered
1793
+ and _LEADER_SESSION_STEP not in state.role_selection_order
1794
+ ):
1795
+ return _leader_session_prompt(state)
1796
+ profile = _load_role_profile_for_state(state)
1797
+ for requirement in profile.roles:
1798
+ # 필수 수량: min < max 이고 min > 0 일 때만.
1799
+ if (
1800
+ requirement.dynamic
1801
+ or requirement.min_count == requirement.max_count
1802
+ or requirement.min_count == 0
1803
+ ):
1804
+ continue
1805
+ if requirement.role not in state.role_counts:
1806
+ return _count_prompt(state, requirement)
1807
+ count = state.role_counts[requirement.role]
1808
+ if count < requirement.min_count or count > requirement.max_count:
1809
+ raise WizardError(
1810
+ f"role {requirement.role!r} count must be in "
1811
+ f"{requirement.min_count}..{requirement.max_count}: {count}"
1812
+ )
1813
+ for requirement in profile.roles:
1814
+ # 선택 역할(min=0, max>0): 기본은 추가 안 함. role-add 로만 연다.
1815
+ if (
1816
+ requirement.dynamic
1817
+ or requirement.min_count != 0
1818
+ or requirement.max_count == 0
1819
+ ):
1820
+ continue
1821
+ if requirement.role not in state.role_counts:
1822
+ return _role_add_prompt(state, requirement)
1823
+ count = state.role_counts[requirement.role]
1824
+ if count < 0 or count > requirement.max_count:
1825
+ raise WizardError(
1826
+ f"role {requirement.role!r} count must be in "
1827
+ f"0..{requirement.max_count}: {count}"
1828
+ )
1829
+ requirements = _selectable_static_requirements(state, profile)
1830
+ try:
1831
+ scopes = _model_default_scopes(Path(state.project_root))
1832
+ except (PrepareError, ValueError) as exc:
1833
+ raise WizardError(str(exc)) from exc
1834
+ context = load_assignment_context(
1835
+ host_runtime=state.host_runtime,
1836
+ terminal_backend=detect_terminal_backend(),
1837
+ )
1838
+ _discard_invalid_previous_models(
1839
+ state,
1840
+ profile,
1841
+ requirements,
1842
+ context,
1843
+ scopes,
1844
+ )
1845
+ for requirement in requirements:
1846
+ count = _selected_role_count(state, requirement)
1847
+ selected = state.role_models.get(requirement.role, [])
1848
+ if len(selected) < count:
1849
+ return _model_prompt(
1850
+ state,
1851
+ profile,
1852
+ requirement,
1853
+ len(selected) + 1,
1854
+ context,
1855
+ scopes,
1856
+ )
1857
+ _validate_role_selection_feasibility(state, profile, context, scopes)
1858
+ return None
1859
+
1860
+
1861
+ def _validate_submitted_role_model(
1862
+ state: WizardState,
1863
+ profile: RoleProfile,
1864
+ ) -> None:
1865
+ try:
1866
+ scopes = _model_default_scopes(Path(state.project_root))
1867
+ except (PrepareError, ValueError) as exc:
1868
+ raise WizardError(str(exc)) from exc
1869
+ context = load_assignment_context(
1870
+ host_runtime=state.host_runtime,
1871
+ terminal_backend=detect_terminal_backend(),
1872
+ )
1873
+ role_models = {
1874
+ role: tuple(models) for role, models in state.role_models.items()
1875
+ }
1876
+ requirements = _selectable_static_requirements(state, profile)
1877
+ complete = all(
1878
+ len(role_models.get(requirement.role, ()))
1879
+ == _selected_role_count(state, requirement)
1880
+ for requirement in requirements
1881
+ )
1882
+ if complete:
1883
+ _validate_role_selection_feasibility(state, profile, context, scopes)
1884
+ return
1885
+ if not _role_selection_can_complete(
1886
+ state,
1887
+ profile,
1888
+ context,
1889
+ role_models,
1890
+ scopes,
1891
+ ):
1892
+ raise WizardError("selected role model leaves no complete role assignment")
1893
+
1894
+
1895
+ def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
1896
+ if prompt.step == _LEADER_SESSION_STEP:
1897
+ if value != "continue":
1898
+ raise WizardError("leader session step accepts only 'continue'")
1899
+ # role_models["leader"] 에 쓰지 않는다 — 현재 세션 모델을 그대로 쓴다.
1900
+ return "leader-session: continue"
1901
+ if prompt.step.startswith("role-count:"):
1902
+ role = prompt.step.split(":", 1)[1]
1903
+ profile = _load_role_profile_for_state(state)
1904
+ requirement = next(
1905
+ (row for row in profile.roles if row.role == role),
1906
+ None,
1907
+ )
1908
+ if (
1909
+ requirement is None
1910
+ or requirement.dynamic
1911
+ or requirement.min_count == requirement.max_count
1912
+ or requirement.min_count == 0
1913
+ ):
1914
+ raise WizardError(f"role {role!r} does not accept a count selection")
1915
+ try:
1916
+ count = int(value)
1917
+ except ValueError as exc:
1918
+ raise WizardError(f"role {role!r} count must be an integer") from exc
1919
+ if count < requirement.min_count or count > requirement.max_count:
1920
+ raise WizardError(
1921
+ f"role {role!r} count must be in "
1922
+ f"{requirement.min_count}..{requirement.max_count}: {count}"
1923
+ )
1924
+ state.role_counts[role] = count
1925
+ state.role_models.pop(role, None)
1926
+ return f"role-count: {role}={count}"
1927
+ if prompt.step.startswith("role-add:"):
1928
+ role = prompt.step.split(":", 1)[1]
1929
+ profile = _load_role_profile_for_state(state)
1930
+ requirement = next(
1931
+ (row for row in profile.roles if row.role == role),
1932
+ None,
1933
+ )
1934
+ if (
1935
+ requirement is None
1936
+ or requirement.dynamic
1937
+ or requirement.min_count != 0
1938
+ or requirement.max_count == 0
1939
+ ):
1940
+ raise WizardError(f"role {role!r} does not accept an optional add")
1941
+ allowed = {option.value for option in prompt.options}
1942
+ if value not in allowed:
1943
+ raise WizardError(
1944
+ f"role {role!r} add selection must be one of "
1945
+ f"{sorted(allowed, key=int)}: {value}"
1946
+ )
1947
+ try:
1948
+ count = int(value)
1949
+ except ValueError as exc:
1950
+ raise WizardError(f"role {role!r} count must be an integer") from exc
1951
+ if count < 0 or count > requirement.max_count:
1952
+ raise WizardError(
1953
+ f"role {role!r} count must be in "
1954
+ f"0..{requirement.max_count}: {count}"
1955
+ )
1956
+ state.role_counts[role] = count
1957
+ state.role_models.pop(role, None)
1958
+ return f"role-add: {role}={count}"
1959
+ _, role, ordinal_raw = prompt.step.split(":", 2)
1960
+ ordinal = int(ordinal_raw)
1961
+ allowed = {option.value for option in prompt.options}
1962
+ if value not in allowed:
1963
+ raise WizardError(
1964
+ f"model {value!r} is not a compatible candidate for role {role!r}"
1965
+ )
1966
+ selected = state.role_models.setdefault(role, [])
1967
+ if ordinal != len(selected) + 1:
1968
+ raise WizardError(
1969
+ f"role {role!r} model selection is out of order at ordinal {ordinal}"
1970
+ )
1971
+ selected.append(value)
1972
+ try:
1973
+ profile = _load_role_profile_for_state(state)
1974
+ _validate_submitted_role_model(state, profile)
1975
+ except WizardError:
1976
+ selected.pop()
1977
+ raise
1978
+ return f"role-model: {role}#{ordinal}={value}"
1979
+
1980
+
962
1981
  def _load_profile_workers(workspace_root: Path, task_type: str) -> list[str]:
963
1982
  return resolve_profile_workers(_profile_path(workspace_root, task_type))
964
1983
 
@@ -1270,6 +2289,30 @@ def _contract_outcome_suffix(entry: dict) -> str:
1270
2289
  return ""
1271
2290
 
1272
2291
 
2292
+ def _next_phase_cell(raw: Any) -> str:
2293
+ """task picker 한 줄에 실을 다음 phase 표기.
2294
+
2295
+ `phase` 만 싣고 `status` 를 버리면 "아직 여기 있다" 가 "다음으로 갈 수 있다"
2296
+ 로 읽힌다. prepare 는 끝나지 않은 run 의 포인터를 `ready` → `pending` 으로
2297
+ 내리면서 `phase` 는 남겨두므로(`render._derive_next_recommended_phase`),
2298
+ 준비만 된 implementation 태스크의 포인터가
2299
+ `{"phase": "final-verification", "status": "pending"}` 이다. status 를 안
2300
+ 보면 그 줄이 `next: final-verification` 으로 찍혀 착수 가능한 것처럼 읽힌다.
2301
+
2302
+ 그래서 `ready` 가 아닌 status 는 괄호로 함께 싣는다. `okstra-inspect` 의
2303
+ status 표는 같은 정보를 `*` 마커로 싣지만(`facets/status.md`), 그쪽은 표
2304
+ 아래 범례 한 줄을 붙일 수 있는 자리다. picker 옵션은 라벨 한 줄이 전부라
2305
+ 범례를 둘 데가 없으므로 status 이름을 그대로 적는다. phase 를 `--` 로
2306
+ 지우지 않는 이유는 그 이름이 "어디로 가는 태스크인가" 라는 정보를 여전히
2307
+ 나르기 때문이다 — 지우는 것은 착수 가능성만이 아니라 목적지까지 지운다.
2308
+ """
2309
+ pointer = next_phase.promote(raw)
2310
+ cell = pointer["phase"] or "--"
2311
+ if pointer["status"] == next_phase.STATUS_READY:
2312
+ return cell
2313
+ return f"{cell} ({pointer['status']})"
2314
+
2315
+
1273
2316
  def _build_task_pick(state: WizardState) -> Prompt:
1274
2317
  t = _p(state.workspace_root, "task_pick")
1275
2318
  project_root = Path(state.project_root)
@@ -1285,7 +2328,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
1285
2328
  # catalog entries are flat (render_task_catalog_discovery) — there is
1286
2329
  # no nested "workflow" object here, unlike task-manifest.json.
1287
2330
  phase = entry.get("currentPhase") or ttype
1288
- nxt = entry.get("nextRecommendedPhase") or ""
2331
+ nxt = _next_phase_cell(entry.get("nextRecommendedPhase"))
1289
2332
  suffix = latest_suffix if key == latest_key else ""
1290
2333
  label = f"{key} · {phase} · next: {nxt}{_contract_outcome_suffix(entry)}{suffix}"
1291
2334
  options.append(_opt(value=key, label=label))
@@ -1325,8 +2368,7 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
1325
2368
  # task_type seeded from manifest's nextRecommendedPhase as the recommended default
1326
2369
  root = find_task_root(Path(state.project_root), value)
1327
2370
  manifest = (read_task_manifest(root) or {}) if root else {}
1328
- workflow = manifest.get("workflow") or {}
1329
- state.task_type = workflow.get("nextRecommendedPhase") or ""
2371
+ state.task_type = next_phase.autofill_task_type(manifest)
1330
2372
  return f"task: {value}"
1331
2373
 
1332
2374
 
@@ -1770,8 +2812,18 @@ def _build_task_type(state: WizardState) -> Prompt:
1770
2812
 
1771
2813
  workflow = _existing_task_workflow(state)
1772
2814
  revision_requested = _latest_revision_requested_analysis_type(state)
2815
+ # 포인터에서 phase 를 꺼내는 것은 `status` 가 `ready` 일 때뿐이다. prepare 는
2816
+ # 실행이 끝나지 않은 run 의 포인터를 일부러 `pending` 으로 내려두는데
2817
+ # (`render._derive_next_recommended_phase`), 여기서 status 를 안 보고 `phase`
2818
+ # 만 꺼내면 그 방어를 그대로 우회한다 — 기록된 사례가 `implementation` 이
2819
+ # `prepared` 상태인데 `final-verification` 이 추천으로 뜬 것이다. 판정은
2820
+ # 셸 진입점이 쓰는 것과 같은 함수(`next_phase.autofill_task_type`)로 한다.
2821
+ #
2822
+ # `ready` 가 아니면 추천은 비고, 아래 `currentPhase` 재실행 옵션이 남는다.
2823
+ # 실패한 run 의 포인터가 `{"phase": "", "status": "blocked"}` 라는 점에서
2824
+ # 그것이 맞는 제안이다 — 그 옵션은 포인터가 아니라 `currentPhase` 에서 온다.
1773
2825
  recommended = (revision_requested or state.task_type
1774
- or workflow.get("nextRecommendedPhase") or "")
2826
+ or next_phase.autofill_task_type({"workflow": workflow}))
1775
2827
  if not recommended and not workflow:
1776
2828
  recommended = TASK_TYPE_VALUES[0]
1777
2829
  add(recommended, recommended_suffix)
@@ -3534,6 +4586,14 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
3534
4586
  report_writer_provider = state.report_writer_provider or "claude"
3535
4587
  state.report_writer_model = _safe_model(
3536
4588
  report_writer_provider, inputs.get("reportWriterModel"))
4589
+ if _role_selection_enabled(state):
4590
+ conversion_payload = dict(inputs)
4591
+ conversion_payload.setdefault("grokModel", state.grok_model)
4592
+ conversion_payload.setdefault("kimiModel", state.kimi_model)
4593
+ state.role_counts.clear()
4594
+ state.role_models.clear()
4595
+ state.role_selection_order.clear()
4596
+ _convert_v1_provider_selections(state, conversion_payload)
3537
4597
  directive = inputs.get("directive")
3538
4598
  state.directive = directive if isinstance(directive, str) else ""
3539
4599
  related = inputs.get("relatedTasks")
@@ -3579,6 +4639,7 @@ def _role_model_lines(state: WizardState) -> str:
3579
4639
  .resolve(state.host_runtime)
3580
4640
  .descriptor.native_provider_id
3581
4641
  or "",
4642
+ report_writer_provider=state.report_writer_provider or "claude",
3582
4643
  )
3583
4644
  roster = _resolved_roster(state)
3584
4645
  impl = state.task_type == "implementation"
@@ -4116,7 +5177,15 @@ def _build_edit_target(state: WizardState) -> Prompt:
4116
5177
  t = _p(state.workspace_root, "edit_target")
4117
5178
  # offer every step that has been answered.
4118
5179
  options: list[Option] = []
4119
- for sid in state.answered:
5180
+ selection_ids = [
5181
+ *state.answered,
5182
+ *(
5183
+ step_id
5184
+ for step_id in state.role_selection_order
5185
+ if step_id not in state.answered
5186
+ ),
5187
+ ]
5188
+ for sid in selection_ids:
4120
5189
  if sid in (S_CONFIRM, S_EDIT_TARGET):
4121
5190
  continue
4122
5191
  options.append(_opt(sid, sid))
@@ -4128,11 +5197,59 @@ def _build_edit_target(state: WizardState) -> Prompt:
4128
5197
  )
4129
5198
 
4130
5199
 
5200
+ def _reset_role_selection_from(state: WizardState, target_step: str) -> None:
5201
+ try:
5202
+ target_index = state.role_selection_order.index(target_step)
5203
+ except ValueError as exc:
5204
+ raise WizardError(f"unknown role selection step: {target_step!r}") from exc
5205
+ kept_ids = state.role_selection_order[:target_index]
5206
+ kept_counts = {
5207
+ step_id.split(":", 1)[1]
5208
+ for step_id in kept_ids
5209
+ if step_id.startswith(("role-count:", "role-add:"))
5210
+ }
5211
+ kept_models: dict[str, int] = {}
5212
+ for step_id in kept_ids:
5213
+ if not step_id.startswith("role-model:"):
5214
+ continue
5215
+ _, role, _ = step_id.split(":", 2)
5216
+ kept_models[role] = kept_models.get(role, 0) + 1
5217
+ state.role_counts = {
5218
+ role: count
5219
+ for role, count in state.role_counts.items()
5220
+ if role in kept_counts
5221
+ }
5222
+ state.role_models = {
5223
+ role: models[:kept_models[role]]
5224
+ for role, models in state.role_models.items()
5225
+ if kept_models.get(role, 0) > 0
5226
+ }
5227
+ removed_ids = set(state.role_selection_order[target_index:])
5228
+ state.role_selection_order = kept_ids
5229
+ state.answered = [
5230
+ step_id for step_id in state.answered if step_id not in removed_ids
5231
+ ]
5232
+
5233
+
5234
+ def _clear_role_selection(state: WizardState) -> None:
5235
+ state.role_counts.clear()
5236
+ state.role_models.clear()
5237
+ state.role_selection_order.clear()
5238
+ state.answered = [
5239
+ step_id
5240
+ for step_id in state.answered
5241
+ if not _is_role_selection_step(step_id)
5242
+ ]
5243
+
5244
+
4131
5245
  def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
4132
- if not any(s.id == value for s in STEPS):
5246
+ if _is_role_selection_step(value):
5247
+ _reset_role_selection_from(state, value)
5248
+ elif any(s.id == value for s in STEPS):
5249
+ _reset_from(state, value)
5250
+ else:
4133
5251
  raise WizardError(f"unknown step: {value!r}")
4134
5252
  state.edit_target = value
4135
- _reset_from(state, value)
4136
5253
  state.confirmed = None
4137
5254
  state.edit_target = ""
4138
5255
  return f"edit-target: {value} (rewinding)"
@@ -4379,23 +5496,28 @@ STEPS: list[Step] = [
4379
5496
  owns=("handoff_mode", "handoff_stages", "approved_plan_path")),
4380
5497
  Step(S_EXECUTOR,
4381
5498
  applies=lambda s: (s.task_type == "implementation"
5499
+ and not _role_selection_enabled(s)
4382
5500
  and bool(s.approved_plan_path)
4383
5501
  and not s.executor),
4384
5502
  build=_build_executor, submit=_submit_executor,
4385
5503
  owns=("executor",)),
4386
5504
  Step(S_CRITIC_PICK,
4387
- applies=lambda s: (s.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification")
5505
+ applies=lambda s: (not _role_selection_enabled(s)
5506
+ and s.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification")
4388
5507
  and not s.critic
4389
5508
  and not s.critic_pending_text
4390
5509
  and S_CRITIC_PICK not in s.answered),
4391
5510
  build=_build_critic_pick, submit=_submit_critic_pick,
4392
5511
  owns=("critic", "critic_pending_text")),
4393
5512
  Step(S_CRITIC_TEXT,
4394
- applies=lambda s: (s.critic_pending_text and S_CRITIC_TEXT not in s.answered),
5513
+ applies=lambda s: (not _role_selection_enabled(s)
5514
+ and s.critic_pending_text
5515
+ and S_CRITIC_TEXT not in s.answered),
4395
5516
  build=_build_critic_text, submit=_submit_critic_text,
4396
5517
  owns=("critic", "critic_pending_text")),
4397
5518
  Step(S_DEFAULTS_OR_CUSTOM,
4398
5519
  applies=lambda s: (_identity_ready(s)
5520
+ and not _role_selection_enabled(s)
4399
5521
  and s.use_defaults is None),
4400
5522
  build=_build_defaults_or_custom, submit=_submit_defaults_or_custom,
4401
5523
  owns=("use_defaults",)),
@@ -4408,6 +5530,7 @@ STEPS: list[Step] = [
4408
5530
  # still skipped because it runs lead + executor only.
4409
5531
  Step(S_WORKERS_OVERRIDE,
4410
5532
  applies=lambda s: (s.task_type != "implementation"
5533
+ and not _role_selection_enabled(s)
4411
5534
  and any(
4412
5535
  w != "report-writer"
4413
5536
  for w in (s.profile_workers
@@ -4419,23 +5542,27 @@ STEPS: list[Step] = [
4419
5542
  # "직접 선택" 을 고른 run 에서만 등장한다 — 기본 로스터에서 워커를 빼는
4420
5543
  # 축소가 가능한 유일한 화면.
4421
5544
  Step(S_WORKERS_CUSTOM,
4422
- applies=lambda s: (s.workers_custom_pending
5545
+ applies=lambda s: (not _role_selection_enabled(s)
5546
+ and s.workers_custom_pending
4423
5547
  and S_WORKERS_CUSTOM not in s.answered),
4424
5548
  build=_build_workers_custom, submit=_submit_workers_custom,
4425
5549
  owns=("workers_override", "workers_custom_pending")),
4426
5550
  Step(S_LEAD_MODEL,
4427
5551
  applies=lambda s: (s.use_defaults is False
5552
+ and not _role_selection_enabled(s)
4428
5553
  and S_LEAD_MODEL not in s.answered),
4429
5554
  build=_build_lead_model, submit=_submit_lead_model,
4430
5555
  owns=("lead_provider", "lead_model")),
4431
5556
  Step(S_EXECUTOR_MODEL,
4432
5557
  applies=lambda s: (s.use_defaults is False
5558
+ and not _role_selection_enabled(s)
4433
5559
  and s.task_type == "implementation"
4434
5560
  and S_EXECUTOR_MODEL not in s.answered),
4435
5561
  build=_build_executor_model, submit=_submit_executor_model,
4436
5562
  owns=("claude_model", "codex_model", "antigravity_model")),
4437
5563
  Step(S_CLAUDE_MODEL,
4438
5564
  applies=lambda s: (s.use_defaults is False
5565
+ and not _role_selection_enabled(s)
4439
5566
  and s.task_type != "implementation"
4440
5567
  and "claude" in _resolved_roster(s)
4441
5568
  and S_CLAUDE_MODEL not in s.answered),
@@ -4443,6 +5570,7 @@ STEPS: list[Step] = [
4443
5570
  owns=("claude_model",)),
4444
5571
  Step(S_CODEX_MODEL,
4445
5572
  applies=lambda s: (s.use_defaults is False
5573
+ and not _role_selection_enabled(s)
4446
5574
  and s.task_type != "implementation"
4447
5575
  and "codex" in _resolved_roster(s)
4448
5576
  and S_CODEX_MODEL not in s.answered),
@@ -4450,6 +5578,7 @@ STEPS: list[Step] = [
4450
5578
  owns=("codex_model",)),
4451
5579
  Step(S_ANTIGRAVITY_MODEL,
4452
5580
  applies=lambda s: (s.use_defaults is False
5581
+ and not _role_selection_enabled(s)
4453
5582
  and s.task_type != "implementation"
4454
5583
  and "antigravity" in _resolved_roster(s)
4455
5584
  and S_ANTIGRAVITY_MODEL not in s.answered),
@@ -4457,6 +5586,7 @@ STEPS: list[Step] = [
4457
5586
  owns=("antigravity_model",)),
4458
5587
  Step(S_GROK_MODEL,
4459
5588
  applies=lambda s: (s.use_defaults is False
5589
+ and not _role_selection_enabled(s)
4460
5590
  and s.task_type != "implementation"
4461
5591
  and "grok" in _resolved_roster(s)
4462
5592
  and S_GROK_MODEL not in s.answered),
@@ -4464,6 +5594,7 @@ STEPS: list[Step] = [
4464
5594
  owns=("grok_model",)),
4465
5595
  Step(S_KIMI_MODEL,
4466
5596
  applies=lambda s: (s.use_defaults is False
5597
+ and not _role_selection_enabled(s)
4467
5598
  and s.task_type != "implementation"
4468
5599
  and "kimi" in _resolved_roster(s)
4469
5600
  and S_KIMI_MODEL not in s.answered),
@@ -4471,6 +5602,7 @@ STEPS: list[Step] = [
4471
5602
  owns=("kimi_model",)),
4472
5603
  Step(S_REPORT_WRITER_MODEL,
4473
5604
  applies=lambda s: (s.use_defaults is False
5605
+ and not _role_selection_enabled(s)
4474
5606
  and (s.task_type == "implementation"
4475
5607
  or "report-writer" in _resolved_roster(s))
4476
5608
  and S_REPORT_WRITER_MODEL not in s.answered),
@@ -4590,7 +5722,7 @@ def _identity_ready(s: WizardState) -> bool:
4590
5722
  if s.task_type in _STAGE_SCOPED_TASK_TYPES:
4591
5723
  if not s.approved_plan_path:
4592
5724
  return False
4593
- if s.task_type == "implementation":
5725
+ if s.task_type == "implementation" and not _role_selection_enabled(s):
4594
5726
  if not s.executor:
4595
5727
  return False
4596
5728
  if (s.task_type == "release-handoff"
@@ -4600,6 +5732,24 @@ def _identity_ready(s: WizardState) -> bool:
4600
5732
 
4601
5733
 
4602
5734
  def _ready_for_confirm(s: WizardState) -> bool:
5735
+ if _role_selection_enabled(s):
5736
+ try:
5737
+ if next_role_prompt(s) is not None:
5738
+ return False
5739
+ except WizardError:
5740
+ return False
5741
+ followup_ids = [
5742
+ S_DIRECTIVE_PICK,
5743
+ S_DIRECTIVE,
5744
+ S_RELATED_TASKS_PICK,
5745
+ S_RELATED_TASKS,
5746
+ S_CLARIFICATION_PICK,
5747
+ S_CLARIFICATION,
5748
+ S_PR_TEMPLATE_PICK,
5749
+ S_PR_TEMPLATE,
5750
+ S_PR_TEMPLATE_SCOPE,
5751
+ ]
5752
+ return not any(STEP_BY_ID[step_id].applies(s) for step_id in followup_ids)
4603
5753
  if s.use_defaults is None:
4604
5754
  return False
4605
5755
  # Worker roster is required regardless of the defaults / customize
@@ -4631,6 +5781,12 @@ def _reset_from(state: WizardState, target_step: str) -> None:
4631
5781
  idx = next((i for i, s in enumerate(STEPS) if s.id == target_step), -1)
4632
5782
  if idx < 0:
4633
5783
  return
5784
+ role_boundary = next(
5785
+ i for i, step in enumerate(STEPS)
5786
+ if step.id == S_DEFAULTS_OR_CUSTOM
5787
+ )
5788
+ if idx < role_boundary:
5789
+ _clear_role_selection(state)
4634
5790
  # A later step may own a field an earlier answered step also owns —
4635
5791
  # `handoff_stage_pick` owns `approved_plan_path` because it resolves the
4636
5792
  # plan on its own. Clearing it while rewinding to a step in front of it
@@ -4687,6 +5843,8 @@ _FIELD_DEFAULTS: dict[str, Any] = {
4687
5843
  "selected_stages": "",
4688
5844
  "handoff_mode": "", "handoff_stages": "",
4689
5845
  "executor": "", "critic": "", "critic_pending_text": False,
5846
+ "execution_identity_version": 1,
5847
+ "role_counts": {}, "role_models": {}, "role_selection_order": [],
4690
5848
  "reuse_previous": None,
4691
5849
  "use_defaults": None, "workers_override": "",
4692
5850
  "workers_custom_pending": False,
@@ -4711,6 +5869,8 @@ def _reset_field(state: WizardState, fname: str) -> None:
4711
5869
  # copy mutable defaults
4712
5870
  if isinstance(default, list):
4713
5871
  setattr(state, fname, [])
5872
+ elif isinstance(default, dict):
5873
+ setattr(state, fname, {})
4714
5874
  else:
4715
5875
  setattr(state, fname, default)
4716
5876
 
@@ -4725,6 +5885,7 @@ def init_state(
4725
5885
  ) -> WizardState:
4726
5886
  """Bootstrap a new wizard state."""
4727
5887
  return WizardState(
5888
+ execution_identity_version=2,
4728
5889
  workspace_root=workspace_root,
4729
5890
  project_root=project_root,
4730
5891
  project_id=project_id,
@@ -4764,6 +5925,10 @@ def next_prompt(state: WizardState) -> Prompt:
4764
5925
  if state.confirmed:
4765
5926
  return Prompt(step=S_DONE, kind="done")
4766
5927
  for step in STEPS:
5928
+ if step.id == S_DEFAULTS_OR_CUSTOM:
5929
+ role_prompt = next_role_prompt(state)
5930
+ if role_prompt is not None:
5931
+ return role_prompt
4767
5932
  if step.id in state.answered and not step.repeatable:
4768
5933
  continue
4769
5934
  if step.applies(state):
@@ -4800,6 +5965,13 @@ def _sim_advance(state: WizardState, prompt: Prompt) -> None:
4800
5965
  """기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
4801
5966
  _submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
4802
5967
  try:
5968
+ if _is_role_selection_step(prompt.step):
5969
+ _submit_role_prompt(state, prompt, _sim_answer(prompt))
5970
+ if prompt.step not in state.answered:
5971
+ state.answered.append(prompt.step)
5972
+ if prompt.step not in state.role_selection_order:
5973
+ state.role_selection_order.append(prompt.step)
5974
+ return
4803
5975
  if prompt.kind == "pick_group":
4804
5976
  for q in prompt.questions:
4805
5977
  STEP_BY_ID[q.step].submit(state, _sim_answer(q))
@@ -4980,6 +6152,14 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
4980
6152
  value = _normalize_interaction_answer(state, prompt, plan, value)
4981
6153
  if prompt.kind == "pick_group":
4982
6154
  return _submit_group(state, prompt, value)
6155
+ if _is_role_selection_step(prompt.step):
6156
+ echo = _submit_role_prompt(state, prompt, value or "")
6157
+ if prompt.step not in state.answered:
6158
+ state.answered.append(prompt.step)
6159
+ if prompt.step not in state.role_selection_order:
6160
+ state.role_selection_order.append(prompt.step)
6161
+ nxt = next_prompt(state)
6162
+ return {"echo": echo, "next": prompt_payload(state, nxt)}
4983
6163
  step = STEP_BY_ID[prompt.step]
4984
6164
  echo = step.submit(state, value or "")
4985
6165
  if prompt.step not in state.answered:
@@ -5005,15 +6185,45 @@ def _stage_intent(state: WizardState) -> WizardStageIntent:
5005
6185
  raise WizardError(str(exc)) from exc
5006
6186
 
5007
6187
 
5008
- def render_args(state: WizardState) -> dict[str, str]:
6188
+ def render_role_args(state: WizardState) -> list[str]:
6189
+ """Return ordered canonical role count/model CLI tokens."""
6190
+ role_argv: list[str] = []
6191
+ if _role_selection_enabled(state):
6192
+ profile = _load_role_profile_for_state(state)
6193
+ requirements = _selectable_static_requirements(state, profile)
6194
+ for requirement in profile.roles:
6195
+ if (
6196
+ requirement.dynamic
6197
+ or requirement.min_count == requirement.max_count
6198
+ ):
6199
+ continue
6200
+ if requirement.role not in state.role_counts:
6201
+ continue
6202
+ count = state.role_counts[requirement.role]
6203
+ if count <= 0:
6204
+ continue
6205
+ role_argv.extend([
6206
+ "--role-count",
6207
+ f"{requirement.role}={count}",
6208
+ ])
6209
+ ordered_roles = [requirement.role for requirement in requirements]
6210
+ else:
6211
+ ordered_roles = []
6212
+ for role in state.role_models:
6213
+ if role not in ordered_roles:
6214
+ ordered_roles.append(role)
6215
+ for role in ordered_roles:
6216
+ for model_ref in state.role_models.get(role, []):
6217
+ role_argv.extend(["--role-model", f"{role}={model_ref}"])
6218
+ return role_argv
6219
+
6220
+
6221
+ def render_args(state: WizardState) -> dict[str, Any]:
5009
6222
  """Convert finalized state into ``okstra render-bundle`` argument map."""
5010
6223
  if state.aborted:
5011
6224
  raise WizardError(
5012
6225
  "wizard was aborted by the user — render-args is unavailable"
5013
6226
  )
5014
- workers = state.workers_override.strip()
5015
- if state.task_type == "implementation":
5016
- workers = "" # profile-default roster is mandatory for impl
5017
6227
  base_ref = (
5018
6228
  ""
5019
6229
  if state.reuse_worktree or state.task_type == "final-verification"
@@ -5030,15 +6240,7 @@ def render_args(state: WizardState) -> dict[str, str]:
5030
6240
  for path in (state.feature_evidence_path, state.project_evidence_path)
5031
6241
  if path
5032
6242
  ]
5033
- worker_models = ",".join(
5034
- f"{provider}={model}"
5035
- for provider, model in (
5036
- ("grok", state.grok_model),
5037
- ("kimi", state.kimi_model),
5038
- )
5039
- if model
5040
- )
5041
- return {
6243
+ rendered: dict[str, Any] = {
5042
6244
  "project-root": state.project_root,
5043
6245
  "project-id": state.project_id,
5044
6246
  "task-group": state.task_group,
@@ -5047,22 +6249,11 @@ def render_args(state: WizardState) -> dict[str, str]:
5047
6249
  "task-brief": state.brief_path,
5048
6250
  "analysis-target": state.analysis_target,
5049
6251
  "evidence-inputs": ",".join(evidence_paths),
5050
- "executor": state.executor,
5051
- "critic": state.critic,
5052
6252
  "approved-plan": state.approved_plan_path,
5053
6253
  "stage": stage_intent.stage,
5054
6254
  "stages": state.handoff_stages,
5055
6255
  "base-ref": base_ref,
5056
- "workers": workers,
5057
6256
  "directive": state.directive,
5058
- "lead-provider": state.lead_provider,
5059
- "lead-model": state.lead_model,
5060
- "claude-model": state.claude_model,
5061
- "codex-model": state.codex_model,
5062
- "antigravity-model": state.antigravity_model,
5063
- "worker-model": worker_models,
5064
- "report-writer-provider": state.report_writer_provider,
5065
- "report-writer-model": state.report_writer_model,
5066
6257
  "related-tasks": state.related_tasks_raw,
5067
6258
  "clarification-response": state.clarification_response_path,
5068
6259
  "selected-direction": state.selected_direction_path,
@@ -5072,7 +6263,36 @@ def render_args(state: WizardState) -> dict[str, str]:
5072
6263
  ),
5073
6264
  "pr-template-path": pr_template,
5074
6265
  "fix-cycle": state.fix_cycle,
6266
+ "host-session-context-json": serialize_host_session_context(
6267
+ _host_session_context(state)
6268
+ ),
5075
6269
  }
6270
+ role_argv = render_role_args(state)
6271
+ rendered["role-count"] = [
6272
+ role_argv[index + 1]
6273
+ for index, token in enumerate(role_argv)
6274
+ if token == "--role-count"
6275
+ ]
6276
+ rendered["role-model"] = [
6277
+ role_argv[index + 1]
6278
+ for index, token in enumerate(role_argv)
6279
+ if token == "--role-model"
6280
+ ]
6281
+ return rendered
6282
+
6283
+
6284
+ def _render_argv(rendered: dict[str, Any], *, host_runtime: str) -> list[str]:
6285
+ """Flatten render arguments into the canonical render-bundle argv."""
6286
+ argv = ["--lead-runtime", host_runtime]
6287
+ for name, raw_value in rendered.items():
6288
+ values = raw_value if isinstance(raw_value, list) else [raw_value]
6289
+ for value in values:
6290
+ if not isinstance(value, str):
6291
+ raise WizardError(
6292
+ f"wizard render arg --{name} must be a string"
6293
+ )
6294
+ argv.extend([f"--{name}", value])
6295
+ return argv
5076
6296
 
5077
6297
 
5078
6298
  def _clarification_sidecar_line(state: WizardState) -> Optional[str]:
@@ -5172,37 +6392,77 @@ def confirmation_block(state: WizardState) -> str:
5172
6392
  worktree_line = _worktree_preview_line(state)
5173
6393
  if worktree_line is not None:
5174
6394
  lines.append(worktree_line)
5175
- if state.task_type == "implementation":
6395
+ role_selection = _role_selection_enabled(state)
6396
+ if state.task_type == "implementation" and not role_selection:
5176
6397
  lines.append(f" executor : {state.executor or '(default)'}")
5177
6398
  lines.append(
5178
6399
  _msg(state.workspace_root, "confirmation", "workers_implementation_default")
5179
6400
  )
5180
- else:
6401
+ elif not role_selection:
5181
6402
  roster = state.workers_override or ",".join(state.profile_workers) or "(profile default)"
5182
6403
  lines.append(f" workers : {roster}")
5183
- lines.append(
5184
- f" lead : {state.lead_provider or 'claude'} / "
5185
- f"{state.lead_model or 'default'}"
5186
- )
5187
- if state.claude_model:
5188
- lines.append(f" claude-model : {state.claude_model}")
5189
- if state.codex_model:
5190
- lines.append(f" codex-model : {state.codex_model}")
5191
- if state.antigravity_model:
5192
- lines.append(f" antigravity-model : {state.antigravity_model}")
5193
- if state.grok_model:
5194
- lines.append(f" grok-model : {state.grok_model}")
5195
- if state.kimi_model:
5196
- lines.append(f" kimi-model : {state.kimi_model}")
5197
- if state.report_writer_model or state.report_writer_provider:
6404
+ if role_selection:
6405
+ profile = _load_role_profile_for_state(state)
6406
+ if state.host_entry_mode == "current-session":
6407
+ lines.append(_msg(
6408
+ state.workspace_root,
6409
+ "confirmation",
6410
+ "static_role",
6411
+ role="leader",
6412
+ ordinal="1",
6413
+ model="current-session",
6414
+ ))
6415
+ for requirement in _selectable_static_requirements(state, profile):
6416
+ count = _selected_role_count(state, requirement)
6417
+ selected = state.role_models.get(requirement.role, [])
6418
+ for ordinal in range(1, count + 1):
6419
+ model_ref = (
6420
+ selected[ordinal - 1]
6421
+ if ordinal <= len(selected)
6422
+ else "(selection required)"
6423
+ )
6424
+ lines.append(_msg(
6425
+ state.workspace_root,
6426
+ "confirmation",
6427
+ "static_role",
6428
+ role=requirement.role,
6429
+ ordinal=str(ordinal),
6430
+ model=model_ref,
6431
+ ))
6432
+ for requirement in profile.roles:
6433
+ if not requirement.dynamic:
6434
+ continue
6435
+ lines.append(_msg(
6436
+ state.workspace_root,
6437
+ "confirmation",
6438
+ "dynamic_role",
6439
+ role=requirement.role,
6440
+ ))
6441
+ else:
5198
6442
  lines.append(
5199
- f" report-writer : {state.report_writer_provider or 'claude'} / "
5200
- f"{state.report_writer_model or 'default'}"
6443
+ f" lead : {state.lead_provider or 'claude'} / "
6444
+ f"{state.lead_model or 'default'}"
5201
6445
  )
6446
+ if state.claude_model:
6447
+ lines.append(f" claude-model : {state.claude_model}")
6448
+ if state.codex_model:
6449
+ lines.append(f" codex-model : {state.codex_model}")
6450
+ if state.antigravity_model:
6451
+ lines.append(f" antigravity-model : {state.antigravity_model}")
6452
+ if state.grok_model:
6453
+ lines.append(f" grok-model : {state.grok_model}")
6454
+ if state.kimi_model:
6455
+ lines.append(f" kimi-model : {state.kimi_model}")
6456
+ if state.report_writer_model or state.report_writer_provider:
6457
+ lines.append(
6458
+ f" report-writer : {state.report_writer_provider or 'claude'} / "
6459
+ f"{state.report_writer_model or 'default'}"
6460
+ )
5202
6461
  lines.append(f" directive : {state.directive or '(none)'}")
5203
6462
  if state.related_tasks_raw:
5204
6463
  lines.append(f" related-tasks : {state.related_tasks_raw}")
5205
- if state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification"):
6464
+ if (not role_selection
6465
+ and state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification")):
5206
6466
  lines.append(f" critic : {state.critic or '(off)'}")
5207
6467
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
5208
6468
  lines.append(f" approved-plan : {state.approved_plan_path}")
@@ -5269,8 +6529,10 @@ def wizard_outcome(state: WizardState) -> dict[str, Any]:
5269
6529
  raise WizardError("wizard was aborted by the user — outcome is unavailable")
5270
6530
  if state.confirmed is not True:
5271
6531
  raise WizardError("wizard is not complete — outcome is unavailable")
6532
+ rendered = render_args(state)
5272
6533
  return {
5273
- "renderArgs": render_args(state),
6534
+ "renderArgs": rendered,
6535
+ "renderArgv": _render_argv(rendered, host_runtime=state.host_runtime),
5274
6536
  "orchestration": {"chainStages": _stage_intent(state).chain_stages},
5275
6537
  "persistActions": _wizard_persist_actions(state),
5276
6538
  "confirmationText": confirmation_block(state),
@@ -5279,16 +6541,136 @@ def wizard_outcome(state: WizardState) -> dict[str, Any]:
5279
6541
 
5280
6542
  # ---- File I/O helpers (used by CLI) -------------------------------------
5281
6543
 
6544
+ _LEGACY_SOURCE_FIELD = "legacySource"
6545
+
6546
+
6547
+ def _legacy_v2_companion_path(path: Path) -> Path:
6548
+ return path.with_name(f"{path.name}.v2.json")
6549
+
6550
+
6551
+ def _content_addressed_v2_companion_path(
6552
+ path: Path,
6553
+ source_digest: str,
6554
+ suffix: int | None = None,
6555
+ ) -> Path:
6556
+ numbered = f".{suffix}" if suffix is not None else ""
6557
+ return path.with_name(f"{path.name}.v2.{source_digest}{numbered}.json")
6558
+
6559
+
6560
+ def _content_addressed_v2_companion_paths(
6561
+ path: Path,
6562
+ source_digest: str,
6563
+ ) -> tuple[Path, ...]:
6564
+ canonical = _content_addressed_v2_companion_path(path, source_digest)
6565
+ numbered_prefix = f"{path.name}.v2.{source_digest}."
6566
+ numbered = tuple(sorted(
6567
+ candidate
6568
+ for candidate in path.parent.iterdir()
6569
+ if candidate.is_file()
6570
+ and candidate.name.startswith(numbered_prefix)
6571
+ and candidate.name.endswith(".json")
6572
+ ))
6573
+ return (canonical, *numbered)
6574
+
6575
+
6576
+ def _available_v2_companion_path(path: Path, source_digest: str) -> Path:
6577
+ candidate = _content_addressed_v2_companion_path(path, source_digest)
6578
+ suffix = 1
6579
+ while candidate.exists():
6580
+ candidate = _content_addressed_v2_companion_path(
6581
+ path,
6582
+ source_digest,
6583
+ suffix,
6584
+ )
6585
+ suffix += 1
6586
+ return candidate
6587
+
6588
+
6589
+ def _legacy_source_metadata(path: Path, source: bytes) -> dict[str, str]:
6590
+ return {
6591
+ "path": str(path.resolve()),
6592
+ "sha256": hashlib.sha256(source).hexdigest(),
6593
+ }
6594
+
6595
+
6596
+ def _matching_v2_companion(
6597
+ path: Path,
6598
+ source_metadata: dict[str, str],
6599
+ ) -> dict[str, Any] | None:
6600
+ try:
6601
+ payload = json.loads(path.read_text(encoding="utf-8"))
6602
+ except (OSError, UnicodeError, json.JSONDecodeError):
6603
+ return None
6604
+ if not isinstance(payload, dict):
6605
+ return None
6606
+ if payload.get("executionIdentityVersion") != 2:
6607
+ return None
6608
+ if payload.get(_LEGACY_SOURCE_FIELD) != source_metadata:
6609
+ return None
6610
+ return payload
6611
+
6612
+
6613
+ def _load_matching_v2_state(
6614
+ paths: tuple[Path, ...],
6615
+ source_metadata: dict[str, str],
6616
+ ) -> tuple[WizardState | None, Path | None]:
6617
+ for companion_path in paths:
6618
+ payload = _matching_v2_companion(companion_path, source_metadata)
6619
+ if payload is None:
6620
+ continue
6621
+ try:
6622
+ return WizardState.from_json(payload), companion_path
6623
+ except WizardError:
6624
+ continue
6625
+ return None, None
6626
+
6627
+
5282
6628
  def load_state_file(path: Path) -> WizardState:
5283
- data = json.loads(Path(path).read_text(encoding="utf-8"))
5284
- return WizardState.from_json(data)
6629
+ source_path = Path(path)
6630
+ source = source_path.read_bytes()
6631
+ data = json.loads(source)
6632
+ source_version = data.get("executionIdentityVersion", 1)
6633
+ source_metadata = _legacy_source_metadata(source_path, source)
6634
+ source_digest = source_metadata["sha256"]
6635
+ resumed_state = None
6636
+ companion_path = None
6637
+ if source_version in (None, 1):
6638
+ content_paths = _content_addressed_v2_companion_paths(
6639
+ source_path,
6640
+ source_digest,
6641
+ )
6642
+ resumed_state, companion_path = _load_matching_v2_state(
6643
+ content_paths,
6644
+ source_metadata,
6645
+ )
6646
+ if resumed_state is None:
6647
+ resumed_state, _ = _load_matching_v2_state(
6648
+ (_legacy_v2_companion_path(source_path),),
6649
+ source_metadata,
6650
+ )
6651
+ state = resumed_state or WizardState.from_json(data)
6652
+ state._source_execution_identity_version = source_version
6653
+ if source_version in (None, 1):
6654
+ state._legacy_source_metadata = source_metadata
6655
+ state._v2_companion_path = companion_path or _available_v2_companion_path(
6656
+ source_path,
6657
+ source_digest,
6658
+ )
6659
+ return state
6660
+
6661
+
6662
+ def _save_resumed_state_file(path: Path, state: WizardState) -> None:
6663
+ """Persist native v2 state while keeping legacy resume sources read-only."""
6664
+ if getattr(state, "_source_execution_identity_version", 2) in (None, 1):
6665
+ payload = state.to_json()
6666
+ payload[_LEGACY_SOURCE_FIELD] = state._legacy_source_metadata
6667
+ write_json_atomic(state._v2_companion_path, payload)
6668
+ return
6669
+ save_state_file(path, state)
5285
6670
 
5286
6671
 
5287
6672
  def save_state_file(path: Path, state: WizardState) -> None:
5288
- Path(path).write_text(
5289
- json.dumps(state.to_json(), indent=2, ensure_ascii=False),
5290
- encoding="utf-8",
5291
- )
6673
+ write_json_atomic(Path(path), state.to_json())
5292
6674
 
5293
6675
 
5294
6676
  # ---- CLI entrypoint -----------------------------------------------------
@@ -5366,6 +6748,7 @@ def _cli(argv: list[str]) -> int:
5366
6748
 
5367
6749
  if args.cmd == "step":
5368
6750
  state = load_state_file(state_path)
6751
+ persisted_state = copy.deepcopy(state)
5369
6752
  if args.no_submit and args.answer is not None:
5370
6753
  print(json.dumps(
5371
6754
  {"ok": False, "error": "--no-submit and --answer are mutually exclusive"},
@@ -5390,7 +6773,7 @@ def _cli(argv: list[str]) -> int:
5390
6773
  else:
5391
6774
  result = submit(state, args.answer)
5392
6775
  except WizardError as exc:
5393
- save_state_file(state_path, state)
6776
+ _save_resumed_state_file(state_path, persisted_state)
5394
6777
  try:
5395
6778
  current = prompt_payload(state, next_prompt(state))
5396
6779
  except WizardError:
@@ -5402,7 +6785,7 @@ def _cli(argv: list[str]) -> int:
5402
6785
  print(json.dumps({"ok": False, "error": str(exc), "current": current},
5403
6786
  ensure_ascii=False, indent=2))
5404
6787
  return 0
5405
- save_state_file(state_path, state)
6788
+ _save_resumed_state_file(state_path, state)
5406
6789
  print(json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2))
5407
6790
  return 0
5408
6791