okstra 0.201.3 → 0.204.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 (273) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.mjs +7 -7
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +50 -124
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/dist/commands/lifecycle/setup.mjs +15 -0
  7. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  8. package/dist/commands/memory/memory.mjs +41 -8
  9. package/dist/commands/memory/memory.mjs.map +1 -1
  10. package/dist/lib/citation-guidance.d.mts +21 -0
  11. package/dist/lib/citation-guidance.mjs +79 -0
  12. package/dist/lib/citation-guidance.mjs.map +1 -0
  13. package/dist/lib/install-assets.mjs +3 -0
  14. package/dist/lib/install-assets.mjs.map +1 -1
  15. package/dist/lib/runtime-manifest.mjs +2 -1
  16. package/dist/lib/runtime-manifest.mjs.map +1 -1
  17. package/dist/lib/types.d.mts +2 -1
  18. package/docs/architecture/storage-model.md +17 -10
  19. package/docs/architecture.md +26 -20
  20. package/docs/cli.md +16 -13
  21. package/docs/contributor-change-matrix.md +3 -2
  22. package/docs/performance-improvement-plan-v2.md +2 -3
  23. package/docs/project-structure-overview.md +38 -9
  24. package/docs/task-process/README.md +1 -1
  25. package/docs/task-process/common-flow.md +1 -1
  26. package/docs/task-process/final-verification.md +3 -1
  27. package/docs/task-process/implementation.md +1 -1
  28. package/docs/task-process/release-handoff.md +36 -39
  29. package/package.json +1 -2
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/common.json +28 -0
  32. package/runtime/agents/operations/code-review.json +6 -0
  33. package/runtime/agents/operations/report-translation.json +6 -0
  34. package/runtime/agents/operations/schedule-verification.json +6 -0
  35. package/runtime/agents/roles/analyser.json +18 -0
  36. package/runtime/agents/roles/critic.json +18 -0
  37. package/runtime/agents/roles/designer.json +18 -0
  38. package/runtime/agents/roles/implementer.json +20 -0
  39. package/runtime/agents/roles/leader.json +20 -0
  40. package/runtime/agents/roles/planner.json +18 -0
  41. package/runtime/agents/roles/report-writer.json +19 -0
  42. package/runtime/agents/roles/translator.json +19 -0
  43. package/runtime/agents/roles/verifier.json +18 -0
  44. package/runtime/bin/lib/okstra/usage.sh +5 -5
  45. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  46. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  47. package/runtime/prompts/duties/analysis-worker.json +32 -0
  48. package/runtime/prompts/duties/code-reviewer.json +32 -0
  49. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  50. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  51. package/runtime/prompts/duties/discovery-worker.json +32 -0
  52. package/runtime/prompts/duties/implementation-executor.json +32 -0
  53. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  54. package/runtime/prompts/duties/lead.json +32 -0
  55. package/runtime/prompts/duties/planning-worker.json +36 -0
  56. package/runtime/prompts/duties/report-writer.json +32 -0
  57. package/runtime/prompts/duties/reverification-worker.json +32 -0
  58. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  59. package/runtime/prompts/duties/scope-critic.json +32 -0
  60. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  61. package/runtime/prompts/duties/translator.json +32 -0
  62. package/runtime/prompts/launch.template.md +3 -2
  63. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  64. package/runtime/prompts/lead/convergence.md +4 -4
  65. package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
  66. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  67. package/runtime/prompts/lead/report-writer.md +3 -3
  68. package/runtime/prompts/profiles/_common-contract.md +2 -2
  69. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  70. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  71. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  72. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  73. package/runtime/prompts/profiles/error-analysis.json +39 -0
  74. package/runtime/prompts/profiles/error-analysis.md +0 -25
  75. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  76. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  77. package/runtime/prompts/profiles/final-verification.json +30 -0
  78. package/runtime/prompts/profiles/final-verification.md +3 -22
  79. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  80. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  81. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  82. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  83. package/runtime/prompts/profiles/implementation-planning.md +6 -29
  84. package/runtime/prompts/profiles/implementation.json +30 -0
  85. package/runtime/prompts/profiles/implementation.md +1 -20
  86. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  87. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  88. package/runtime/prompts/profiles/project-analysis.json +31 -0
  89. package/runtime/prompts/profiles/project-analysis.md +0 -20
  90. package/runtime/prompts/profiles/release-handoff.json +5 -0
  91. package/runtime/prompts/profiles/release-handoff.md +71 -73
  92. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  93. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  94. package/runtime/prompts/profiles/technical-verification.json +39 -0
  95. package/runtime/prompts/profiles/technical-verification.md +0 -25
  96. package/runtime/prompts/wizard/prompts.ko.json +12 -17
  97. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  98. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  101. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  103. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  104. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  105. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  106. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  107. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  108. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  109. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  110. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  111. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  112. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  113. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  114. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  115. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  116. package/runtime/python/okstra_ctl/cmux.py +69 -20
  117. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  118. package/runtime/python/okstra_ctl/conformance.py +43 -0
  119. package/runtime/python/okstra_ctl/consumers.py +6 -3
  120. package/runtime/python/okstra_ctl/container.py +31 -8
  121. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  122. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  123. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  124. package/runtime/python/okstra_ctl/convergence_provenance.py +81 -18
  125. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  126. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  127. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  128. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  129. package/runtime/python/okstra_ctl/error_report.py +4 -3
  130. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  131. package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
  132. package/runtime/python/okstra_ctl/handoff.py +167 -277
  133. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  134. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  135. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  136. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +175 -14
  138. package/runtime/python/okstra_ctl/manager_launch.py +41 -19
  139. package/runtime/python/okstra_ctl/manager_paths.py +22 -3
  140. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  141. package/runtime/python/okstra_ctl/manager_store.py +331 -21
  142. package/runtime/python/okstra_ctl/manager_sync.py +37 -16
  143. package/runtime/python/okstra_ctl/manager_view.py +217 -0
  144. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  145. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  146. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  147. package/runtime/python/okstra_ctl/models.py +1 -1
  148. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  149. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  150. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  151. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  152. package/runtime/python/okstra_ctl/paths.py +3 -0
  153. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  154. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  155. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  156. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  157. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  158. package/runtime/python/okstra_ctl/render.py +50 -0
  159. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  160. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  161. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  162. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  163. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  164. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  165. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  166. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  167. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  168. package/runtime/python/okstra_ctl/rollup.py +3 -1
  169. package/runtime/python/okstra_ctl/run.py +76 -29
  170. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  171. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  172. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  173. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  174. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  175. package/runtime/python/okstra_ctl/team.py +22 -13
  176. package/runtime/python/okstra_ctl/time_report.py +2 -1
  177. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  178. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  179. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  180. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  181. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  182. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  183. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -23
  184. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  185. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  186. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  187. package/runtime/python/okstra_ctl/workflow.py +1 -1
  188. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  189. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  190. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  191. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  192. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  193. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  194. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  195. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  196. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  197. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  198. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  199. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  200. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  201. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  202. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  203. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  204. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  205. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  206. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  207. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  208. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  209. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  210. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  211. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  212. package/runtime/skills/okstra-manager/SKILL.md +70 -5
  213. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  214. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  215. package/runtime/skills/okstra-run/SKILL.md +32 -13
  216. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  217. package/runtime/skills/okstra-setup/SKILL.md +21 -10
  218. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  219. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  220. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  221. package/runtime/templates/manager/view.template.html +109 -0
  222. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  223. package/runtime/templates/reports/brief.template.md +14 -4
  224. package/runtime/templates/reports/html/i18n/en.json +7 -4
  225. package/runtime/templates/reports/html/i18n/ko.json +7 -4
  226. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  227. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  228. package/runtime/templates/reports/i18n/en.json +1 -1
  229. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  230. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  231. package/runtime/templates/translator-prompt-preamble.md +36 -0
  232. package/runtime/validators/checks/validate-assets-01.py +7 -8
  233. package/runtime/validators/validate-brief.py +77 -2
  234. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  235. package/runtime/validators/validate-run.py +59 -9
  236. package/runtime/validators/validate-schedule.py +9 -0
  237. package/docs/for-ai/README.md +0 -68
  238. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  239. package/docs/for-ai/skills/okstra-chat.md +0 -34
  240. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  241. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  242. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  243. package/docs/for-ai/skills/okstra-manager.md +0 -69
  244. package/docs/for-ai/skills/okstra-memory.md +0 -126
  245. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  246. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  247. package/docs/for-ai/skills/okstra-run.md +0 -250
  248. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  249. package/docs/for-ai/skills/okstra-setup.md +0 -158
  250. package/docs/for-ai/skills/okstra-usage.md +0 -29
  251. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  252. package/runtime/agents/workers/claude-worker.md +0 -128
  253. package/runtime/agents/workers/report-writer-worker.md +0 -37
  254. package/runtime/agents/workers/translator-worker.md +0 -63
  255. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  256. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  257. package/runtime/prompts/duties/analysis-worker.md +0 -44
  258. package/runtime/prompts/duties/code-reviewer.md +0 -44
  259. package/runtime/prompts/duties/common.md +0 -39
  260. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  261. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  262. package/runtime/prompts/duties/discovery-worker.md +0 -44
  263. package/runtime/prompts/duties/implementation-executor.md +0 -44
  264. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  265. package/runtime/prompts/duties/lead.md +0 -44
  266. package/runtime/prompts/duties/planning-worker.md +0 -52
  267. package/runtime/prompts/duties/report-writer.md +0 -44
  268. package/runtime/prompts/duties/reverification-worker.md +0 -44
  269. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  270. package/runtime/prompts/duties/scope-critic.md +0 -44
  271. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  272. package/runtime/prompts/duties/translator.md +0 -44
  273. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -41,6 +41,7 @@ _CLI_EPILOG = r"""Usage:
41
41
  okstra agent-prompt verify-completion [options]
42
42
  okstra agent-prompt record-dispatch [options]
43
43
  okstra agent-prompt link-result [options]
44
+ okstra agent-prompt refreeze-contracts --project-root <dir> --run-manifest <path> [--json]
44
45
  """
