okstra 0.191.2 → 0.192.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 (29) hide show
  1. package/docs/architecture.md +2 -2
  2. package/docs/cli.md +2 -1
  3. package/docs/project-structure-overview.md +3 -1
  4. package/docs/task-process/README.md +2 -2
  5. package/docs/task-process/common-flow.md +4 -5
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/workers/translator-worker.md +1 -1
  9. package/runtime/prompts/launch.template.md +1 -1
  10. package/runtime/prompts/lead/convergence.md +7 -3
  11. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  12. package/runtime/prompts/lead/report-writer.md +11 -8
  13. package/runtime/prompts/wizard/prompts.ko.json +52 -23
  14. package/runtime/python/okstra_ctl/convergence.py +63 -1
  15. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
  16. package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
  17. package/runtime/python/okstra_ctl/next_phase.py +18 -8
  18. package/runtime/python/okstra_ctl/plan_items.py +6 -4
  19. package/runtime/python/okstra_ctl/report_finalize.py +57 -10
  20. package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
  21. package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
  22. package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
  23. package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
  24. package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
  25. package/runtime/python/okstra_ctl/wizard/state.py +39 -27
  26. package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
  27. package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
  28. package/runtime/skills/okstra-run/SKILL.md +2 -2
  29. package/runtime/validators/validate-run.py +2 -2
@@ -1,4 +1,18 @@
1
- """역할 인스턴스 선택 루프 — `next_role_prompt` 와 역할 수·모델 픽의 검증·되감기."""
1
+ """역할 인스턴스 선택 루프 — `next_role_prompt` 와 역할 모델 픽의 검증·되감기.
2
+
3
+ 역할 하나에 화면 하나다. 여러 인스턴스를 띄우는 역할(analyser·designer·planner·
4
+ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인스턴스 수다 —
5
+ "몇 개를 띄울까" 를 따로 묻지 않는다(종전 `role-count:` 화면, 실측 2026-09-09
6
+ 사용자 요청으로 제거). 허용 범위와 권장 수는 화면 문구가 말한다. 선택 역할
7
+ (`min = 0`, 예: critic)은 같은 화면에 "추가 안 함" 줄이 있다(종전 `role-add:`).
8
+ 고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
9
+
10
+ 체크박스 화면은 두 장이다. `role-models:<role>` 은 기본 후보(프로젝트
11
+ `modelDefaults` 또는 카탈로그 기본값)만 싣고 권장 수만큼 앞줄을 추천으로 표시한다
12
+ — 호스트 네이티브 선택기의 옵션 한도(claude-code 4, codex 3, grok 15) 안에
13
+ 들어가야 체크박스로 렌더되기 때문이다. 마지막 줄 "직접 선택" 이
14
+ `role-models-custom:<role>` 을 열고, 거기에 실행 가능한 전체 후보가 실린다.
15
+ """
2
16
  from __future__ import annotations
3
17
 
4
18
  from pathlib import Path
@@ -23,6 +37,7 @@ from okstra_ctl.run import (
23
37
  _model_default_scopes,
24
38
  )
25
39
 
