okstra 0.189.3 → 0.190.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +3 -3
  15. package/docs/cli.md +11 -9
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +15 -5
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +2 -1
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  50. package/runtime/prompts/profiles/implementation.md +2 -0
  51. package/runtime/prompts/profiles/release-handoff.md +4 -3
  52. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  53. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  56. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  58. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  64. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  66. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  67. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
  68. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  72. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  73. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  74. package/runtime/python/okstra_ctl/cmux.py +34 -13
  75. package/runtime/python/okstra_ctl/consumers.py +30 -7
  76. package/runtime/python/okstra_ctl/convergence.py +110 -21
  77. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  78. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  79. package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
  80. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  81. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  82. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  83. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  84. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  85. package/runtime/python/okstra_ctl/group_context.py +475 -10
  86. package/runtime/python/okstra_ctl/handoff.py +109 -32
  87. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  88. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  89. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  90. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  91. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  92. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  93. package/runtime/python/okstra_ctl/recap.py +179 -17
  94. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  95. package/runtime/python/okstra_ctl/render.py +34 -8
  96. package/runtime/python/okstra_ctl/report_assembly.py +17 -10
  97. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  98. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  99. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  100. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  101. package/runtime/python/okstra_ctl/report_html/render.py +69 -17
  102. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  103. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  104. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  105. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  106. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  107. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  108. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  109. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  110. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  111. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  112. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  113. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  114. package/runtime/python/okstra_ctl/run.py +54 -14
  115. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  116. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  117. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  118. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  119. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  120. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  121. package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
  122. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  123. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  124. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  125. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  126. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  127. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  128. package/runtime/python/okstra_ctl/wizard/state.py +689 -0
  129. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  130. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  131. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  132. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  133. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  134. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  135. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  136. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  137. package/runtime/python/okstra_ctl/workflow.py +2 -2
  138. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  139. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  140. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  141. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  142. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  143. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  144. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  145. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  146. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  147. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  148. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  149. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  150. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  151. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  152. package/runtime/skills/okstra-run/SKILL.md +17 -7
  153. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  154. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  155. package/runtime/templates/reports/brief.template.md +15 -7
  156. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  157. package/runtime/templates/reports/group-context.template.md +7 -0
  158. package/runtime/templates/reports/html/base.template.html +13 -3
  159. package/runtime/templates/reports/html/i18n/en.json +43 -5
  160. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  161. package/runtime/templates/reports/html/macros/forms.html +4 -4
  162. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  163. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  164. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  165. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  166. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  167. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  168. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  169. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  170. package/runtime/templates/reports/schedule.template.md +13 -4
  171. package/runtime/templates/reports/task-brief.template.md +1 -1
  172. package/runtime/templates/reverify-output-contract.md +5 -0
  173. package/runtime/templates/worker-error-contract.md +2 -0
  174. package/runtime/templates/worker-prompt-preamble.md +1 -1
  175. package/runtime/validators/validate-report-views.py +30 -1
  176. package/runtime/validators/validate-run.py +194 -25
  177. package/runtime/validators/validate_session_conformance.py +102 -2
  178. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -0,0 +1,689 @@