45
46
 
46
47
 
@@ -57,6 +58,12 @@ def _parser() -> argparse.ArgumentParser:
57
58
  _add_materialize_parser(commands)
58
59
  _add_completion_parsers(commands)
59
60
  _add_dispatch_parsers(commands)
61
+ operation = commands.add_parser(
62
+ "resolve-operation",
63
+ help="resolve the role, worker count and models an operation requires",
64
+ )
65
+ operation.add_argument("--operation", required=True)
66
+ operation.add_argument("--json", action="store_true")
60
67
  jobs = commands.add_parser("jobs", help="generate a verified jobs file without dispatching")
61
68
  _common_paths(jobs)
62
69
  jobs.add_argument("--run-manifest", required=True)
@@ -210,6 +217,15 @@ def _add_dispatch_parsers(commands: argparse._SubParsersAction) -> None:
210
217
  abandon.add_argument("--reason", required=True)
211
218
  abandon.add_argument("--json", action="store_true")
212
219
 
220
+ refreeze = commands.add_parser(
221
+ "refreeze-contracts",
222
+ help="re-freeze this run's duty contracts in the installed format "
223
+ "and stamp the manifest digest",
224
+ )
225
+ _common_paths(refreeze)
226
+ refreeze.add_argument("--run-manifest", required=True)
227
+ refreeze.add_argument("--json", action="store_true")
228
+
213
229
  link_result = commands.add_parser("link-result")