40
+ from .ids import PICK_TYPE_CUSTOM
26
41
  from .state import (
27
42
  Option,
28
43
  Prompt,
@@ -35,17 +50,37 @@ from .state import (
35
50
  )
36
51
  from .prompts import _opt, _p
37
52
 
53
+ # 선택 역할의 "추가 안 함" 값. 모델 참조는 `<provider>/<model>` 꼴이라 겹치지 않는다.
54
+ ROLE_SKIP_TOKEN = "0"
38
55
 
39
- def _role_count_prompt_id(role: str) -> str:
40
- return f"role-count:{role}"
56
+ _MODELS_PREFIX = "role-models:"
57
+ _CUSTOM_PREFIX = "role-models-custom:"
58
+ _SINGLE_PREFIX = "role-model:"
41
59
 
42
60
 
43
- def _role_add_prompt_id(role: str) -> str:
44
- return f"role-add:{role}"
61
+ def _role_models_prompt_id(role: str) -> str:
62
+ return f"{_MODELS_PREFIX}{role}"
63
+
64
+
65
+ def _role_models_custom_prompt_id(role: str) -> str:
66
+ return f"{_CUSTOM_PREFIX}{role}"
45
67
 
46
68
 
47
69
  def _role_model_prompt_id(role: str, ordinal: int) -> str:
48
- return f"role-model:{role}:{ordinal}"
70
+ return f"{_SINGLE_PREFIX}{role}:{ordinal}"
71
+
72
+
73
+ def _step_role(step_id: str) -> str:
74
+ """역할 선택 step id 가 가리키는 역할. 역할 선택 step 이 아니면 빈 문자열."""
75
+ for prefix in (_CUSTOM_PREFIX, _MODELS_PREFIX, _SINGLE_PREFIX):
76
+ if step_id.startswith(prefix):
77
+ return step_id[len(prefix):].split(":", 1)[0]
78
+ return ""
79
+
80
+
81
+ def _fixed_single(requirement: RoleRequirement) -> bool:
82
+ """정확히 한 인스턴스만 두는 역할 — 단일 선택 화면이다."""
83
+ return requirement.min_count == requirement.max_count == 1
49
84
 
50
85
 
51
86
  def _selected_role_count(
@@ -74,93 +109,34 @@ def _selectable_static_requirements(
74
109
  )
75
110
 
76
111
 
77
- def _count_prompt(
78
- state: WizardState,
79
- requirement: RoleRequirement,
80
- ) -> Prompt:
81
- prompt = _p(
82
- state.workspace_root,
83
- "role_count",
84
- role=requirement.role,
85
- minimum=str(requirement.min_count),
86
- maximum=str(requirement.max_count),
87
- default=str(requirement.recommended_count),
88
- )
89
- return Prompt(
90
- step=_role_count_prompt_id(requirement.role),
91
- kind="pick",
92
- label=prompt["label"],
93
- options=[
94
- _opt(
95
- str(count),
96
- prompt["options"]["count"].format(
97
- count=count,
98
- default_suffix=(
99
- prompt["options"].get("default_suffix", "")
100
- if count == requirement.recommended_count
101
- else ""
102
- ),
103
- ),
104
- )
105
- for count in range(requirement.min_count, requirement.max_count + 1)
106
- ],
107
- echo_template=prompt["echo_template"],
112
+ def _static_requirements(profile: RoleProfile) -> tuple[RoleRequirement, ...]:
113
+ return tuple(
114
+ requirement for requirement in profile.roles
115
+ if not requirement.dynamic and requirement.max_count > 0
108
116
  )
109
117
 
110
118
 
111
- def _role_add_prompt(
112
- state: WizardState,
113
- requirement: RoleRequirement,
114
- ) -> Prompt:
115
- """min=0 선택 역할: 프로필의 적정 수량이 기본이고, 1..max 를 이 스텝에서 고른다.
116
-
117
- 적정이 0 이면 기본은 추가 안 함이다. 0 보다 큰 적정을 선언한 역할만 기본이 열린
118
- 상태로 뜬다 — 어느 쪽이든 사용자는 이 화면에서 바꿀 수 있다.
119
- """
120
- prompt = _p(
121
- state.workspace_root,
122
- "role_add",
123
- role=requirement.role,
124
- maximum=str(requirement.max_count),
125
- )
126
- suffix = prompt["options"].get("default_suffix", "")
127
- # 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 0 옵션에
128
- # 경고를 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
129
- skip_warnings = prompt["options"].get("skip_warnings", {})
130
- skip_warning = (
131
- skip_warnings.get(requirement.role, "")
132
- if isinstance(skip_warnings, dict)
133
- else ""
119
+ def _count_range_text(requirement: RoleRequirement, t: dict) -> str:
120
+ """화면 문구의 허용 범위: "허용 2..5개, 권장 3개" 또는 "정확히 2개"."""
121
+ ranges = t["labels"]
122
+ if requirement.min_count == requirement.max_count:
123
+ return ranges["exact"].format(count=requirement.max_count)
124
+ return ranges["range"].format(
125
+ minimum=requirement.min_count,
126
+ maximum=requirement.max_count,
127
+ recommended=requirement.recommended_count,
134
128
  )
135
129
 
136
- def _default_suffix(count: int) -> str:
137
- return suffix if count == requirement.recommended_count else ""
138
130
 
139
- options = [
140
- _opt(
141
- "0",
142
- prompt["options"]["skip"].format(
143
- default_suffix=_default_suffix(0),
144
- skip_warning=skip_warning,
145
- ),
146
- ),
147
- ]
148
- for count in range(1, requirement.max_count + 1):
149
- options.append(
150
- _opt(
151
- str(count),
152
- prompt["options"]["add"].format(
153
- count=count, default_suffix=_default_suffix(count),
154
- ),
155
- )
156
- )
157
- return Prompt(
158
- step=_role_add_prompt_id(requirement.role),
159
- kind="pick",
160
- label=prompt["label"],
161
- options=options,
162
- echo_template=prompt["echo_template"],
163
- )
131
+ def _role_models_texts(
132
+ state: WizardState, prompt_key: str, requirement: RoleRequirement,
133
+ ) -> dict:
134
+ """`role_models` / `role_models_custom` 문구. 허용 범위 문장은 `range` 키다."""
135
+ probe = _p(state.workspace_root, prompt_key, role=requirement.role, range="")
136
+ range_text = _count_range_text(requirement, probe)
137
+ texts = _p(state.workspace_root, prompt_key, role=requirement.role, range=range_text)
138
+ texts["range"] = range_text
139
+ return texts
164
140
 
165
141
 
166
142
  def _role_default_candidates(
@@ -193,55 +169,153 @@ def _role_default_candidates(
193
169
  )
194
170
 
195
171
 
196
- def _role_model_options(
172
+ def _available_role_models(
173
+ state: WizardState,
174
+ profile: RoleProfile,
175
+ requirement: RoleRequirement,
176
+ context: AssignmentContext,
177
+ scopes: ModelDefaultScopes,
178
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
179
+ """(기본 후보, 실행 가능한 전체 후보). 전체 후보는 기본 후보를 앞에 둔다.
180
+
181
+ 실행 가능 여부는 prepare 와 같은 배정 스냅샷(`resolve_model_assignment`)으로
182
+ 판정한다 — 카탈로그에는 있지만 이 호스트·환경에서 못 띄우는 모델은 싣지
183
+ 않는다.
184
+ """
185
+ role = requirement.role
186
+ executable = set(_executable_role_models(state, profile, requirement, context))
187
+ defaults = tuple(
188
+ model_ref
189
+ for model_ref in _role_default_candidates(state, role, context.pool, scopes)
190
+ if model_ref in executable
191
+ )
192
+ everything = tuple(dict.fromkeys([
193
+ *defaults,
194
+ *(
195
+ str(model.model_ref) for model in context.pool.list(role=role)
196
+ if str(model.model_ref) in executable
197
+ ),
198
+ ]))
199
+ return defaults, everything
200
+
201
+
202
+ def _shortlist_budget(state: WizardState, requirement: RoleRequirement) -> int:
203
+ """`role-models:` 화면에 실을 모델 줄 수.
204
+
205
+ 호스트에 네이티브 선택기가 있으면 그 한도에서 "직접 선택" 한 줄과, 선택
206
+ 역할이면 "추가 안 함" 한 줄을 뺀 수다. 한도를 넘기는 순간
207
+ `CapabilityInteractionPort.plan` 이 번호 목록으로 내려 체크박스가 아니게
208
+ 된다. 선택기 자체가 없는 호스트는 어차피 번호 목록이므로 자르지 않는다.
209
+ """
210
+ functions = set(state.available_functions)
211
+ if "native_single_select" not in functions and "native_multi_select" not in functions:
212
+ return 10**6
213
+ port = default_host_registry().resolve(state.host_runtime).interaction()
214
+ reserved = 1 + (1 if requirement.min_count == 0 else 0)
215
+ return max(1, port.native_option_limit - reserved)
216
+
217
+
218
+ def _skip_option(requirement: RoleRequirement, t: dict, *, recommended: bool) -> Option:
219
+ # 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 경고를
220
+ # 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
221
+ warnings = t["options"].get("skip_warnings", {})
222
+ warning = warnings.get(requirement.role, "") if isinstance(warnings, dict) else ""
223
+ return _opt(
224
+ ROLE_SKIP_TOKEN,
225
+ t["options"]["skip"].format(skip_warning=warning),
226
+ recommended=recommended,
227
+ )
228
+
229
+
230
+ def _role_models_prompt(
231
+ state: WizardState,
232
+ profile: RoleProfile,
233
+ requirement: RoleRequirement,
234
+ context: AssignmentContext,
235
+ scopes: ModelDefaultScopes,
236
+ *,
237
+ full: bool,
238
+ ) -> Prompt:
239
+ """역할 하나의 모델 화면. `full` 이면 전체 후보, 아니면 기본 후보 + 직접 선택.
240
+
241
+ 추천은 권장 수만큼의 앞줄이다 — 프로젝트 `modelDefaults`(없으면 카탈로그
242
+ 기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
243
+ """
244
+ role = requirement.role
245
+ pool = context.pool
246
+ defaults, everything = _available_role_models(
247
+ state, profile, requirement, context, scopes,
248
+ )
249
+ if not everything:
250
+ _validate_role_selection_feasibility(state, profile, context, scopes)
251
+ raise WizardError(f"role {role!r} has no executable model candidates")
252
+ custom = full
253
+ if not full:
254
+ shown = defaults[: _shortlist_budget(state, requirement)]
255
+ # 기본 후보만으로 최소 수를 못 채우면 줄인 목록은 답이 될 수 없다 —
256
+ # 같은 화면에 전체 후보를 싣는다.
257
+ if len(shown) < min(requirement.min_count, len(everything)) or not shown:
258
+ full = True
259
+ if full:
260
+ shown = everything
261
+ prompt_key = "role_models_custom" if custom else "role_models"
262
+ t = _role_models_texts(state, prompt_key, requirement)
263
+ label = t["label"]
264
+ optional = requirement.min_count == 0
265
+ skip_first = optional and requirement.recommended_count == 0
266
+ options: list[Option] = []
267
+ if skip_first:
268
+ options.append(_skip_option(requirement, t, recommended=True))
269
+ for index, model_ref in enumerate(shown):
270
+ model = pool.resolve(model_ref)
271
+ options.append(_opt(
272
+ model_ref,
273
+ t["options"]["model"].format(model_ref=model_ref, display=model.display_name),
274
+ recommended=index < requirement.recommended_count,
275
+ ))
276
+ if optional and not skip_first:
277
+ options.append(_skip_option(requirement, t, recommended=False))
278
+ if not full:
279
+ options.append(_opt(
280
+ PICK_TYPE_CUSTOM,
281
+ t["options"][PICK_TYPE_CUSTOM].format(total=len(everything)),
282
+ ))
283
+ return Prompt(
284
+ step=(
285
+ _role_models_custom_prompt_id(role) if custom
286
+ else _role_models_prompt_id(role)
287
+ ),
288
+ kind="pick",
289
+ multi=requirement.max_count > 1,
290
+ label=label,
291
+ options=options,
292
+ echo_template=t["echo_template"],
293
+ )
294
+
295
+
296
+ def _single_model_options(
197
297
  state: WizardState,
198
298
  profile: RoleProfile,
199
299
  role: str,
200
- ordinal: int,
201
300
  context: AssignmentContext,
202
301
  scopes: ModelDefaultScopes,
203
302
  ) -> list[Option]:
303
+ """고정 단일 역할의 후보 전체. 기본 후보가 앞이고 첫 줄이 추천이다."""
204
304
  pool = context.pool
205
- defaults = _role_default_candidates(state, role, pool, scopes)
206
- if defaults:
207
- offset = (ordinal - 1) % len(defaults)
208
- defaults = (*defaults[offset:], *defaults[:offset])
209
- candidate_refs = [
210
- *defaults,
211
- *(str(model.model_ref) for model in pool.list(role=role)),
212
- ]
213
- ordered_refs = tuple(dict.fromkeys(candidate_refs))
214
- default_refs = frozenset(defaults)
215
- prompt = _p(
216
- state.workspace_root,
217
- "role_model",
218
- role=role,
219
- ordinal="1",
220
- count="1",
305
+ requirement = next(row for row in profile.roles if row.role == role)
306
+ defaults, everything = _available_role_models(
307
+ state, profile, requirement, context, scopes,
221
308
  )
309
+ prompt = _p(state.workspace_root, "role_model", role=role)
222
310
  options: list[Option] = []
223
- for model_ref in ordered_refs:
224
- availability = pool.availability(
225
- model_ref,
226
- role,
227
- state.host_runtime,
228
- "new-session",
229
- )
230
- if not availability.available:
231
- continue
311
+ for model_ref in everything:
232
312
  candidate_models = {
233
313
  selected_role: tuple(models)
234
314
  for selected_role, models in state.role_models.items()
235
315
  }
236
- selected = list(candidate_models.get(role, ()))[: ordinal - 1]
237
- selected.append(model_ref)
238
- candidate_models[role] = tuple(selected)
316
+ candidate_models[role] = (model_ref,)
239
317
  if not _role_selection_can_complete(
240
- state,
241
- profile,
242
- context,
243
- candidate_models,
244
- scopes,
318
+ state, profile, context, candidate_models, scopes,
245
319
  ):
246
320
  continue
247
321
  model = pool.resolve(model_ref)
@@ -252,10 +326,11 @@ def _role_model_options(
252
326
  display=model.display_name,
253
327
  default_suffix=(
254
328
  prompt["options"].get("default_suffix", "")
255
- if model_ref in default_refs
329
+ if model_ref in defaults
256
330
  else ""
257
331
  ),
258
332
  ),
333
+ recommended=not options,
259
334
  ))
260
335
  if not options:
261
336
  _validate_role_selection_feasibility(state, profile, context, scopes)
@@ -267,60 +342,36 @@ def _model_prompt(
267
342
  state: WizardState,
268
343
  profile: RoleProfile,
269
344
  requirement: RoleRequirement,
270
- ordinal: int,
271
345
  context: AssignmentContext,
272
346
  scopes: ModelDefaultScopes,
273
347
  ) -> Prompt:
274
- prompt = _p(
275
- state.workspace_root,
276
- "role_model",
277
- role=requirement.role,
278
- ordinal=str(ordinal),
279
- count=str(_selected_role_count(state, requirement)),
280
- )
348
+ prompt = _p(state.workspace_root, "role_model", role=requirement.role)
281
349
  return Prompt(
282
- step=_role_model_prompt_id(requirement.role, ordinal),
350
+ step=_role_model_prompt_id(requirement.role, 1),
283
351
  kind="pick",
284
352
  label=prompt["label"],
285
- options=_role_model_options(
286
- state,
287
- profile,
288
- requirement.role,
289
- ordinal,
290
- context,
291
- scopes,
353
+ options=_single_model_options(
354
+ state, profile, requirement.role, context, scopes,
292
355
  ),
293
356
  echo_template=prompt["echo_template"],
294
357
  )
295
358
 
296
359
 
297
- def _rewind_invalid_role_models(
298
- state: WizardState,
299
- requirements: tuple[RoleRequirement, ...],
300
- requirement_index: int,
301
- model_index: int,
302
- ) -> None:
303
- requirement = requirements[requirement_index]
304
- del state.role_models[requirement.role][model_index:]
305
- invalid_ids: set[str] = set()
306
- for later_index, later_requirement in enumerate(
307
- requirements[requirement_index:],
308
- start=requirement_index,
309
- ):
310
- first_ordinal = model_index + 1 if later_index == requirement_index else 1
311
- later_count = _selected_role_count(state, later_requirement)
312
- invalid_ids.update(
313
- _role_model_prompt_id(later_requirement.role, ordinal)
314
- for ordinal in range(first_ordinal, later_count + 1)
315
- )
316
- if later_index != requirement_index:
317
- state.role_models.pop(later_requirement.role, None)
360
+ def _drop_role_selection(state: WizardState, roles: set[str]) -> None:
361
+ """이 역할들의 선택값과 step 기록을 지운다."""
362
+ for role in roles:
363
+ state.role_models.pop(role, None)
364
+ state.role_counts.pop(role, None)
365
+ state.role_models_custom = [
366
+ role for role in state.role_models_custom if role not in roles
367
+ ]
318
368
  state.role_selection_order = [
319
369
  step_id for step_id in state.role_selection_order
320
- if step_id not in invalid_ids
370
+ if _step_role(step_id) not in roles
321
371
  ]
322
372
  state.answered = [
323
- step_id for step_id in state.answered if step_id not in invalid_ids
373
+ step_id for step_id in state.answered
374
+ if not (_is_role_selection_step(step_id) and _step_role(step_id) in roles)
324
375
  ]
325
376
 
326
377
 
@@ -331,46 +382,35 @@ def _discard_invalid_previous_models(
331
382
  context: AssignmentContext,
332
383
  scopes: ModelDefaultScopes,
333
384
  ) -> None:
385
+ """재개한 상태의 선택값이 지금의 배정 스냅샷에서 무효면 그 역할부터 되감는다.
386
+
387
+ 무효 판정은 역할 단위다 — 체크박스 한 장이 그 역할의 답이므로, 그 안의 모델
388
+ 하나가 빠졌어도 같은 화면을 다시 묻는다. 그 뒤 역할들은 앞 역할의 답에
389
+ 따라 후보가 달라지므로 함께 비운다.
390
+ """
334
391
  selected_models = {
335
392
  role: tuple(models) for role, models in state.role_models.items()
336
393
  }
337
394
  if _role_selection_error(
338
- state,
339
- profile,
340
- context,
341
- selected_models,
342
- scopes,
395
+ state, profile, context, selected_models, scopes,
343
396
  ) is None:
344
397
  return
345
- selected_prefix: dict[str, tuple[str, ...]] = {}
346
- for requirement_index, requirement in enumerate(requirements):
347
- count = _selected_role_count(state, requirement)
398
+ prefix: dict[str, tuple[str, ...]] = {}
399
+ for index, requirement in enumerate(requirements):
348
400
  selected = state.role_models.get(requirement.role, [])
349
- if len(selected) > count:
350
- raise WizardError(
351
- f"role {requirement.role!r} has {len(selected)} models for "
352
- f"{count} instances"
353
- )
354
- for index, model_ref in enumerate(selected):
355
- candidate_prefix = dict(selected_prefix)
356
- candidate_prefix[requirement.role] = tuple(selected[: index + 1])
357
- if _role_selection_can_complete(
358
- state,
359
- profile,
360
- context,
361
- candidate_prefix,
362
- scopes,
363
- ):
364
- continue
365
- _rewind_invalid_role_models(
366
- state,
367
- requirements,
368
- requirement_index,
369
- index,
401
+ if not selected:
402
+ continue
403
+ count = _selected_role_count(state, requirement)
404
+ candidate = dict(prefix)
405
+ candidate[requirement.role] = tuple(selected)
406
+ if len(selected) > count or not _role_selection_can_complete(
407
+ state, profile, context, candidate, scopes,
408
+ ):
409
+ _drop_role_selection(
410
+ state, {later.role for later in requirements[index:]},
370
411
  )
371
412
  return
372
- if selected:
373
- selected_prefix[requirement.role] = tuple(selected)
413
+ prefix[requirement.role] = tuple(selected)
374
414
 
375
415
 
376
416
  def _host_session_context(state: WizardState) -> HostSessionContext:
@@ -475,11 +515,7 @@ def _role_selection_can_complete(
475
515
  ) -> bool:
476
516
  completed = _completed_role_models(state, profile, context, role_models)
477
517
  return completed is not None and _role_selection_error(
478
- state,
479
- profile,
480
- context,
481
- completed,
482
- scopes,
518
+ state, profile, context, completed, scopes,
483
519
  ) is None
484
520
 
485
521
 
@@ -507,45 +543,7 @@ def _validate_role_selection_feasibility(
507
543
  raise WizardError(error)
508
544
 
509
545
 
510
- def next_role_prompt(state: WizardState) -> Prompt | None:
511
- """Ask every adjustable count before one model question per role instance."""
512
- if not _role_selection_enabled(state) or not _identity_ready(state):
513
- return None
514
- state.use_defaults = False
515
- profile = _load_role_profile_for_state(state)
516
- for requirement in profile.roles:
517
- # 필수 수량: min < max 이고 min > 0 일 때만.
518
- if (
519
- requirement.dynamic
520
- or requirement.min_count == requirement.max_count
521
- or requirement.min_count == 0
522
- ):
523
- continue
524
- if requirement.role not in state.role_counts:
525
- return _count_prompt(state, requirement)
526
- count = state.role_counts[requirement.role]
527
- if count < requirement.min_count or count > requirement.max_count:
528
- raise WizardError(
529
- f"role {requirement.role!r} count must be in "
530
- f"{requirement.min_count}..{requirement.max_count}: {count}"
531
- )
532
- for requirement in profile.roles:
533
- # 선택 역할(min=0, max>0): 기본은 추가 안 함. role-add 로만 연다.
534
- if (
535
- requirement.dynamic
536
- or requirement.min_count != 0
537
- or requirement.max_count == 0
538
- ):
539
- continue
540
- if requirement.role not in state.role_counts:
541
- return _role_add_prompt(state, requirement)
542
- count = state.role_counts[requirement.role]
543
- if count < 0 or count > requirement.max_count:
544
- raise WizardError(
545
- f"role {requirement.role!r} count must be in "
546
- f"0..{requirement.max_count}: {count}"
547
- )
548
- requirements = _selectable_static_requirements(state, profile)
546
+ def _load_context(state: WizardState) -> tuple[AssignmentContext, ModelDefaultScopes]:
549
547
  try:
550
548
  scopes = _model_default_scopes(Path(state.project_root))
551
549
  except (PrepareError, ValueError) as exc:
@@ -554,41 +552,123 @@ def next_role_prompt(state: WizardState) -> Prompt | None:
554
552
  host_runtime=state.host_runtime,
555
553
  terminal_backend=detect_terminal_backend(),
556
554
  )
557
- _discard_invalid_previous_models(
558
- state,
559
- profile,
560
- requirements,
561
- context,
562
- scopes,
563
- )
555
+ return context, scopes
556
+
557
+
558
+ def next_role_prompt(state: WizardState) -> Prompt | None:
559
+ """프로필 순서대로 역할마다 화면 하나 — 답이 없는 첫 역할의 화면을 낸다."""
560
+ if not _role_selection_enabled(state) or not _identity_ready(state):
561
+ return None
562
+ state.use_defaults = False
563
+ profile = _load_role_profile_for_state(state)
564
+ requirements = _static_requirements(profile)
565
+ context, scopes = _load_context(state)
566
+ _discard_invalid_previous_models(state, profile, requirements, context, scopes)
564
567
  for requirement in requirements:
565
- count = _selected_role_count(state, requirement)
566
- selected = state.role_models.get(requirement.role, [])
567
- if len(selected) < count:
568
- return _model_prompt(
568
+ role = requirement.role
569
+ selected = state.role_models.get(role, [])
570
+ if _fixed_single(requirement):
571
+ if not selected:
572
+ return _model_prompt(state, profile, requirement, context, scopes)
573
+ continue
574
+ if role in state.role_models_custom:
575
+ return _role_models_prompt(
576
+ state, profile, requirement, context, scopes, full=True,
577
+ )
578
+ if role not in state.role_counts:
579
+ return _role_models_prompt(
580
+ state, profile, requirement, context, scopes, full=False,
581
+ )
582
+ count = state.role_counts[role]
583
+ if count != len(selected) or not (
584
+ (0 if requirement.min_count == 0 else requirement.min_count)
585
+ <= count <= requirement.max_count
586
+ ):
587
+ # 재개한 상태의 수와 모델이 어긋난다 — 그 역할부터 다시 묻는다.
588
+ _drop_role_selection(
569
589
  state,
570
- profile,
571
- requirement,
572
- len(selected) + 1,
573
- context,
574
- scopes,
590
+ {later.role for later in requirements[requirements.index(requirement):]},
591
+ )
592
+ return _role_models_prompt(
593
+ state, profile, requirement, context, scopes, full=False,
575
594
  )
576
595
  _validate_role_selection_feasibility(state, profile, context, scopes)
577
596
  return None
578
597
 
579
598
 
599
+ def _requirement_for(profile: RoleProfile, role: str) -> RoleRequirement:
600
+ requirement = next((row for row in profile.roles if row.role == role), None)
601
+ if requirement is None or requirement.dynamic or requirement.max_count == 0:
602
+ raise WizardError(f"role {role!r} does not accept a model selection")
603
+ return requirement
604
+
605
+
606
+ def _submit_role_models(
607
+ state: WizardState,
608
+ profile: RoleProfile,
609
+ requirement: RoleRequirement,
610
+ prompt: Prompt,
611
+ value: str,
612
+ ) -> str:
613
+ """체크박스(또는 선택 역할 단일 픽)의 답을 그 역할의 수와 모델로 확정한다."""
614
+ role = requirement.role
615
+ prompt_key = (
616
+ "role_models_custom" if prompt.step.startswith(_CUSTOM_PREFIX)
617
+ else "role_models"
618
+ )
619
+ t = _role_models_texts(state, prompt_key, requirement)
620
+ range_text = t["range"]
621
+ # 같은 값이 두 번 오면(번호 목록에서 `1,1`) 한 번으로 센다 — 거절할 일이 아니다.
622
+ picked = list(dict.fromkeys(
623
+ item.strip() for item in (value or "").split(",") if item.strip()
624
+ ))
625
+ if PICK_TYPE_CUSTOM in picked:
626
+ if role not in state.role_models_custom:
627
+ state.role_models_custom.append(role)
628
+ return t["echo_variants"]["custom"]
629
+ allowed = {option.value for option in prompt.options}
630
+ unknown = [item for item in picked if item not in allowed]
631
+ if unknown:
632
+ raise WizardError(t["errors"]["unknown_option"].format(values=", ".join(unknown)))
633
+ models = [item for item in picked if item != ROLE_SKIP_TOKEN]
634
+ # "추가 안 함" 과 모델을 같이 고르면 모델을 고른 것이다.
635
+ if not models:
636
+ if ROLE_SKIP_TOKEN not in picked:
637
+ raise WizardError(t["errors"]["min_one_required"].format(range=range_text))
638
+ previous = (state.role_counts.get(role), state.role_models.get(role))
639
+ state.role_counts[role] = 0
640
+ state.role_models.pop(role, None)
641
+ state.role_models_custom = [r for r in state.role_models_custom if r != role]
642
+ return t["echo_variants"]["skipped"]
643
+ if not requirement.min_count <= len(models) <= requirement.max_count:
644
+ raise WizardError(t["errors"]["count_out_of_range"].format(
645
+ range=range_text, count=len(models),
646
+ ))
647
+ previous_count = state.role_counts.get(role)
648
+ previous_models = state.role_models.get(role)
649
+ state.role_counts[role] = len(models)
650
+ state.role_models[role] = list(models)
651
+ state.role_models_custom = [r for r in state.role_models_custom if r != role]
652
+ try:
653
+ _validate_submitted_role_model(state, profile)
654
+ except WizardError:
655
+ if previous_count is None:
656
+ state.role_counts.pop(role, None)
657
+ else:
658
+ state.role_counts[role] = previous_count
659
+ if previous_models is None:
660
+ state.role_models.pop(role, None)
661
+ else:
662
+ state.role_models[role] = previous_models
663
+ raise
664
+ return t["echo_template"].format(value=f"{role}={','.join(models)}")
665
+
666
+
580
667
  def _validate_submitted_role_model(
581
668
  state: WizardState,
582
669
  profile: RoleProfile,
583
670
  ) -> None:
584
- try:
585
- scopes = _model_default_scopes(Path(state.project_root))
586
- except (PrepareError, ValueError) as exc:
587
- raise WizardError(str(exc)) from exc
588
- context = load_assignment_context(
589
- host_runtime=state.host_runtime,
590
- terminal_backend=detect_terminal_backend(),
591
- )
671
+ context, scopes = _load_context(state)
592
672
  role_models = {
593
673
  role: tuple(models) for role, models in state.role_models.items()
594
674
  }
@@ -602,133 +682,56 @@ def _validate_submitted_role_model(
602
682
  _validate_role_selection_feasibility(state, profile, context, scopes)
603
683
  return
604
684
  if not _role_selection_can_complete(
605
- state,
606
- profile,
607
- context,
608
- role_models,
609
- scopes,
685
+ state, profile, context, role_models, scopes,
610
686
  ):
611
687
  raise WizardError("selected role model leaves no complete role assignment")
612
688
 
613
689
 
614
690
  def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
615
- if prompt.step.startswith("role-count:"):
616
- role = prompt.step.split(":", 1)[1]
617
- profile = _load_role_profile_for_state(state)
618
- requirement = next(
619
- (row for row in profile.roles if row.role == role),
620
- None,
621
- )
622
- if (
623
- requirement is None
624
- or requirement.dynamic
625
- or requirement.min_count == requirement.max_count
626
- or requirement.min_count == 0
627
- ):
628
- raise WizardError(f"role {role!r} does not accept a count selection")
629
- try:
630
- count = int(value)
631
- except ValueError as exc:
632
- raise WizardError(f"role {role!r} count must be an integer") from exc
633
- if count < requirement.min_count or count > requirement.max_count:
634
- raise WizardError(
635
- f"role {role!r} count must be in "
636
- f"{requirement.min_count}..{requirement.max_count}: {count}"
637
- )
638
- state.role_counts[role] = count
639
- state.role_models.pop(role, None)
640
- return f"role-count: {role}={count}"
641
- if prompt.step.startswith("role-add:"):
642
- role = prompt.step.split(":", 1)[1]
643
- profile = _load_role_profile_for_state(state)
644
- requirement = next(
645
- (row for row in profile.roles if row.role == role),
646
- None,
647
- )
648
- if (
649
- requirement is None
650
- or requirement.dynamic
651
- or requirement.min_count != 0
652
- or requirement.max_count == 0
653
- ):
654
- raise WizardError(f"role {role!r} does not accept an optional add")
655
- allowed = {option.value for option in prompt.options}
656
- if value not in allowed:
657
- raise WizardError(
658
- f"role {role!r} add selection must be one of "
659
- f"{sorted(allowed, key=int)}: {value}"
660
- )
661
- try:
662
- count = int(value)
663
- except ValueError as exc:
664
- raise WizardError(f"role {role!r} count must be an integer") from exc
665
- if count < 0 or count > requirement.max_count:
666
- raise WizardError(
667
- f"role {role!r} count must be in "
668
- f"0..{requirement.max_count}: {count}"
669
- )
670
- state.role_counts[role] = count
671
- state.role_models.pop(role, None)
672
- return f"role-add: {role}={count}"
673
- _, role, ordinal_raw = prompt.step.split(":", 2)
674
- ordinal = int(ordinal_raw)
691
+ profile = _load_role_profile_for_state(state)
692
+ role = _step_role(prompt.step)
693
+ requirement = _requirement_for(profile, role)
694
+ if prompt.step.startswith((_MODELS_PREFIX, _CUSTOM_PREFIX)):
695
+ if _fixed_single(requirement):
696
+ raise WizardError(f"role {role!r} takes exactly one model")
697
+ return _submit_role_models(state, profile, requirement, prompt, value)
698
+ # 고정 단일 역할의 `role-model:<role>:1`.
699
+ if not _fixed_single(requirement):
700
+ raise WizardError(f"role {role!r} is chosen on its checkbox step")
675
701
  allowed = {option.value for option in prompt.options}
676
702
  if value not in allowed:
677
703
  raise WizardError(
678
704
  f"model {value!r} is not a compatible candidate for role {role!r}"
679
705
  )
680
- selected = state.role_models.setdefault(role, [])
681
- if ordinal != len(selected) + 1:
682
- raise WizardError(
683
- f"role {role!r} model selection is out of order at ordinal {ordinal}"
684
- )
685
- selected.append(value)
706
+ previous = state.role_models.get(role)
707
+ state.role_models[role] = [value]
686
708
  try:
687
- profile = _load_role_profile_for_state(state)
688
709
  _validate_submitted_role_model(state, profile)
689
710
  except WizardError:
690
- selected.pop()
711
+ if previous is None:
712
+ state.role_models.pop(role, None)
713
+ else:
714
+ state.role_models[role] = previous
691
715
  raise
692
- return f"role-model: {role}#{ordinal}={value}"
716
+ return f"role-model: {role}#1={value}"
693
717
 
694
718
 
695
719
  def _reset_role_selection_from(state: WizardState, target_step: str) -> None:
720
+ """편집 대상 step 부터 되감는다 — 그 step 의 역할과 그 뒤 역할의 답을 비운다."""
696
721
  try:
697
722
  target_index = state.role_selection_order.index(target_step)
698
723
  except ValueError as exc:
699
724
  raise WizardError(f"unknown role selection step: {target_step!r}") from exc
700
- kept_ids = state.role_selection_order[:target_index]
701
- kept_counts = {
702
- step_id.split(":", 1)[1]
703
- for step_id in kept_ids
704
- if step_id.startswith(("role-count:", "role-add:"))
725
+ removed_roles = {
726
+ _step_role(step_id) for step_id in state.role_selection_order[target_index:]
705
727
  }
706
- kept_models: dict[str, int] = {}
707
- for step_id in kept_ids:
708
- if not step_id.startswith("role-model:"):
709
- continue
710
- _, role, _ = step_id.split(":", 2)
711
- kept_models[role] = kept_models.get(role, 0) + 1
712
- state.role_counts = {
713
- role: count
714
- for role, count in state.role_counts.items()
715
- if role in kept_counts
716
- }
717
- state.role_models = {
718
- role: models[:kept_models[role]]
719
- for role, models in state.role_models.items()
720
- if kept_models.get(role, 0) > 0
721
- }
722
- removed_ids = set(state.role_selection_order[target_index:])
723
- state.role_selection_order = kept_ids
724
- state.answered = [
725
- step_id for step_id in state.answered if step_id not in removed_ids
726
- ]
728
+ _drop_role_selection(state, removed_roles)
727
729
 
728
730
 
729
731
  def _clear_role_selection(state: WizardState) -> None:
730
732
  state.role_counts.clear()
731
733
  state.role_models.clear()
734
+ state.role_models_custom.clear()
732
735
  state.role_selection_order.clear()
733
736
  state.answered = [
734
737
  step_id