1
+ """위저드 상태 — `WizardState`·`Prompt`·`Option`·`Step` 데이터 타입, v1 provider 선택의 v2 변환, 상태 술어."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Optional
7
+
8
+ from okstra_ctl.registry.provider_registry import default_provider_registry
9
+ from okstra_ctl.model_pool import ModelPool
10
+ from okstra_ctl.role_requirements import (
11
+ RoleProfile,
12
+ RoleProfileError,
13
+ RoleRequirement,
14
+ load_role_profile,
15
+ )
16
+ from okstra_ctl.ids import slugify_task_segment
17
+
18
+ from .ids import (
19
+ PICK_TYPE_CUSTOM,
20
+ S_BASE_REF_PICK,
21
+ S_HANDOFF_STAGE_PICK,
22
+ _ABORT_OPTION,
23
+ _SLUG_OK,
24
+ _STAGE_SCOPED_TASK_TYPES,
25
+ )
26
+
27
+
28
+ # ---- Data types ----------------------------------------------------------
29
+
30
+ @dataclass
31
+ class WizardState:
32
+ # execution identity v2: role-first canonical model selections
33
+ execution_identity_version: int = 1
34
+ role_counts: dict[str, int] = field(default_factory=dict)
35
+ role_models: dict[str, list[str]] = field(default_factory=dict)
36
+ role_selection_order: list[str] = field(default_factory=list)
37
+
38
+ # bootstrap
39
+ workspace_root: str = ""
40
+ project_root: str = ""
41
+ project_id: str = ""
42
+ host_runtime: str = "claude-code"
43
+ host_entry_mode: str = "current-session"
44
+ available_functions: list[str] = field(default_factory=list)
45
+
46
+ # task identity
47
+ is_new_task: Optional[bool] = None
48
+ task_group: str = ""
49
+ task_id: str = ""
50
+ existing_brief_path: str = ""
51
+ # brief-derived suggestions (new-task flow only; set when brief is
52
+ # accepted, cleared if the user picks "type custom" so the next
53
+ # `_build_*` falls back to plain text input)
54
+ task_group_suggestion: str = ""
55
+ task_id_suggestion: str = ""
56
+ task_group_pending_text: bool = False
57
+ task_id_pending_text: bool = False
58
+
59
+ # task-type + dependents
60
+ task_type: str = ""
61
+ profile_workers: list[str] = field(default_factory=list)
62
+ profile_optional_workers: list[str] = field(default_factory=list)
63
+
64
+ # brief
65
+ keep_existing_brief: Optional[bool] = None
66
+ brief_path: str = ""
67
+ brief_path_pending_text: bool = False
68
+
69
+ # analysis evidence and target
70
+ project_evidence_path: str = ""
71
+ project_evidence_pending_text: bool = False
72
+ feature_evidence_path: str = ""
73
+ feature_evidence_pending_text: bool = False
74
+ analysis_target: str = ""
75
+ analysis_target_pending_text: bool = False
76
+
77
+ # worktree
78
+ reuse_worktree: Optional[bool] = None
79
+ base_ref: str = ""
80
+ base_ref_pending_text: bool = False
81
+
82
+ # impl extras
83
+ approved_plan_path: str = ""
84
+ approved_plan_pending_text: bool = False
85
+ # A plan that is approvable (gate ok, no blockers) but not yet `approved`.
86
+ # Set when the user selects such a plan; the approve-confirm step reads it.
87
+ approve_plan_candidate: str = ""
88
+ # HTML Plan Approval 위젯이 남긴 sidecar 감지 결과. 선택된 plan 과
89
+ # source-report·seq 가 일치하는 APPROVAL 블록이 있을 때만 채워지고,
90
+ # approve-confirm 단계가 3-옵션(yes_apply/yes/no)으로 확장된다.
91
+ html_approval_sidecar: str = ""
92
+ html_approval_option: str = ""
93
+ design_prep_queue: list[str] = field(default_factory=list)
94
+ design_prep_current: str = ""
95
+ design_prep_decision: str = ""
96
+ design_prep_overrides_json: str = ""
97
+ design_prep_notes: str = ""
98
+ selected_stage: str = "auto"
99
+ selected_stages: str = "" # implementation 다중선택 위상정렬 CSV
100
+ executor: str = ""
101
+ critic: str = ""
102
+ critic_pending_text: bool = False
103
+
104
+ # release-handoff: PR 로 내보낼 범위. mode 는 stages 선택의 파생값이다 —
105
+ # whole-task = 빈 stages(whole-task 검증 기반 단일 PR), stage-group = csv.
106
+ handoff_mode: str = "" # "" | "whole-task" | "stage-group"
107
+ handoff_stages: str = "" # csv ("2,3"), whole-task 면 ""
108
+
109
+ # resume: 직전 run-inputs 재사용 여부 (None=미응답, True=재사용, False=재입력)
110
+ reuse_previous: Optional[bool] = None
111
+
112
+ # customize
113
+ use_defaults: Optional[bool] = None
114
+ workers_override: str = ""
115
+ workers_custom_pending: bool = False
116
+ lead_provider: str = ""
117
+ lead_model: str = ""
118
+ claude_model: str = ""
119
+ codex_model: str = ""
120
+ antigravity_model: str = ""
121
+ grok_model: str = ""
122
+ kimi_model: str = ""
123
+ report_writer_provider: str = ""
124
+ report_writer_model: str = ""
125
+ directive: str = ""
126
+ directive_pending_text: bool = False
127
+ last_directive_cached: str = ""
128
+ related_tasks_raw: str = ""
129
+ related_tasks_pending_text: bool = False
130
+ last_siblings_cached: str = ""
131
+ clarification_response_path: str = ""
132
+ clarification_pending_text: bool = False
133
+ last_final_report_cached: str = ""
134
+ selected_direction_path: str = ""
135
+ # "" | "auto" | "full" | "<stage csv>" — 사용자가 고른 이번 재실행의 재검증
136
+ # 범위. implementation-planning 재실행에서 좁힐 여지가 있을 때만 채워진다.
137
+ reverify_scope: str = ""
138
+ reverify_scope_pending_text: bool = False
139
+ pr_template_path: str = ""
140
+ pr_template_pending_text: bool = False
141
+ pr_template_scope: str = "" # "once" | "project" | "global"
142
+ last_pr_template_cached: str = ""
143
+
144
+ # confirm / edit
145
+ # "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
146
+ fix_cycle: str = ""
147
+ confirmed: Optional[bool] = None
148
+ edit_target: str = ""
149
+ # terminal: user picked 중단 — no further prompt ever applies
150
+ aborted: bool = False
151
+
152
+ # bookkeeping
153
+ answered: list[str] = field(default_factory=list)
154
+
155
+ def to_json(self) -> dict[str, Any]:
156
+ payload = asdict(self)
157
+ payload.pop("execution_identity_version")
158
+ payload["executionIdentityVersion"] = 2
159
+ payload["roleCounts"] = payload.pop("role_counts")
160
+ payload["roleModels"] = payload.pop("role_models")
161
+ payload["roleSelectionOrder"] = payload.pop("role_selection_order")
162
+ for field_name in _V1_PROVIDER_STATE_FIELDS:
163
+ payload.pop(field_name, None)
164
+ return payload
165
+
166
+ @classmethod
167
+ def from_json(cls, d: dict[str, Any]) -> "WizardState":
168
+ return _wizard_state_from_json(d)
169
+
170
+
171
+ @dataclass
172
+ class Option:
173
+ value: str
174
+ label: str
175
+ description: str = ""
176
+ # 이 run 이 계산한 추천. 표시는 이 한 곳에서만 나온다 — 종전에는 추천이
177
+ # 순서로만 암시됐고(첫 항목), 리드는 그와 별개로 산문에서 자기 추천을
178
+ # 적으라는 지시를 받았다. 둘이 갈리면 사용자는 서로 다른 두 추천을 동시에
179
+ # 본다(실측: 계획 run 의 clarification 단계 — 리드는 `직접 입력` 을 권했고
180
+ # 화면 첫 줄은 위저드의 캐시된 리포트였다).
181
+ recommended: bool = False
182
+
183
+
184
+ @dataclass
185
+ class Prompt:
186
+ step: str
187
+ kind: str # "pick" | "text" | "pick_group" | "done" | "aborted"
188
+ label: str = ""
189
+ options: list[Option] = field(default_factory=list)
190
+ help: str = ""
191
+ echo_template: str = "" # e.g. "task-group: {value}"
192
+ multi: bool = False # only meaningful when kind == "pick"
193
+ # only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
194
+ questions: list["Prompt"] = field(default_factory=list)
195
+
196
+ def __post_init__(self) -> None:
197
+ """자유 입력 선택지는 언제나 마지막이다.
198
+
199
+ 목록이 자유 입력으로 열리면 질문을 통째로 사용자에게 되돌려준 것이다 —
200
+ 앞선 선택지들이 이 run 이 이미 알아낸 것이고, 자유 입력은 그것들이
201
+ 전부 맞지 않을 때의 탈출구다. 실측: 재검증 범위 질문의 `unlinked`
202
+ 분기가 `직접 입력` 을 첫 선택지로 내놓았고, 리드가 preamble 에 해당
203
+ stage 번호를 이미 적어 놓은 화면에서도 사용자는 그 번호를 다시
204
+ 타이핑해야 했다.
205
+
206
+ 중단(abort)만 그 뒤에 올 수 있다. 중단은 탈출구가 아니라 취소이고,
207
+ `brief_carry` 가 실제로 그 모양이다 — 전환(추천) / 직접 입력 / 중단.
208
+
209
+ 배치 지점이 12곳이라 관례로는 지켜지지 않는다. 값을 만드는 자리에서
210
+ 막는다.
211
+ """
212
+ values = [option.value for option in self.options]
213
+ if PICK_TYPE_CUSTOM not in values:
214
+ return
215
+ after = values[values.index(PICK_TYPE_CUSTOM) + 1:]
216
+ offenders = [value for value in after if value != _ABORT_OPTION]
217
+ if offenders:
218
+ raise WizardError(
219
+ f"wizard step {self.step!r}: the free-input option "
220
+ f"({PICK_TYPE_CUSTOM!r}) must come after every real choice — "
221
+ f"only {_ABORT_OPTION!r} may follow it, but {offenders} do"
222
+ )
223
+ self._check_recommendations()
224
+
225
+ def _check_recommendations(self) -> None:
226
+ """추천은 목록 앞머리에 모여 있고, 탈출구는 추천이 아니다.
227
+
228
+ 추천을 목록 중간에 흩어 놓으면 화면에서 어디까지가 이 run 의 판단인지
229
+ 읽히지 않는다. 그리고 `직접 입력` / `중단` 은 앞의 선택지가 전부 맞지
230
+ 않을 때의 탈출구이므로 추천 대상이 될 수 없다.
231
+ """
232
+ flags = [option.recommended for option in self.options]
233
+ if any(flags) and any(flags[index] for index in range(len(flags))
234
+ if index > 0 and not flags[index - 1]):
235
+ raise WizardError(
236
+ f"wizard step {self.step!r}: recommended options must be the "
237
+ "leading run of the list"
238
+ )
239
+ escapes = {PICK_TYPE_CUSTOM, _ABORT_OPTION}
240
+ marked = [
241
+ option.value for option in self.options
242
+ if option.recommended and option.value in escapes
243
+ ]
244
+ if marked:
245
+ raise WizardError(
246
+ f"wizard step {self.step!r}: {marked} is an escape hatch, "
247
+ "not a recommendation"
248
+ )
249
+
250
+ @property
251
+ def id(self) -> str:
252
+ """Stable public identifier shared by static and role-instance prompts."""
253
+ return self.step
254
+
255
+ def to_json(self) -> dict[str, Any]:
256
+ out = {
257
+ "id": self.step,
258
+ "step": self.step,
259
+ "kind": self.kind,
260
+ "label": self.label,
261
+ "options": [asdict(o) for o in self.options],
262
+ "help": self.help,
263
+ "echoTemplate": self.echo_template,
264
+ "multi": self.multi,
265
+ }
266
+ if self.kind == "pick_group":
267
+ out["questions"] = [
268
+ {"step": q.step, "label": q.label,
269
+ "options": [asdict(o) for o in q.options],
270
+ "multi": q.multi}
271
+ for q in self.questions
272
+ ]
273
+ return out
274
+
275
+
276
+ class WizardError(Exception):
277
+ """validation failure surfaced to user."""
278
+
279
+
280
+ def _slug_or_die(value: str, field_name: str) -> str:
281
+ slug = slugify_task_segment(value or "")
282
+ if not slug or not _SLUG_OK.search(slug):
283
+ raise WizardError(
284
+ f"{field_name} must contain at least one alphanumeric character "
285
+ f"(got: {value!r})"
286
+ )
287
+ return slug
288
+
289
+
290
+ # ---- Roster / profile helpers -------------------------------------------
291
+
292
+ def _profile_path(workspace_root: Path, task_type: str) -> Path:
293
+ return workspace_root / "prompts" / "profiles" / f"{task_type}.md"
294
+
295
+
296
+ _V2_STATE_FIELDS = (
297
+ "roleCounts",
298
+ "roleModels",
299
+ "roleSelectionOrder",
300
+ )
301
+
302
+
303
+ _V1_PROVIDER_STATE_FIELDS = (
304
+ "workers_override",
305
+ "lead_provider",
306
+ "lead_model",
307
+ "claude_model",
308
+ "codex_model",
309
+ "antigravity_model",
310
+ "grok_model",
311
+ "kimi_model",
312
+ "report_writer_provider",
313
+ "report_writer_model",
314
+ "executor",
315
+ "critic",
316
+ )
317
+
318
+
319
+ _V1_PROVIDER_STATE_JSON_FIELDS = frozenset({
320
+ *_V1_PROVIDER_STATE_FIELDS,
321
+ "workers",
322
+ "leadProvider",
323
+ "leadModel",
324
+ "claudeModel",
325
+ "codexModel",
326
+ "antigravityModel",
327
+ "grokModel",
328
+ "kimiModel",
329
+ "reportWriterProvider",
330
+ "reportWriterModel",
331
+ })
332
+
333
+
334
+ def _load_role_profile_for_state(state: WizardState) -> RoleProfile:
335
+ try:
336
+ return load_role_profile(
337
+ _profile_path(Path(state.workspace_root), state.task_type)
338
+ )
339
+ except RoleProfileError as exc:
340
+ raise WizardError(str(exc)) from exc
341
+
342
+
343
+ def _role_selection_enabled(state: WizardState) -> bool:
344
+ if (
345
+ state.execution_identity_version != 2
346
+ or not state.workspace_root
347
+ or not state.task_type
348
+ ):
349
+ return False
350
+ try:
351
+ load_role_profile(_profile_path(Path(state.workspace_root), state.task_type))
352
+ except (OSError, RoleProfileError):
353
+ return False
354
+ return True
355
+
356
+
357
+ def _legacy_value(payload: dict[str, Any], snake: str, camel: str) -> Any:
358
+ return payload.get(snake, payload.get(camel, ""))
359
+
360
+
361
+ def _legacy_model_ref(
362
+ pool: ModelPool,
363
+ provider: str,
364
+ role: str,
365
+ raw_model: Any,
366
+ ) -> str | None:
367
+ provider_id = provider.strip().lower()
368
+ if not provider_id:
369
+ return None
370
+ model_value = raw_model.strip() if isinstance(raw_model, str) else ""
371
+ try:
372
+ model = (
373
+ pool.resolve_alias(provider_id, model_value)
374
+ if model_value
375
+ else pool.default_candidate(provider_id, role)
376
+ )
377
+ except (ValueError, RuntimeError):
378
+ return None
379
+ if model is None:
380
+ return None
381
+ availability = pool.availability(str(model.model_ref), role)
382
+ return str(model.model_ref) if availability.available else None
383
+
384
+
385
+ def _legacy_worker_providers(payload: dict[str, Any]) -> list[str]:
386
+ raw = _legacy_value(payload, "workers_override", "workers")
387
+ if isinstance(raw, list):
388
+ values = raw
389
+ elif isinstance(raw, str):
390
+ values = raw.split(",")
391
+ else:
392
+ values = []
393
+ return [
394
+ value.strip().lower()
395
+ for value in values
396
+ if isinstance(value, str)
397
+ and value.strip()
398
+ and value.strip().lower() != "report-writer"
399
+ ]
400
+
401
+
402
+ def _initial_cross_verification_requirement(
403
+ profile: RoleProfile,
404
+ ) -> RoleRequirement | None:
405
+ return next((
406
+ requirement
407
+ for requirement in profile.roles
408
+ if not requirement.dynamic
409
+ and requirement.role in {
410
+ "analyser",
411
+ "designer",
412
+ "planner",
413
+ "verifier",
414
+ }
415
+ ), None)
416
+
417
+
418
+ def _convert_v1_provider_selections(
419
+ state: WizardState,
420
+ payload: dict[str, Any],
421
+ ) -> None:
422
+ if not _role_selection_enabled(state):
423
+ return
424
+ profile = _load_role_profile_for_state(state)
425
+ pool = ModelPool.from_registry(default_provider_registry())
426
+ cross_requirement = _initial_cross_verification_requirement(profile)
427
+ providers = _legacy_worker_providers(payload)
428
+ if cross_requirement is not None:
429
+ selected_count = cross_requirement.recommended_count
430
+ if providers and cross_requirement.min_count < cross_requirement.max_count:
431
+ selected_count = min(
432
+ cross_requirement.max_count,
433
+ max(cross_requirement.min_count, len(providers)),
434
+ )
435
+ if cross_requirement.min_count < cross_requirement.max_count:
436
+ state.role_counts[cross_requirement.role] = selected_count
437
+ count_id = f"role-count:{cross_requirement.role}"
438
+ state.role_selection_order.append(count_id)
439
+ if not providers:
440
+ providers = [
441
+ model.provider_id
442
+ for model in pool.default_candidates(cross_requirement.role)
443
+ ]
444
+ for ordinal, provider in enumerate(providers[:selected_count], start=1):
445
+ raw_model = _legacy_value(
446
+ payload,
447
+ f"{provider}_model",
448
+ f"{provider}Model",
449
+ )
450
+ model_ref = _legacy_model_ref(
451
+ pool,
452
+ provider,
453
+ cross_requirement.role,
454
+ raw_model,
455
+ )
456
+ if model_ref is None:
457
+ break
458
+ state.role_models.setdefault(cross_requirement.role, []).append(model_ref)
459
+ state.role_selection_order.append(
460
+ f"role-model:{cross_requirement.role}:{ordinal}"
461
+ )
462
+
463
+ provider_rows = (
464
+ (
465
+ "leader",
466
+ _legacy_value(payload, "lead_provider", "leadProvider") or "claude",
467
+ _legacy_value(payload, "lead_model", "leadModel"),
468
+ ),
469
+ (
470
+ "report-writer",
471
+ _legacy_value(
472
+ payload,
473
+ "report_writer_provider",
474
+ "reportWriterProvider",
475
+ ) or "claude",
476
+ _legacy_value(
477
+ payload,
478
+ "report_writer_model",
479
+ "reportWriterModel",
480
+ ),
481
+ ),
482
+ (
483
+ "implementer",
484
+ _legacy_value(payload, "executor", "executor") or "claude",
485
+ _legacy_value(
486
+ payload,
487
+ f"{_legacy_value(payload, 'executor', 'executor') or 'claude'}_model",
488
+ f"{_legacy_value(payload, 'executor', 'executor') or 'claude'}Model",
489
+ ),
490
+ ),
491
+ (
492
+ "critic",
493
+ _legacy_value(payload, "critic", "critic"),
494
+ _legacy_value(
495
+ payload,
496
+ f"{_legacy_value(payload, 'critic', 'critic')}_model",
497
+ f"{_legacy_value(payload, 'critic', 'critic')}Model",
498
+ ),
499
+ ),
500
+ )
501
+ static_roles = {
502
+ requirement.role
503
+ for requirement in profile.roles
504
+ if not requirement.dynamic and requirement.min_count > 0
505
+ }
506
+ for role, provider, raw_model in provider_rows:
507
+ if role not in static_roles or not isinstance(provider, str) or not provider:
508
+ continue
509
+ model_ref = _legacy_model_ref(pool, provider, role, raw_model)
510
+ if model_ref is None:
511
+ continue
512
+ state.role_models[role] = [model_ref]
513
+ state.role_selection_order.append(f"role-model:{role}:1")
514
+
515
+
516
+ def _wizard_state_from_json(payload: dict[str, Any]) -> WizardState:
517
+ if not isinstance(payload, dict):
518
+ raise WizardError("wizard state must be a JSON object")
519
+ data = dict(payload)
520
+ version = data.pop("executionIdentityVersion", None)
521
+ has_v2_fields = any(field_name in data for field_name in _V2_STATE_FIELDS)
522
+ has_v1_fields = any(
523
+ field_name in data for field_name in _V1_PROVIDER_STATE_JSON_FIELDS
524
+ )
525
+ if version == 2:
526
+ if has_v1_fields or not all(field_name in data for field_name in _V2_STATE_FIELDS):
527
+ raise WizardError("mixed wizard state versions are not allowed")
528
+ data["role_counts"] = data.pop("roleCounts")
529
+ data["role_models"] = data.pop("roleModels")
530
+ data["role_selection_order"] = data.pop("roleSelectionOrder")
531
+ data["execution_identity_version"] = 2
532
+ _validate_v2_state_fields(data)
533
+ elif version in (None, 1):
534
+ if has_v2_fields:
535
+ raise WizardError("mixed wizard state versions are not allowed")
536
+ data.pop("execution_identity_version", None)
537
+ else:
538
+ raise WizardError(f"unsupported wizard state version: {version!r}")
539
+ allowed = WizardState.__dataclass_fields__.keys()
540
+ state = WizardState(**{key: value for key, value in data.items() if key in allowed})
541
+ if version != 2:
542
+ state.execution_identity_version = 2
543
+ _convert_v1_provider_selections(state, payload)
544
+ _discard_implicit_leader_selection(state)
545
+ return state
546
+
547
+
548
+ def _validate_v2_state_fields(data: dict[str, Any]) -> None:
549
+ role_counts = data.get("role_counts")
550
+ role_models = data.get("role_models")
551
+ selection_order = data.get("role_selection_order")
552
+ if not isinstance(role_counts, dict) or not all(
553
+ isinstance(role, str)
554
+ and isinstance(count, int)
555
+ and not isinstance(count, bool)
556
+ for role, count in role_counts.items()
557
+ ):
558
+ raise WizardError("wizard state roleCounts must map roles to integers")
559
+ if not isinstance(role_models, dict) or not all(
560
+ isinstance(role, str)
561
+ and isinstance(models, list)
562
+ and all(isinstance(model_ref, str) for model_ref in models)
563
+ for role, models in role_models.items()
564
+ ):
565
+ raise WizardError("wizard state roleModels must map roles to modelRef arrays")
566
+ if not isinstance(selection_order, list) or not all(
567
+ isinstance(step_id, str) for step_id in selection_order
568
+ ):
569
+ raise WizardError("wizard state roleSelectionOrder must be a string array")
570
+
571
+
572
+ def _is_role_selection_step(step_id: str) -> bool:
573
+ return step_id.startswith(("role-count:", "role-model:", "role-add:"))
574
+
575
+
576
+ def _discard_implicit_leader_selection(state: WizardState) -> None:
577
+ """호스트에 고정된 리더의 구형 선택값을 상태에서 제거한다."""
578
+ state.role_models.pop("leader", None)
579
+ leader_steps = {
580
+ step_id
581
+ for step_id in state.role_selection_order
582
+ if step_id.startswith("role-model:leader:")
583
+ }
584
+ state.role_selection_order = [
585
+ step_id for step_id in state.role_selection_order
586
+ if step_id not in leader_steps
587
+ ]
588
+ state.answered = [
589
+ step_id for step_id in state.answered
590
+ if step_id not in leader_steps
591
+ ]
592
+
593
+
594
+ def _base_ref_required(state: WizardState) -> bool:
595
+ return state.task_type != "final-verification" and state.reuse_worktree is False
596
+
597
+
598
+ def _base_ref_ready(state: WizardState) -> bool:
599
+ return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
600
+
601
+
602
+ # ---- Step descriptors ---------------------------------------------------
603
+
604
+ @dataclass
605
+ class Step:
606
+ id: str
607
+ applies: Callable[[WizardState], bool]
608
+ build: Callable[[WizardState], Prompt]
609
+ submit: Callable[[WizardState, str], Optional[str]]
610
+ # Field names this step owns; resetting the step clears them.
611
+ owns: tuple[str, ...] = ()
612
+ repeatable: bool = False
613
+
614
+
615
+ def _identity_ready(s: WizardState) -> bool:
616
+ """All identity questions (task pick → executor for impl) answered."""
617
+ if not s.task_type:
618
+ return False
619
+ # release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
620
+ if not s.brief_path and s.task_type != "release-handoff":
621
+ return False
622
+ if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
623
+ return False
624
+ if s.base_ref_pending_text:
625
+ return False
626
+ if s.task_type in _STAGE_SCOPED_TASK_TYPES:
627
+ if not s.approved_plan_path:
628
+ return False
629
+ if s.task_type == "implementation" and not _role_selection_enabled(s):
630
+ if not s.executor:
631
+ return False
632
+ if (s.task_type == "release-handoff"
633
+ and S_HANDOFF_STAGE_PICK not in s.answered):
634
+ return False
635
+ return True
636
+
637
+
638
+ _FIELD_DEFAULTS: dict[str, Any] = {
639
+ "is_new_task": None, "task_group": "", "task_id": "",
640
+ "existing_brief_path": "", "task_type": "",
641
+ "task_group_suggestion": "", "task_id_suggestion": "",
642
+ "task_group_pending_text": False, "task_id_pending_text": False,
643
+ "profile_workers": [], "profile_optional_workers": [],
644
+ "keep_existing_brief": None,
645
+ "brief_path": "", "brief_path_pending_text": False,
646
+ "project_evidence_path": "", "project_evidence_pending_text": False,
647
+ "feature_evidence_path": "", "feature_evidence_pending_text": False,
648
+ "analysis_target": "", "analysis_target_pending_text": False,
649
+ "reuse_worktree": None, "base_ref": "",
650
+ "base_ref_pending_text": False, "approved_plan_path": "",
651
+ "approved_plan_pending_text": False, "approve_plan_candidate": "",
652
+ "html_approval_sidecar": "", "html_approval_option": "",
653
+ "design_prep_queue": [], "design_prep_current": "",
654
+ "design_prep_decision": "", "design_prep_overrides_json": "",
655
+ "design_prep_notes": "",
656
+ "selected_stage": "auto",
657
+ "selected_stages": "",
658
+ "handoff_mode": "", "handoff_stages": "",
659
+ "executor": "", "critic": "", "critic_pending_text": False,
660
+ "execution_identity_version": 1,
661
+ "role_counts": {}, "role_models": {}, "role_selection_order": [],
662
+ "reuse_previous": None,
663
+ "use_defaults": None, "workers_override": "",
664
+ "workers_custom_pending": False,
665
+ "lead_provider": "", "lead_model": "", "claude_model": "", "codex_model": "",
666
+ "antigravity_model": "", "grok_model": "", "kimi_model": "",
667
+ "report_writer_provider": "", "report_writer_model": "", "directive": "",
668
+ "directive_pending_text": False,
669
+ "related_tasks_raw": "", "related_tasks_pending_text": False,
670
+ "clarification_response_path": "", "clarification_pending_text": False,
671
+ "selected_direction_path": "",
672
+ "reverify_scope": "", "reverify_scope_pending_text": False,
673
+ "pr_template_path": "", "pr_template_pending_text": False,
674
+ "pr_template_scope": "",
675
+ "fix_cycle": "",
676
+ "confirmed": None, "edit_target": "",
677
+ }
678
+
679
+
680
+ def _reset_field(state: WizardState, fname: str) -> None:
681
+ if fname in _FIELD_DEFAULTS:
682
+ default = _FIELD_DEFAULTS[fname]
683
+ # copy mutable defaults
684
+ if isinstance(default, list):
685
+ setattr(state, fname, [])
686
+ elif isinstance(default, dict):
687
+ setattr(state, fname, {})
688
+ else:
689
+ setattr(state, fname, default)