214
230
  _common_paths(link_result)
215
231
  link_result.add_argument("--run-manifest", required=True)
@@ -229,9 +245,99 @@ def _standalone_completion_args(parser: argparse.ArgumentParser) -> None:
229
245
  parser.add_argument("--json", action="store_true")
230
246
 
231
247
 
248
+ def _refreeze_contracts(args: argparse.Namespace) -> dict[str, object]:
249
+ """이 run 의 계약을 설치본 형식으로 다시 동결한다. 사용자만 부른다."""
250
+ from ...contract_refreeze import (
251
+ ContractRefreezeError,
252
+ refreeze_run_contracts,
253
+ )
254
+ from ...paths import find_asset_root
255
+
256
+ workspace_root = find_asset_root(("prompts", "duties"), is_present=Path.is_dir)
257
+ if workspace_root is None:
258
+ raise AgentPromptCliError(
259
+ "agent duty catalog not found: no prompts/duties under OKSTRA_HOME "
260
+ "or this checkout"
261
+ )
262
+ try:
263
+ result = refreeze_run_contracts(
264
+ project_root=Path(args.project_root),
265
+ run_manifest_path=Path(args.run_manifest),
266
+ workspace_root=workspace_root,
267
+ )
268
+ except ContractRefreezeError as exc:
269
+ raise AgentPromptCliError(str(exc)) from exc
270
+ return {
271
+ "dutyRoot": result.duty_root,
272
+ "digest": result.digest,
273
+ "previousDigest": result.previous_digest,
274
+ "formatVersion": result.format_version,
275
+ "previousFormatVersion": result.previous_format_version,
276
+ "changed": result.changed,
277
+ }
278
+
279
+
280
+ def _resolve_operation(operation_id: str) -> dict[str, object]:
281
+ """이 작업이 열 칸과 각 칸의 모델. 기술은 작업 이름만 알면 된다."""
282
+ from ...model_pool import ModelPool
283
+ from ...operation_invocation import (
284
+ OperationPreparationError,
285
+ resolve_operation_slots,
286
+ )
287
+ from ...paths import find_asset_root
288
+ from ...registry.provider_registry import default_provider_registry
289
+
290
+ root = find_asset_root(("agents", "operations"), is_present=Path.is_dir)
291
+ if root is None:
292
+ raise AgentPromptCliError(
293
+ "operation contracts not found: no agents/operations under "
294
+ "OKSTRA_HOME or this checkout"
295
+ )
296
+ try:
297
+ slots = resolve_operation_slots(
298
+ operation_id,
299
+ contract_root=root,
300
+ pool=ModelPool.from_registry(default_provider_registry()),
301
+ )
302
+ except OperationPreparationError as exc:
303
+ raise AgentPromptCliError(str(exc)) from exc
304
+ return {
305
+ "operation": operation_id,
306
+ "dutyId": slots[0].duty_id,
307
+ "roleId": slots[0].role_id,
308
+ "count": len(slots),
309
+ "slots": [
310
+ {
311
+ "ordinal": slot.ordinal,
312
+ "provider": slot.provider_id,
313
+ "modelRef": slot.model_ref,
314
+ "model": slot.display_name,
315
+ }
316
+ for slot in slots
317
+ ],
318
+ }
319
+
320
+
232
321
  def main(argv: list[str] | None = None) -> int:
233
322
  try:
234
323
  args = _parser().parse_args(argv)
324
+ if args.command == "resolve-operation":
325
+ payload = _resolve_operation(args.operation)
326
+ if args.json:
327
+ _emit(payload, True)
328
+ else:
329
+ # 기본 출력은 고정 텍스트다. 기술 본문이 모델에게 okstra 소유
330
+ # JSON 을 읽으라고 지시하면 그 해석이 곧 두 번째 파서가 된다.
331
+ print(f"operation: {payload['operation']}")
332
+ print(f"duty: {payload['dutyId']}")
333
+ print(f"role: {payload['roleId']}")
334
+ print(f"workers: {payload['count']}")
335
+ for slot in payload["slots"]:
336
+ print(
337
+ f" slot {slot['ordinal']}: "
338
+ f"provider={slot['provider']} model={slot['modelRef']}"
339
+ )
340
+ return 0
235
341
  if args.command == "jobs":
236
342
  payload = generate_jobs(args)
237
343
  if args.json:
@@ -277,6 +383,20 @@ def main(argv: list[str] | None = None) -> int:
277
383
  if args.command == "verify-completion":
278
384
  _verify_completion(args)
279
385
  return 0
386
+ if args.command == "refreeze-contracts":
387
+ payload = _refreeze_contracts(args)
388
+ if args.json:
389
+ _emit(payload, True)
390
+ else:
391
+ print(f"duty root: {payload['dutyRoot']}")
392
+ print(f"catalog digest: {payload['digest']}")
393
+ print(
394
+ "contract format: "
395
+ f"{payload['previousFormatVersion']} -> {payload['formatVersion']}"
396
+ if payload["changed"]
397
+ else "contract format: already current"
398
+ )
399
+ return 0
280
400
  if args.command == "record-dispatch":
281
401
  _record_dispatch(args)
282
402
  return 0
@@ -41,6 +41,16 @@ from ...worker_prompt_policy import (
41
41
  is_verification_dispatch_kind,
42
42
  )
43
43
  from ...paths import okstra_home
44
+ from ...contract_refreeze import (
45
+ CONTRACT_FORMAT_VERSION,
46
+ frozen_format_version,
47
+ refreeze_message,
48
+ )
49
+ from ...initial_prompt_materialization import (
50
+ InitialPromptMaterializationError,
51
+ implementation_anchor_lines,
52
+ required_resource_block,
53
+ )
44
54
  from ...final_report_paths import final_report_data_path
45
55
  from ...final_report_schema import load_schema_version
46
56
  from ...report_inputs import report_narrative_path, uses_report_contract_v3
@@ -300,6 +310,12 @@ def _materialize_run(
300
310
  must_exist=True,
301
311
  )
302
312
  if contract.get("catalogDigest") != digest_duty_catalog(duty_root):
313
+ # 형식이 바뀐 릴리스를 설치하면 옛 run 의 다이제스트는 영영 맞지 않는다.
314
+ # "다이제스트 불일치" 만 말하면 읽는 쪽은 파일이 변조된 줄 알고 그 자리를
315
+ # 뒤진다 — 실측 2026-09-23: 한 세션이 dispatch-kind 별 분기를 찾다가
316
+ # 멈췄고, 원인은 그 사이에 일어난 설치였다.
317
+ if frozen_format_version(contract) != CONTRACT_FORMAT_VERSION:
318
+ raise AgentPromptCliError(refreeze_message(str(duty_root)))
303
319
  raise AgentPromptCliError("run duty snapshot catalog digest does not match")
304
320
  active_context_path = _project_manifest_path(
305
321
  project_root,
@@ -357,6 +373,13 @@ def _materialize_run(
357
373
  "--corrections requires report contract 3.0 (a Markdown narrative); "
358
374
  "this run uses an older contract"
359
375
  )
376
+ if args.audience in _RESOURCE_AUDIENCES and not is_reverify:
377
+ anchor_lines = anchor_lines + tuple(
378
+ _implementation_anchors(project_root, manifest_path, args.worker_id)
379
+ )
380
+ body = _with_required_resources(
381
+ project_root, manifest_path, args.worker_id, body
382
+ )
360
383
  request = AgentInvocationRequest(
361
384
  invocation_id=args.invocation_id,
362
385
  worker_id=args.worker_id if identity is None else None,
@@ -410,6 +433,62 @@ def _materialize_run(
410
433
  return prepare_agent_invocation(request)
411
434
 
412
435
 
436
+ # 디스패치 composer 가 필수 자원 블록을 싣는 audience. 이 명령으로 먼저 발행된
437
+ # 프롬프트는 디스패치가 그대로 쓰므로, 블록을 여기서 붙이지 않으면 워커에 닿지 않는다.
438
+ _RESOURCE_AUDIENCES = frozenset({"implementation-executor", "implementation-verifier"})
439
+
440
+
441
+ def _implementation_anchors(
442
+ project_root: Path, manifest_path: Path, worker_id: str,
443
+ ) -> list[str]:
444
+ """디스패치 composer 와 같은 구현 앵커 줄."""
445
+ try:
446
+ return implementation_anchor_lines(
447
+ project_root=project_root,
448
+ run_manifest_path=manifest_path,
449
+ runtime_root=_runtime_root(),
450
+ worker_id=worker_id,
451
+ )
452
+ except InitialPromptMaterializationError as exc:
453
+ raise AgentPromptCliError(f"implementation prompt anchors: {exc}") from exc
454
+
455
+
456
+ def _runtime_root() -> Path:
457
+ from ...paths import find_asset_root
458
+
459
+ root = find_asset_root(("prompts", "profiles"), is_present=Path.is_dir)
460
+ if root is None:
461
+ raise AgentPromptCliError(
462
+ "prompt profiles not found: no prompts/profiles under OKSTRA_HOME "
463
+ "or this checkout"
464
+ )
465
+ return root
466
+
467
+
468
+ def _with_required_resources(
469
+ project_root: Path, manifest_path: Path, worker_id: str, body: str,
470
+ ) -> str:
471
+ """리드의 지시문 뒤에 composer 와 같은 필수 자원 블록을 붙인다.
472
+
473
+ executor 의 BLOCKING 게이트 세 본문, verifier 의 `_implementation-verifier.md`
474
+ (self-mock 검출기 호출문 포함), clarification 권한 블록, fix-run carry 다.
475
+ 배송 방식(eager / lazy)은 run 매니페스트의 선언을 따른다.
476
+ """
477
+ runtime_root = _runtime_root()
478
+ try:
479
+ block = required_resource_block(
480
+ project_root=project_root,
481
+ run_manifest_path=manifest_path,
482
+ runtime_root=runtime_root,
483
+ worker_id=worker_id,
484
+ )
485
+ except InitialPromptMaterializationError as exc:
486
+ raise AgentPromptCliError(f"required prompt resources: {exc}") from exc
487
+ if not block:
488
+ return body
489
+ return body.rstrip() + "\n\n" + block + "\n"
490
+
491
+
413
492
  def _complete_run_reverify_body(
414
493
  args: argparse.Namespace, manifest: Mapping[str, Any],
415
494
  active_context: Mapping[str, Any], assignment: AgentModelAssignment,
@@ -956,17 +1035,43 @@ def _snapshot_standalone_duties(destination: Path) -> None:
956
1035
  "OKSTRA_HOME or this checkout"
957
1036
  )
958
1037
  source = root.joinpath(*duties_relative)
1038
+ # 공통 계약은 직무가 아니라 `agents/` 가 소유한다(ADR-0017). 동결본은 한
1039
+ # 뿌리여야 하므로 여기서 함께 싣는다.
1040
+ common_root = find_asset_root(("agents", "common.json"), is_present=Path.is_file)
1041
+ if common_root is None:
1042
+ raise AgentPromptCliError(
1043
+ "agent common contract not found: no agents/common.json under "
1044
+ "OKSTRA_HOME or this checkout"
1045
+ )
1046
+ common_source = common_root / "agents" / "common.json"
1047
+
1048
+ def _staged_digest() -> str:
1049
+ staged = Path(tempfile.mkdtemp(prefix=".okstra-standalone-duties."))
1050
+ try:
1051
+ shutil.copytree(source, staged, dirs_exist_ok=True)
1052
+ shutil.copyfile(common_source, staged / "common.json")
1053
+ shutil.copytree(
1054
+ common_source.parent / "roles", staged / "roles", dirs_exist_ok=True
1055
+ )
1056
+ return digest_duty_catalog(staged)
1057
+ finally:
1058
+ shutil.rmtree(staged, ignore_errors=True)
1059
+
959
1060
  if destination.exists():
960
- if not destination.is_dir() or digest_duty_catalog(destination) != digest_duty_catalog(source):
1061
+ if not destination.is_dir() or digest_duty_catalog(destination) != _staged_digest():
961
1062
  raise AgentPromptCliError("standalone duty snapshot conflicts with existing files")
962
1063
  return
963
1064
  temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent))
964
1065
  try:
965
1066
  shutil.copytree(source, temporary, dirs_exist_ok=True)
1067
+ shutil.copyfile(common_source, temporary / "common.json")
1068
+ shutil.copytree(
1069
+ common_source.parent / "roles", temporary / "roles", dirs_exist_ok=True
1070
+ )
966
1071
  try:
967
1072
  os.rename(temporary, destination)
968
1073
  except FileExistsError:
969
- if digest_duty_catalog(destination) != digest_duty_catalog(source):
1074
+ if digest_duty_catalog(destination) != _staged_digest():
970
1075
  raise AgentPromptCliError("standalone duty snapshot publication conflict")
971
1076
  finally:
972
1077
  if temporary.exists():
@@ -58,56 +58,7 @@ def _validate_run_identity(
58
58
  ).duty_audience
59
59
  except ValueError as exc:
60
60
  raise AgentPromptCliError(str(exc)) from exc
61
- if audience != expected and _manifest_issued_role_execution(
62
- manifest, audience=audience, assignment_ref=assignment_ref
63
- ):
64
- # The prompt plan names a worker's role by worker id, so on an
65
- # implementation run the executor's provider resolves to the
66
- # executor role no matter which role execution is being targeted.
67
- # But the manifest issues one role execution per role, and a
68
- # provider serving as both executor and verifier gets two — a
69
- # normal roster, since the verifier contract accepts reusing the
70
- # executor's model behind its own session. Refusing the second
71
- # audience made a role execution okstra had itself issued
72
- # undispatchable, which costs the run an independent verifier.
73
- expected = audience
74
61
  if audience != expected:
75
62
  raise AgentPromptCliError(
76
63
  f"audience {audience!r} does not match required audience {expected!r}"
77
64
  )
78
-
79
-
80
- def _manifest_issued_role_execution(
81
- manifest: Mapping[str, Any],
82
- *,
83
- audience: str,
84
- assignment_ref: str,
85
- ) -> bool:
86
- """Whether this run issued a role execution for that audience's role.
87
-
88
- Keyed on the manifest's own rows, not on what the prompt plan infers from
89
- a worker id: the question is whether okstra created the execution being
90
- targeted, and only the manifest answers that.
91
- """
92
- from ...domain.role import RoleCatalogError, role_for_duty
93
-
94
- try:
95
- role = role_for_duty(audience)
96
- except RoleCatalogError:
97
- return False
98
- assignments = manifest.get("invocationAssignments")
99
- assignment = (
100
- assignments.get(assignment_ref) if isinstance(assignments, Mapping) else None
101
- )
102
- provider = (
103
- assignment.get("provider") if isinstance(assignment, Mapping) else None
104
- )
105
- if not provider:
106
- return False
107
- executions = manifest.get("roleExecutions")
108
- return any(
109
- isinstance(row, Mapping)
110
- and row.get("role") == role
111
- and row.get("provider") == provider
112
- for row in (executions if isinstance(executions, list) else [])
113
- )
@@ -500,7 +500,10 @@ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
500
500
  "in it is taken. Never reuse or renumber one: a new stage takes the",
501
501
  "next number after the highest listed here, and reworking a completed",
502
502
  "stage means cancelling it and adding a new number, never editing it",
503
- "in place. `sourcePlan` is the plan the completed stages were built",
503
+ "in place. The new plan declares every stage listed here as well as the",
504
+ "ones it adds — its rows run 1..N with no gap — because a plan whose",
505
+ "rows start above 1 is refused by every consumer that parses a Stage",
506
+ "Map. `sourcePlan` is the plan the completed stages were built",
504
507
  "against; `latestPlan` is the plan this list came from. When the two",
505
508
  "differ, the completed work followed the former and the numbering",
506
509
  "authority is the latter.",
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from collections.abc import Callable, Sequence
5
+ from dataclasses import replace
5
6
 
6
7
  from ..domain.worker_runtime import (
7
8
  EnvironmentBlocked,
@@ -19,11 +20,15 @@ def open_worker(
19
20
  last_unavailable: SurfaceUnavailable | None = None
20
21
  for port in chain:
21
22
  try:
22
- return port.spawn(request_for(port.surface))
23
+ handle = port.spawn(request_for(port.surface))
23
24
  except EnvironmentBlocked:
24
25
  raise
25
26
  except SurfaceUnavailable as exc:
26
27
  last_unavailable = exc
28
+ continue
29
+ if last_unavailable is None:
30
+ return handle
31
+ return replace(handle, fallback_reason=str(last_unavailable))
27
32
  if last_unavailable is None:
28
33
  raise SurfaceUnavailable("runtime chain is empty")
29
34
  raise last_unavailable
@@ -30,7 +30,7 @@ from .ports.host_model import (
30
30
  HostModelBindingPort,
31
31
  HostModelBindingRequest,
32
32
  )
33
- from .role_requirements import RoleProfile, RoleRequirement
33
+ from .role_requirements import RoleProfile, RoleRequirement, StaticRoleRequirement
34
34
 
35
35
 
36
36
  TerminalBackend = Literal[
@@ -239,8 +239,17 @@ def resolve_model_assignment(
239
239
  host: HostSessionContext,
240
240
  environment: AssignmentEnvironment,
241
241
  entry_mode: Literal["current-session", "new-session"] = "new-session",
242
+ require_selectable: bool = True,
242
243
  ) -> ResolvedAssignment:
243
- """Resolve one canonical model through the selected host execution path."""
244
+ """Resolve one canonical model through the selected host execution path.
245
+
246
+ `require_selectable=False` re-resolves a binding a run already recorded.
247
+ Selectability decides what a user may pick now; a run that picked a model
248
+ before it left the picker still has to finish. 실측 2026-09-23
249
+ (fontradar-v2-api dev-10860 implementation-planning-002): `gpt-5.6-sol` 이
250
+ 카탈로그에서 목록 제외로 바뀌자, 그 모델로 이미 세 라운드를 돈 run 의 다음
251
+ 디스패치가 `model binding changed: ... not-selectable` 로 막혔다.
252
+ """
244
253
  _validate_environment(host, environment)
245
254
  model = pool.resolve(model_ref)
246
255
  availability = pool.availability(
@@ -249,7 +258,9 @@ def resolve_model_assignment(
249
258
  host.host_id,
250
259
  entry_mode,
251
260
  )
252
- if not availability.available:
261
+ if not availability.available and (
262
+ require_selectable or availability.reason != "not-selectable"
263
+ ):
253
264
  raise AssignmentResolutionError(
254
265
  f"model {model_ref!r} is unavailable: {availability.reason}"
255
266
  )
@@ -326,8 +337,8 @@ def resolve_model_selection(
326
337
  return selected
327
338
 
328
339
 
329
- def _static_requirements(profile: RoleProfile) -> tuple[RoleRequirement, ...]:
330
- leader = RoleRequirement("leader", 1, 1, 1, "lead")
340
+ def _static_requirements(profile: RoleProfile) -> tuple[StaticRoleRequirement, ...]:
341
+ leader = StaticRoleRequirement("leader", 1, 1, 1, "lead")
331
342
  return (leader, *(row for row in profile.roles if not row.dynamic))
332
343
 
333
344
 
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import json
9
9
  import os
10
+ import re
10
11
  import shlex
11
12
  import shutil
12
13
  import socket
@@ -72,6 +73,10 @@ SOCKET_UNREACHABLE = "unreachable"
72
73
 
73
74
  SOCKET_PROBE_TIMEOUT_SECONDS = 2
74
75
 
76
+ # cmux 가 돌려주는 식별자가 UUID 인지 ref 인지 가리는 자리. ref 는 번호가 다시
77
+ # 매겨지므로 원장에 남길 수 없다.
78
+ _UUID_PATTERN = re.compile(r"[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}")
79
+
75
80
 
76
81
  def cmux_cli_path() -> str:
77
82
  """Absolute path to the cmux CLI, or "" when cmux is not installed.
@@ -455,7 +460,11 @@ def spawn_worker_surface(
455
460
  owned_surface_ids=owned_surface_ids,
456
461
  )
457
462
  target = _pane_by_id(panes, placement.pane_id)
458
- surface_uuid = _open_worker_surface(workspace, placement, target)
463
+ try:
464
+ surface_uuid = _create_surface(workspace, placement, target)
465
+ except (RuntimeError, OSError, subprocess.SubprocessError):
466
+ _restore_lead_after_failed_spawn(workspace, owned_surface_ids)
467
+ raise
459
468
  try:
460
469
  renamed = rename_surface(surface_uuid, title)
461
470
  if renamed.returncode != 0:
@@ -475,10 +484,35 @@ def spawn_worker_surface(
475
484
  raise RuntimeError(
476
485
  f"{exc}; cleanup failed for cmux surface {surface_uuid}: {cleanup_error}"
477
486
  ) from exc
487
+ finally:
488
+ _restore_lead_after_failed_spawn(workspace, owned_surface_ids)
478
489
  raise
479
490
  return surface_uuid
480
491
 
481
492
 
493
+ def _restore_lead_after_failed_spawn(
494
+ workspace: str, owned_surface_ids: Collection[str]
495
+ ) -> None:
496
+ """실패한 분할이 리드에서 떼어 간 폭을 돌려준다.
497
+
498
+ 폭을 되돌리는 자리는 창이 열린 뒤의 `_size_lead_pane` 하나뿐이었다. 실패한
499
+ 시도는 리드에서 폭만 떼어 가고 끝났고, 실패가 반복되면 축소가 누적된다 —
500
+ 실측 2026-09-21, jobs dev-10860: 강등 7건 뒤 리드가 64칼럼에서 14칼럼.
501
+
502
+ 닫지 못한 창의 폭까지 되찾지는 않는다. 그 창은 `lead_target_width` 에서
503
+ 남의 창으로 셈해져 usable 에서 빠지고, 리드는 남은 공간에서 제 몫을 가져
504
+ 간다. 되찾으려면 okstra 가 자기 것이라 증명하지 못한 창의 경계를 미는
505
+ 셈이라 그쪽이 더 나쁘다.
506
+
507
+ 크기 조정이 실패해도 삼킨다: 호출자는 창을 열지 못한 사유를 들고 올라가는
508
+ 중이고, 원장에 남아야 할 문장은 그쪽이다.
509
+ """
510
+ try:
511
+ _size_lead_pane(workspace, owned_surface_ids)
512
+ except (RuntimeError, OSError, subprocess.SubprocessError):
513
+ return
514
+
515
+
482
516
  def rename_surface(surface: str, title: str) -> subprocess.CompletedProcess[str]:
483
517
  """Set the tab title cmux shows for one surface.
484
518
 
@@ -686,27 +720,14 @@ def rpc(method: str, params: dict[str, Any]) -> dict[str, Any]:
686
720
  return payload if isinstance(payload, dict) else {}
687
721
 
688
722
 
689
- def _open_worker_surface(
690
- workspace: str, placement: Placement, target: PaneGeometry
691
- ) -> str:
692
- before = open_surface_ids(workspace)
693
- _create_surface(workspace, placement, target)
694
- new_ids = open_surface_ids(workspace) - before
695
- if len(new_ids) != 1:
696
- raise RuntimeError(
697
- f"cmux opened {len(new_ids)} surfaces where exactly one was expected"
698
- )
699
- return new_ids.pop()
700
-
701
-
702
723
  def open_surface_ids(workspace: str) -> set[str]:
703
724
  """Every surface UUID the workspace currently holds.
704
725
 
705
- Dispatch diffs this set across a create to identify the new surface, rather
706
- than translating the `OK surface:N` echo, because `list-pane-surfaces`
707
- reports only the focused pane unless given a `--pane`, and the new pane is
708
- not focused. Teardown intersects its recorded ids with it to tell a surface
709
- that is still open from one that closed earlier in the run.
726
+ Teardown intersects its recorded ids with this set to tell a surface that is
727
+ still open from one that closed earlier in the run. It is teardown's only
728
+ caller: dispatch used to diff this set across a create to name the surface
729
+ it had just opened, and that is now `new-split --id-format uuids`, which
730
+ says the uuid outright.
710
731
  """
711
732
  return {
712
733
  surface_id
@@ -717,9 +738,22 @@ def open_surface_ids(workspace: str) -> set[str]:
717
738
 
718
739
  def _create_surface(
719
740
  workspace: str, placement: Placement, target: PaneGeometry
720
- ) -> None:
741
+ ) -> str:
742
+ """새 창을 열고 그 표면의 UUID 를 준다.
743
+
744
+ UUID 는 cmux 가 스스로 말해 준다. `--id-format uuids` 를 주면 이 명령의 출력이
745
+ `OK <surface-uuid> <workspace-uuid>` 가 되고, 그 첫 UUID 가 새 창의 표면이다
746
+ (실측 2026-09-21, 폐기용 워크스페이스 1회 분할: echo 의 UUID 가 직후
747
+ `pane.list` 가 보여 준 새 창의 `surface_ids[0]` 과 같았다).
748
+
749
+ 앞서는 분할 전후의 표면 목록 차집합으로 알아냈는데, 그 차집합이 실제 run 에서
750
+ 여덟 번 중 여덟 번 비어 워커가 창에 붙지 못하고 빈 창만 남았다. 자기가 만든 것의
751
+ 이름을 만든 쪽에 묻는 편이 두 번 읽어 비교하는 것보다 틀릴 자리가 적다.
752
+ """
721
753
  created = run_cmux(
722
754
  [
755
+ "--id-format",
756
+ "uuids",
723
757
  "new-split",
724
758
  placement.direction,
725
759
  "--workspace",
@@ -730,6 +764,21 @@ def _create_surface(
730
764
  )
731
765
  if created.returncode != 0:
732
766
  raise RuntimeError(created.stderr.strip() or "cmux could not open a pane")
767
+ return _surface_from_split_echo(created.stdout)
768
+
769
+
770
+ def _surface_from_split_echo(stdout: str) -> str:
771
+ """`new-split` 의 출력에서 새 표면 UUID 만 꺼낸다.
772
+
773
+ ref(`surface:2`)는 받지 않는다. okstra 가 기록하고 나중에 닫는 이름이라, cmux 가
774
+ 창을 여닫을 때마다 번호를 다시 매기는 ref 로는 엉뚱한 창을 닫게 된다.
775
+ """
776
+ parts = stdout.split()
777
+ if len(parts) >= 2 and parts[0] == "OK" and _UUID_PATTERN.fullmatch(parts[1]):
778
+ return parts[1]
779
+ raise RuntimeError(
780
+ f"cmux did not name the pane it opened: {stdout.strip()!r}"
781
+ )
733
782
 
734
783
 
735
784
  def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> None:
@@ -99,13 +99,13 @@ def _branch_snapshot(repo: Path, branch: str, base: str, review_date: str) -> di
99
99
 
100
100
 
101
101
  _CLI_EPILOG = r"""Usage:
102
- okstra code-review target --task-key <k> --stage <N> [--project-root <dir>] [--cwd <dir>] --json
102
+ okstra code-review target --task-key <k> --stage <N> [--base <ref>] [--project-root <dir>] [--cwd <dir>] --json
103
103
  okstra code-review target --branch <name> [--base <ref>] [--date <YYYY-MM-DD>] [--project-root <dir>] --json
104
104
 
105
105
  Output: JSON { ok, mode, worktreePath, branch, baseCommit, headCommit,
106
- reviewPath, round }. Stage mode reads the base commit from the stage
107
- registry row; branch mode falls back to the merge-base with the default
108
- branch. An empty worktreePath means the review reads the branch ref
106
+ reviewPath, round }. `--base` sets the base in either mode. Without it, stage
107
+ mode reads the base commit from the stage registry row and branch mode falls
108
+ back to the merge-base with the default branch. An empty worktreePath means the review reads the branch ref
109
109
  instead of a checked-out tree. This is read-only — it never creates
110
110
  directories or files.
111
111
  """
@@ -124,7 +124,10 @@ def _build_parser() -> argparse.ArgumentParser:
124
124
  parser.add_argument("--task-key", default="", help="project-id:task-group:task-id")
125
125
  parser.add_argument("--stage", type=int, default=0, help="stage number to review")
126
126
  parser.add_argument("--branch", default="", help="branch to review")
127
- parser.add_argument("--base", default="", help="explicit base ref for branch mode")
127
+ parser.add_argument(
128
+ "--base", default="",
129
+ help="explicit base ref; overrides the stage registry base or the branch merge-base",
130
+ )
128
131
  parser.add_argument("--date", default="", help="YYYY-MM-DD for branch result naming")
129
132
  parser.add_argument("--project-root", default="", help="explicit project root")
130
133
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
@@ -138,7 +141,9 @@ def _resolve(args: argparse.Namespace, project_root: Path) -> dict:
138
141
  if args.task_key:
139
142
  if args.stage < 1:
140
143
  raise StateError("--task-key needs --stage <N>, N >= 1", stage="args")
141
- return code_review_target_snapshot(project_root, args.task_key, args.stage)
144
+ return code_review_target_snapshot(
145
+ project_root, args.task_key, args.stage, base=args.base
146
+ )
142
147
  if args.branch:
143
148
  return _branch_snapshot(
144
149
  project_root, args.branch, args.base, _review_date(args.date)
@@ -204,7 +209,7 @@ def main(argv: list[str] | None = None) -> int:
204
209
  # 것으로 합쳐진다.
205
210
 
206
211
  def code_review_target_snapshot(
207
- project_root: Path, task_key: str, stage: int
212
+ project_root: Path, task_key: str, stage: int, *, base: str = ""
208
213
  ) -> dict:
209
214
  """stage 코드 리뷰가 무엇을 읽고 결과를 어디에 쓰는지 해소한다.
210
215
 
@@ -218,6 +223,9 @@ def code_review_target_snapshot(
218
223
  표류한다: 다중의존 stage 는 오늘의 task-key worktree HEAD 를 받게 되어
219
224
  whole-task final-verification 이 stage 들을 병합한 뒤에는 base..head 구간이
220
225
  비거나 뒤집히고, 단일의존 stage 는 선행 stage 가 재실행되면 어긋난다.
226
+
227
+ `base` 가 주어지면 그 값을 쓴다 — 스킬이 기록된 base 를 보여 주고 사용자가
228
+ 다른 ref 를 고른 경우다. 무시하면 사용자가 거부한 범위로 리뷰가 진행된다.
221
229
  """
222
230
  identity = resolve_task_identity(project_root, task_key)
223
231
  task_root = Path(identity["taskRoot"])
@@ -241,7 +249,7 @@ def code_review_target_snapshot(
241
249
  coords = (identity["projectId"], identity["taskGroup"], identity["taskId"])
242
250
  stage_row = worktree_registry.get_stage_row(*coords, stage) or {}
243
251
  worktree_view = _stage_worktree_view(project_root, stage_row, stage)
244
- base_commit = stage_row.get("base_ref") or _legacy_stage_base_commit(
252
+ base_commit = base or stage_row.get("base_ref") or _legacy_stage_base_commit(
245
253
  identity, stages, selected
246
254
  )
247
255
  review_path, round_no = code_review_paths.next_stage_review(