okstra 0.22.0 → 0.24.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 (33) hide show
  1. package/README.kr.md +3 -0
  2. package/README.md +3 -0
  3. package/bin/okstra +5 -0
  4. package/docs/kr/architecture.md +2 -2
  5. package/docs/kr/cli.md +1 -0
  6. package/docs/project-structure-overview.md +4 -1
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/workers/claude-worker.md +3 -1
  10. package/runtime/agents/workers/codex-worker.md +3 -1
  11. package/runtime/agents/workers/gemini-worker.md +3 -1
  12. package/runtime/agents/workers/report-writer-worker.md +17 -2
  13. package/runtime/prompts/profiles/release-handoff.md +16 -0
  14. package/runtime/python/okstra_ctl/render.py +25 -2
  15. package/runtime/python/okstra_ctl/wizard.py +1249 -0
  16. package/runtime/python/okstra_token_usage/collect.py +12 -1
  17. package/runtime/skills/okstra-report-writer/SKILL.md +1 -0
  18. package/runtime/skills/okstra-run/SKILL.md +115 -234
  19. package/runtime/skills/okstra-setup/SKILL.md +37 -0
  20. package/runtime/skills/okstra-team-contract/SKILL.md +47 -1
  21. package/runtime/templates/prd/brief.template.md +1 -0
  22. package/runtime/templates/project-docs/task-index.template.md +1 -0
  23. package/runtime/templates/reports/error-analysis-input.template.md +1 -0
  24. package/runtime/templates/reports/final-report.template.md +1 -0
  25. package/runtime/templates/reports/final-verification-input.template.md +1 -0
  26. package/runtime/templates/reports/implementation-input.template.md +1 -0
  27. package/runtime/templates/reports/implementation-planning-input.template.md +1 -0
  28. package/runtime/templates/reports/quick-input.template.md +1 -0
  29. package/runtime/templates/reports/release-handoff-input.template.md +1 -0
  30. package/runtime/templates/reports/schedule.template.md +1 -0
  31. package/runtime/templates/reports/task-brief.template.md +1 -0
  32. package/src/config.mjs +392 -0
  33. package/src/wizard.mjs +105 -0
@@ -0,0 +1,1249 @@
1
+ """okstra-run wizard — state machine for interactive task setup.
2
+
3
+ The okstra-run skill used to encode the full input-collection flow in prose,
4
+ which drifted into seven internal inconsistencies (executor placement,
5
+ free-text-via-AskUserQuestion, missing clarification prompt for impl, etc.).
6
+ This module owns the flow as code so the skill becomes a thin loop.
7
+
8
+ Public surface:
9
+ - ``WizardState`` - serializable dataclass; one state file per run.
10
+ - ``Prompt`` - what the skill should ask next.
11
+ - ``init_state()`` - seed from project-root / project-id / workspace-root.
12
+ - ``next_prompt()`` - deterministic; pure read on state.
13
+ - ``submit()`` - validate + advance.
14
+ - ``render_args()`` - final args for ``okstra render-bundle``.
15
+
16
+ The skill calls these via the ``okstra wizard`` CLI subcommand; it never
17
+ imports this module directly.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import subprocess
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Optional
27
+
28
+ from okstra_ctl.ids import slugify_task_segment
29
+ from okstra_ctl.models import (
30
+ PROVIDER_MAPPINGS,
31
+ UnknownModelError,
32
+ resolve_model_metadata,
33
+ )
34
+ from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
35
+ from okstra_ctl.run import APPROVED_PLAN_PATTERN
36
+ from okstra_ctl.workers import (
37
+ ALLOWED_WORKERS,
38
+ WorkersError,
39
+ normalize_workers,
40
+ resolve_profile_workers,
41
+ validate_workers_against_profile,
42
+ )
43
+ from okstra_ctl import worktree_registry
44
+ from okstra_project.state import (
45
+ list_project_tasks,
46
+ read_latest_task,
47
+ read_task_manifest,
48
+ find_task_root,
49
+ )
50
+
51
+
52
+ # ---- Constants -----------------------------------------------------------
53
+
54
+ TASK_TYPES: list[tuple[str, str]] = [
55
+ ("requirements-discovery", "Classify request and route to next safe phase"),
56
+ ("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
57
+ ("implementation-planning", "Plan options + request user approval"),
58
+ ("implementation", "Execute approved plan (requires approved final-report)"),
59
+ ("final-verification", "Acceptance + residual-risk review"),
60
+ ("release-handoff", "Drive commit/push/PR after accepted final-verification"),
61
+ ]
62
+ TASK_TYPE_VALUES = [tt for tt, _ in TASK_TYPES]
63
+
64
+ EXECUTORS = ["claude", "codex", "gemini"]
65
+
66
+ CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
67
+ BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
68
+
69
+ CLAUDE_MODEL_OPTIONS = ["default", "opus", "sonnet", "haiku"]
70
+ CODEX_MODEL_OPTIONS = ["default", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
71
+ GEMINI_MODEL_OPTIONS = ["default", "gemini-3-pro-preview", "gemini-3-flash-preview", "auto"]
72
+
73
+ # special pick value: start a brand-new task
74
+ TASK_PICK_NEW_TOKEN = "__new__"
75
+
76
+
77
+ # ---- Step IDs ------------------------------------------------------------
78
+
79
+ S_TASK_PICK = "task_pick"
80
+ S_TASK_GROUP = "task_group"
81
+ S_TASK_ID = "task_id"
82
+ S_TASK_TYPE = "task_type"
83
+ S_BRIEF_KEEP = "brief_keep"
84
+ S_BRIEF_PATH = "brief_path"
85
+ S_BASE_REF_PICK = "base_ref_pick"
86
+ S_BASE_REF_TEXT = "base_ref_text"
87
+ S_APPROVED_PLAN = "approved_plan"
88
+ S_EXECUTOR = "executor"
89
+ S_DEFAULTS_OR_CUSTOM = "defaults_or_custom"
90
+ S_WORKERS_OVERRIDE = "workers_override"
91
+ S_LEAD_MODEL = "lead_model"
92
+ S_EXECUTOR_MODEL = "executor_model"
93
+ S_CLAUDE_MODEL = "claude_model"
94
+ S_CODEX_MODEL = "codex_model"
95
+ S_GEMINI_MODEL = "gemini_model"
96
+ S_REPORT_WRITER_MODEL = "report_writer_model"
97
+ S_DIRECTIVE = "directive"
98
+ S_RELATED_TASKS = "related_tasks"
99
+ S_CLARIFICATION = "clarification"
100
+ S_PR_TEMPLATE = "pr_template"
101
+ S_PR_TEMPLATE_SCOPE = "pr_template_scope"
102
+ S_CONFIRM = "confirm"
103
+ S_EDIT_TARGET = "edit_target"
104
+ S_DONE = "done"
105
+
106
+
107
+ # ---- Data types ----------------------------------------------------------
108
+
109
+ @dataclass
110
+ class WizardState:
111
+ # bootstrap
112
+ workspace_root: str = ""
113
+ project_root: str = ""
114
+ project_id: str = ""
115
+
116
+ # task identity
117
+ is_new_task: Optional[bool] = None
118
+ task_group: str = ""
119
+ task_id: str = ""
120
+ existing_brief_path: str = ""
121
+
122
+ # task-type + dependents
123
+ task_type: str = ""
124
+ profile_workers: list[str] = field(default_factory=list)
125
+
126
+ # brief
127
+ keep_existing_brief: Optional[bool] = None
128
+ brief_path: str = ""
129
+
130
+ # worktree
131
+ reuse_worktree: Optional[bool] = None
132
+ base_ref: str = ""
133
+ base_ref_pending_text: bool = False
134
+
135
+ # impl extras
136
+ approved_plan_path: str = ""
137
+ executor: str = ""
138
+
139
+ # customize
140
+ use_defaults: Optional[bool] = None
141
+ workers_override: str = ""
142
+ lead_model: str = ""
143
+ claude_model: str = ""
144
+ codex_model: str = ""
145
+ gemini_model: str = ""
146
+ report_writer_model: str = ""
147
+ directive: str = ""
148
+ related_tasks_raw: str = ""
149
+ clarification_response_path: str = ""
150
+ pr_template_path: str = ""
151
+ pr_template_scope: str = "" # "once" | "project" | "global"
152
+
153
+ # confirm / edit
154
+ confirmed: Optional[bool] = None
155
+ edit_target: str = ""
156
+
157
+ # bookkeeping
158
+ answered: list[str] = field(default_factory=list)
159
+
160
+ def to_json(self) -> dict[str, Any]:
161
+ return asdict(self)
162
+
163
+ @classmethod
164
+ def from_json(cls, d: dict[str, Any]) -> "WizardState":
165
+ return cls(**d)
166
+
167
+
168
+ @dataclass
169
+ class Option:
170
+ value: str
171
+ label: str
172
+ description: str = ""
173
+
174
+
175
+ @dataclass
176
+ class Prompt:
177
+ step: str
178
+ kind: str # "pick" | "text" | "done"
179
+ label: str = ""
180
+ options: list[Option] = field(default_factory=list)
181
+ help: str = ""
182
+ echo_template: str = "" # e.g. "task-group: {value}"
183
+
184
+ def to_json(self) -> dict[str, Any]:
185
+ return {
186
+ "step": self.step,
187
+ "kind": self.kind,
188
+ "label": self.label,
189
+ "options": [asdict(o) for o in self.options],
190
+ "help": self.help,
191
+ "echoTemplate": self.echo_template,
192
+ }
193
+
194
+
195
+ class WizardError(Exception):
196
+ """validation failure surfaced to user."""
197
+
198
+
199
+ # ---- Validation helpers --------------------------------------------------
200
+
201
+ _SLUG_OK = re.compile(r"[a-z0-9]")
202
+
203
+
204
+ def _slug_or_die(value: str, field_name: str) -> str:
205
+ slug = slugify_task_segment(value or "")
206
+ if not slug or not _SLUG_OK.search(slug):
207
+ raise WizardError(
208
+ f"{field_name} must contain at least one alphanumeric character "
209
+ f"(got: {value!r})"
210
+ )
211
+ return slug
212
+
213
+
214
+ def _resolve_path(path_str: str, project_root: Path) -> Path:
215
+ p = Path(path_str).expanduser()
216
+ return p if p.is_absolute() else (project_root / p).resolve()
217
+
218
+
219
+ def _require_file(path_str: str, project_root: Path, label: str) -> Path:
220
+ if not (path_str or "").strip():
221
+ raise WizardError(f"{label}: empty path")
222
+ p = _resolve_path(path_str, project_root)
223
+ if not p.is_file():
224
+ raise WizardError(f"{label}: file not found: {p}")
225
+ return p
226
+
227
+
228
+ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
229
+ p = _require_file(path_str, project_root, "approved plan")
230
+ body = p.read_text(encoding="utf-8", errors="replace")
231
+ if not APPROVED_PLAN_PATTERN.search(body):
232
+ raise WizardError(
233
+ f"approved plan has no APPROVED marker: {p}\n"
234
+ ' canonical form (top of report): "- [x] Approved"'
235
+ )
236
+ return p
237
+
238
+
239
+ def _git_main_worktree(project_root: Path) -> Path:
240
+ try:
241
+ common = subprocess.check_output(
242
+ ["git", "-C", str(project_root), "rev-parse",
243
+ "--path-format=absolute", "--git-common-dir"],
244
+ stderr=subprocess.DEVNULL,
245
+ ).decode().strip()
246
+ except (subprocess.CalledProcessError, FileNotFoundError) as exc:
247
+ raise WizardError(f"git unavailable in {project_root}: {exc}")
248
+ return Path(common).parent
249
+
250
+
251
+ def _validate_base_ref(ref: str, project_root: Path) -> str:
252
+ ref = (ref or "").strip()
253
+ if not ref:
254
+ raise WizardError("base ref is empty")
255
+ main_wt = _git_main_worktree(project_root)
256
+ rc = subprocess.call(
257
+ ["git", "-C", str(main_wt), "rev-parse",
258
+ "--verify", "--quiet", f"{ref}^{{commit}}"],
259
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
260
+ )
261
+ if rc != 0:
262
+ raise WizardError(f"base ref not found in repository: {ref}")
263
+ return ref
264
+
265
+
266
+ def _validate_model(provider: str, raw: str) -> str:
267
+ """Empty / 'default' → '' (use phase default).
268
+ Known alias → canonical option (passed verbatim to render-bundle).
269
+ Unknown → WizardError.
270
+ """
271
+ raw = (raw or "").strip()
272
+ if raw == "" or raw.lower() == "default":
273
+ return ""
274
+ try:
275
+ resolve_model_metadata(
276
+ provider=provider, raw_value=raw,
277
+ default_display="x", default_execution="x",
278
+ )
279
+ except UnknownModelError as exc:
280
+ raise WizardError(str(exc))
281
+ return raw
282
+
283
+
284
+ def _executor_model_field(executor: str) -> str:
285
+ return {"claude": "claude_model",
286
+ "codex": "codex_model",
287
+ "gemini": "gemini_model"}[executor]
288
+
289
+
290
+ def _executor_model_options(executor: str) -> list[str]:
291
+ return {"claude": CLAUDE_MODEL_OPTIONS,
292
+ "codex": CODEX_MODEL_OPTIONS,
293
+ "gemini": GEMINI_MODEL_OPTIONS}[executor]
294
+
295
+
296
+ # ---- Roster / profile helpers -------------------------------------------
297
+
298
+ def _profile_path(workspace_root: Path, task_type: str) -> Path:
299
+ return workspace_root / "prompts" / "profiles" / f"{task_type}.md"
300
+
301
+
302
+ def _load_profile_workers(workspace_root: Path, task_type: str) -> list[str]:
303
+ return resolve_profile_workers(_profile_path(workspace_root, task_type))
304
+
305
+
306
+ def _resolved_roster(state: WizardState) -> list[str]:
307
+ """Effective worker list AFTER override. Implementation: profile default
308
+ (caller never asks for override). Others: override or profile default."""
309
+ if state.task_type == "implementation":
310
+ return list(state.profile_workers)
311
+ if state.workers_override.strip():
312
+ return normalize_workers(state.workers_override)
313
+ return list(state.profile_workers)
314
+
315
+
316
+ # ---- Worktree resolution ------------------------------------------------
317
+
318
+ def _resolve_reuse_worktree(state: WizardState) -> bool:
319
+ """For a finalized task identity, is there an active worktree to reuse?
320
+ New tasks always answer False (no entry possible)."""
321
+ if state.is_new_task:
322
+ return False
323
+ if not (state.project_id and state.task_group and state.task_id):
324
+ return False
325
+ entry = worktree_registry.lookup(state.project_id,
326
+ state.task_group, state.task_id)
327
+ return bool(entry and entry.status == "active")
328
+
329
+
330
+ def _existing_task_brief(project_root: Path, task_key: str) -> str:
331
+ """Read taskBriefPath from manifest for an existing task. Empty if none."""
332
+ root = find_task_root(project_root, task_key)
333
+ if root is None:
334
+ return ""
335
+ manifest = read_task_manifest(root) or {}
336
+ val = manifest.get("taskBriefPath") or ""
337
+ return val if isinstance(val, str) else ""
338
+
339
+
340
+ # ---- Step descriptors ---------------------------------------------------
341
+
342
+ @dataclass
343
+ class Step:
344
+ id: str
345
+ applies: Callable[[WizardState], bool]
346
+ build: Callable[[WizardState], Prompt]
347
+ submit: Callable[[WizardState, str], Optional[str]]
348
+ # Field names this step owns; resetting the step clears them.
349
+ owns: tuple[str, ...] = ()
350
+
351
+
352
+ def _opt(value: str, label: str = "", description: str = "") -> Option:
353
+ return Option(value=value, label=label or value, description=description)
354
+
355
+
356
+ # --- builders ---
357
+
358
+ def _build_task_pick(state: WizardState) -> Prompt:
359
+ project_root = Path(state.project_root)
360
+ tasks = list_project_tasks(project_root)
361
+ latest = read_latest_task(project_root) or {}
362
+ latest_key = latest.get("taskKey") or ""
363
+ options: list[Option] = []
364
+ for entry in tasks[:8]:
365
+ key = entry.get("taskKey") or ""
366
+ ttype = entry.get("taskType") or ""
367
+ phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
368
+ nxt = (entry.get("workflow") or {}).get("nextRecommendedPhase") or ""
369
+ suffix = " (latest)" if key == latest_key else ""
370
+ label = f"{key} · {phase} · next: {nxt}{suffix}"
371
+ options.append(_opt(value=key, label=label))
372
+ options.append(_opt(value=TASK_PICK_NEW_TOKEN,
373
+ label="Start a brand-new task"))
374
+ return Prompt(step=S_TASK_PICK, kind="pick",
375
+ label="어느 task?", options=options,
376
+ echo_template="task: {value}")
377
+
378
+
379
+ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
380
+ value = value.strip()
381
+ if value == TASK_PICK_NEW_TOKEN:
382
+ state.is_new_task = True
383
+ state.task_group = ""
384
+ state.task_id = ""
385
+ state.existing_brief_path = ""
386
+ state.task_type = ""
387
+ return "task: (brand-new)"
388
+ # parse "<project-id>:<task-group>:<task-id>"
389
+ parts = value.split(":")
390
+ if len(parts) != 3 or not all(parts):
391
+ raise WizardError(
392
+ f"invalid task-key: {value!r} (expected project-id:task-group:task-id)"
393
+ )
394
+ pid, tg, tid = parts
395
+ if pid != state.project_id:
396
+ raise WizardError(
397
+ f"task-key project-id {pid!r} does not match current project {state.project_id!r}"
398
+ )
399
+ state.is_new_task = False
400
+ state.task_group = tg
401
+ state.task_id = tid
402
+ state.existing_brief_path = _existing_task_brief(
403
+ Path(state.project_root), value
404
+ )
405
+ # task_type seeded from manifest's nextRecommendedPhase as the recommended default
406
+ root = find_task_root(Path(state.project_root), value)
407
+ manifest = (read_task_manifest(root) or {}) if root else {}
408
+ workflow = manifest.get("workflow") or {}
409
+ state.task_type = workflow.get("nextRecommendedPhase") or ""
410
+ return f"task: {value}"
411
+
412
+
413
+ def _build_task_group(state: WizardState) -> Prompt:
414
+ return Prompt(step=S_TASK_GROUP, kind="text",
415
+ label="Task group 을 알려주세요 (예: backend-api, INV-1234, refactor)",
416
+ echo_template="task-group: {value}")
417
+
418
+
419
+ def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
420
+ state.task_group = _slug_or_die(value, "task_group")
421
+ return f"task-group: {state.task_group}"
422
+
423
+
424
+ def _build_task_id(state: WizardState) -> Prompt:
425
+ return Prompt(step=S_TASK_ID, kind="text",
426
+ label="Task id 를 알려주세요 (예: login-error-analysis, dev-9043)",
427
+ echo_template="task-id: {value}")
428
+
429
+
430
+ def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
431
+ state.task_id = _slug_or_die(value, "task_id")
432
+ return f"task-id: {state.task_id}"
433
+
434
+
435
+ def _build_task_type(state: WizardState) -> Prompt:
436
+ options: list[Option] = []
437
+ recommended = state.task_type if not state.is_new_task else ""
438
+ seen: list[str] = []
439
+ if recommended and recommended in TASK_TYPE_VALUES:
440
+ d = dict(TASK_TYPES)[recommended]
441
+ options.append(_opt(recommended, f"{recommended} (recommended)", d))
442
+ seen.append(recommended)
443
+ for tt, desc in TASK_TYPES:
444
+ if tt in seen:
445
+ continue
446
+ options.append(_opt(tt, tt, desc))
447
+ return Prompt(step=S_TASK_TYPE, kind="pick",
448
+ label="Task type?", options=options,
449
+ echo_template="task-type: {value}")
450
+
451
+
452
+ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
453
+ if value not in TASK_TYPE_VALUES:
454
+ raise WizardError(f"unknown task-type: {value!r}")
455
+ state.task_type = value
456
+ state.profile_workers = _load_profile_workers(
457
+ Path(state.workspace_root), value
458
+ )
459
+ # Reuse-worktree is decided once identity is final. Recompute here so
460
+ # subsequent base-ref step knows whether to apply.
461
+ state.reuse_worktree = _resolve_reuse_worktree(state)
462
+ return f"task-type: {value}"
463
+
464
+
465
+ def _build_brief_keep(state: WizardState) -> Prompt:
466
+ return Prompt(
467
+ step=S_BRIEF_KEEP, kind="pick",
468
+ label=f"기존 brief 경로 [{state.existing_brief_path}] 를 유지할까요?",
469
+ options=[_opt("keep", "유지"), _opt("change", "변경")],
470
+ echo_template="brief: {value}",
471
+ )
472
+
473
+
474
+ def _submit_brief_keep(state: WizardState, value: str) -> Optional[str]:
475
+ if value not in ("keep", "change"):
476
+ raise WizardError(f"expected 'keep' or 'change', got: {value!r}")
477
+ state.keep_existing_brief = value == "keep"
478
+ if state.keep_existing_brief:
479
+ state.brief_path = state.existing_brief_path
480
+ return f"brief: {state.brief_path} (유지)"
481
+ return None # next prompt is S_BRIEF_PATH
482
+
483
+
484
+ def _build_brief_path(state: WizardState) -> Prompt:
485
+ return Prompt(
486
+ step=S_BRIEF_PATH, kind="text",
487
+ label="task brief markdown 의 경로를 알려주세요 (project root 기준 상대경로 또는 절대경로)",
488
+ echo_template="brief: {value}",
489
+ )
490
+
491
+
492
+ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
493
+ p = _require_file(value, Path(state.project_root), "task brief")
494
+ state.brief_path = str(p)
495
+ return f"brief: {p}"
496
+
497
+
498
+ def _build_base_ref_pick(state: WizardState) -> Prompt:
499
+ options = [_opt(r, "main (recommended)" if r == "main" else r)
500
+ for r in CANONICAL_BASE_REFS]
501
+ options.append(_opt(BASE_REF_FREE_INPUT_TOKEN, "직접 입력"))
502
+ return Prompt(
503
+ step=S_BASE_REF_PICK, kind="pick",
504
+ label="이 task worktree 의 base branch?",
505
+ options=options,
506
+ echo_template="base-ref: {value}",
507
+ )
508
+
509
+
510
+ def _submit_base_ref_pick(state: WizardState, value: str) -> Optional[str]:
511
+ if value == BASE_REF_FREE_INPUT_TOKEN:
512
+ state.base_ref_pending_text = True
513
+ state.base_ref = ""
514
+ return None
515
+ state.base_ref_pending_text = False
516
+ ref = _validate_base_ref(value, Path(state.project_root))
517
+ state.base_ref = ref
518
+ return f"base-ref: {ref}"
519
+
520
+
521
+ def _build_base_ref_text(state: WizardState) -> Prompt:
522
+ return Prompt(
523
+ step=S_BASE_REF_TEXT, kind="text",
524
+ label="base ref 를 입력해주세요 (branch, tag, 또는 short/full SHA)",
525
+ echo_template="base-ref: {value}",
526
+ )
527
+
528
+
529
+ def _submit_base_ref_text(state: WizardState, value: str) -> Optional[str]:
530
+ ref = _validate_base_ref(value, Path(state.project_root))
531
+ state.base_ref = ref
532
+ state.base_ref_pending_text = False
533
+ return f"base-ref: {ref}"
534
+
535
+
536
+ def _build_approved_plan(state: WizardState) -> Prompt:
537
+ return Prompt(
538
+ step=S_APPROVED_PLAN, kind="text",
539
+ label="approved final-report.md 의 경로를 알려주세요 (APPROVED 마커 필수)",
540
+ echo_template="approved-plan: {value}",
541
+ )
542
+
543
+
544
+ def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
545
+ p = _validate_approved_plan(value, Path(state.project_root))
546
+ state.approved_plan_path = str(p)
547
+ return f"approved-plan: {p}"
548
+
549
+
550
+ def _build_executor(state: WizardState) -> Prompt:
551
+ options = [_opt(e, e + (" (default)" if e == "claude" else ""))
552
+ for e in EXECUTORS]
553
+ return Prompt(
554
+ step=S_EXECUTOR, kind="pick",
555
+ label="실행자 (executor)?",
556
+ options=options,
557
+ echo_template="executor: {value}",
558
+ )
559
+
560
+
561
+ def _submit_executor(state: WizardState, value: str) -> Optional[str]:
562
+ if value not in EXECUTORS:
563
+ raise WizardError(f"executor must be one of {EXECUTORS}, got: {value!r}")
564
+ state.executor = value
565
+ return f"executor: {value}"
566
+
567
+
568
+ def _build_defaults_or_custom(state: WizardState) -> Prompt:
569
+ return Prompt(
570
+ step=S_DEFAULTS_OR_CUSTOM, kind="pick",
571
+ label="기본 워커/모델로 진행할까요, 아니면 커스터마이즈할까요?",
572
+ options=[_opt("defaults", "Use defaults"),
573
+ _opt("customize", "Customize")],
574
+ echo_template="customize: {value}",
575
+ )
576
+
577
+
578
+ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
579
+ if value not in ("defaults", "customize"):
580
+ raise WizardError(f"expected 'defaults' or 'customize', got: {value!r}")
581
+ state.use_defaults = value == "defaults"
582
+ return f"customize: {'no' if state.use_defaults else 'yes'}"
583
+
584
+
585
+ def _build_workers_override(state: WizardState) -> Prompt:
586
+ csv = ",".join(state.profile_workers)
587
+ return Prompt(
588
+ step=S_WORKERS_OVERRIDE, kind="text",
589
+ label=(f"참여 워커 목록을 쉼표로 구분해서 적어주세요. "
590
+ f"빈 줄이면 프로필 기본값 [{csv}] 을 그대로 씁니다. "
591
+ f"사용 가능한 워커: {csv}"),
592
+ echo_template="workers: {value}",
593
+ )
594
+
595
+
596
+ def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
597
+ if not (value or "").strip():
598
+ state.workers_override = ""
599
+ return f"workers: (profile default: {','.join(state.profile_workers)})"
600
+ try:
601
+ chosen = normalize_workers(value)
602
+ validate_workers_against_profile(chosen, state.profile_workers)
603
+ except WorkersError as exc:
604
+ raise WizardError(str(exc))
605
+ state.workers_override = ",".join(chosen)
606
+ return f"workers: {state.workers_override}"
607
+
608
+
609
+ def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
610
+ opts = [_opt(o, o) for o in options]
611
+ return Prompt(step=step, kind="pick", label=label,
612
+ options=opts, echo_template=echo)
613
+
614
+
615
+ def _build_lead_model(state: WizardState) -> Prompt:
616
+ return _model_pick(S_LEAD_MODEL, "리더(Claude lead) 모델?",
617
+ CLAUDE_MODEL_OPTIONS, "lead-model: {value}")
618
+
619
+
620
+ def _submit_lead_model(state: WizardState, value: str) -> Optional[str]:
621
+ state.lead_model = _validate_model("claude", value)
622
+ return f"lead-model: {state.lead_model or 'default'}"
623
+
624
+
625
+ def _build_executor_model(state: WizardState) -> Prompt:
626
+ return _model_pick(
627
+ S_EXECUTOR_MODEL,
628
+ f"실행자({state.executor}) 모델?",
629
+ _executor_model_options(state.executor),
630
+ f"{state.executor}-model: {{value}}",
631
+ )
632
+
633
+
634
+ def _submit_executor_model(state: WizardState, value: str) -> Optional[str]:
635
+ resolved = _validate_model(state.executor, value)
636
+ setattr(state, _executor_model_field(state.executor), resolved)
637
+ return f"{state.executor}-model: {resolved or 'default'}"
638
+
639
+
640
+ def _build_claude_model(state: WizardState) -> Prompt:
641
+ return _model_pick(S_CLAUDE_MODEL, "claude 워커 모델?",
642
+ CLAUDE_MODEL_OPTIONS, "claude-model: {value}")
643
+
644
+
645
+ def _submit_claude_model(state: WizardState, value: str) -> Optional[str]:
646
+ state.claude_model = _validate_model("claude", value)
647
+ return f"claude-model: {state.claude_model or 'default'}"
648
+
649
+
650
+ def _build_codex_model(state: WizardState) -> Prompt:
651
+ return _model_pick(S_CODEX_MODEL, "codex 워커 모델?",
652
+ CODEX_MODEL_OPTIONS, "codex-model: {value}")
653
+
654
+
655
+ def _submit_codex_model(state: WizardState, value: str) -> Optional[str]:
656
+ state.codex_model = _validate_model("codex", value)
657
+ return f"codex-model: {state.codex_model or 'default'}"
658
+
659
+
660
+ def _build_gemini_model(state: WizardState) -> Prompt:
661
+ return _model_pick(S_GEMINI_MODEL, "gemini 워커 모델?",
662
+ GEMINI_MODEL_OPTIONS, "gemini-model: {value}")
663
+
664
+
665
+ def _submit_gemini_model(state: WizardState, value: str) -> Optional[str]:
666
+ state.gemini_model = _validate_model("gemini", value)
667
+ return f"gemini-model: {state.gemini_model or 'default'}"
668
+
669
+
670
+ def _build_report_writer_model(state: WizardState) -> Prompt:
671
+ return _model_pick(S_REPORT_WRITER_MODEL,
672
+ "리포트 작성자(report-writer) 모델?",
673
+ CLAUDE_MODEL_OPTIONS,
674
+ "report-writer-model: {value}")
675
+
676
+
677
+ def _submit_report_writer_model(state: WizardState, value: str) -> Optional[str]:
678
+ state.report_writer_model = _validate_model("claude", value)
679
+ return f"report-writer-model: {state.report_writer_model or 'default'}"
680
+
681
+
682
+ def _build_directive(state: WizardState) -> Prompt:
683
+ return Prompt(
684
+ step=S_DIRECTIVE, kind="text",
685
+ label="추가 directive 가 있으면 적어주세요 (없으면 빈 줄)",
686
+ echo_template="directive: {value}",
687
+ )
688
+
689
+
690
+ def _submit_directive(state: WizardState, value: str) -> Optional[str]:
691
+ state.directive = (value or "").strip()
692
+ return f"directive: {state.directive or '(none)'}"
693
+
694
+
695
+ def _build_related_tasks(state: WizardState) -> Prompt:
696
+ return Prompt(
697
+ step=S_RELATED_TASKS, kind="text",
698
+ label="관련 task id 목록을 쉼표로 구분해서 적어주세요 (없으면 빈 줄)",
699
+ echo_template="related-tasks: {value}",
700
+ )
701
+
702
+
703
+ def _submit_related_tasks(state: WizardState, value: str) -> Optional[str]:
704
+ state.related_tasks_raw = (value or "").strip()
705
+ return f"related-tasks: {state.related_tasks_raw or '(none)'}"
706
+
707
+
708
+ def _build_clarification(state: WizardState) -> Prompt:
709
+ return Prompt(
710
+ step=S_CLARIFICATION, kind="text",
711
+ label="clarification-response 파일 경로 (follow-up 시에만, 없으면 빈 줄)",
712
+ echo_template="clarification: {value}",
713
+ )
714
+
715
+
716
+ def _submit_clarification(state: WizardState, value: str) -> Optional[str]:
717
+ val = (value or "").strip()
718
+ if not val:
719
+ state.clarification_response_path = ""
720
+ return "clarification: (none)"
721
+ p = _require_file(val, Path(state.project_root), "clarification-response")
722
+ state.clarification_response_path = str(p)
723
+ return f"clarification: {p}"
724
+
725
+
726
+ def _build_pr_template(state: WizardState) -> Prompt:
727
+ return Prompt(
728
+ step=S_PR_TEMPLATE, kind="text",
729
+ label=("PR 본문 템플릿 경로 1회성 override (빈 줄이면 project.json → "
730
+ "~/.okstra/config.json → 스킬 디폴트 순으로 자동 해석)"),
731
+ echo_template="pr-template: {value}",
732
+ )
733
+
734
+
735
+ def _submit_pr_template(state: WizardState, value: str) -> Optional[str]:
736
+ val = (value or "").strip()
737
+ if not val:
738
+ state.pr_template_path = ""
739
+ state.pr_template_scope = ""
740
+ return "pr-template: (auto-resolve)"
741
+ # Validate by re-using resolve_pr_template_path with override.
742
+ try:
743
+ resolved = resolve_pr_template_path(
744
+ Path(state.project_root), override_path=val
745
+ )
746
+ except PrTemplateError as exc:
747
+ raise WizardError(str(exc))
748
+ state.pr_template_path = str(resolved.path)
749
+ return f"pr-template: {resolved.path}"
750
+
751
+
752
+ def _build_pr_template_scope(state: WizardState) -> Prompt:
753
+ return Prompt(
754
+ step=S_PR_TEMPLATE_SCOPE, kind="pick",
755
+ label="방금 입력한 경로를 영구 저장할까요?",
756
+ options=[
757
+ _opt("once", "이번 run 만 (1회성)"),
758
+ _opt("project", "프로젝트에 저장 (project scope)"),
759
+ _opt("global", "전역에 저장 (global scope)"),
760
+ ],
761
+ echo_template="pr-template-scope: {value}",
762
+ )
763
+
764
+
765
+ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
766
+ if value not in ("once", "project", "global"):
767
+ raise WizardError(
768
+ f"expected 'once' / 'project' / 'global', got: {value!r}"
769
+ )
770
+ state.pr_template_scope = value
771
+ return f"pr-template-scope: {value}"
772
+
773
+
774
+ def _build_confirm(state: WizardState) -> Prompt:
775
+ return Prompt(
776
+ step=S_CONFIRM, kind="pick",
777
+ label="이대로 진행할까요?",
778
+ options=[_opt("proceed", "Proceed"), _opt("edit", "Edit")],
779
+ echo_template="confirm: {value}",
780
+ )
781
+
782
+
783
+ def _submit_confirm(state: WizardState, value: str) -> Optional[str]:
784
+ if value not in ("proceed", "edit"):
785
+ raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
786
+ state.confirmed = value == "proceed"
787
+ return f"confirm: {value}"
788
+
789
+
790
+ def _build_edit_target(state: WizardState) -> Prompt:
791
+ # offer every step that has been answered.
792
+ options: list[Option] = []
793
+ for sid in state.answered:
794
+ if sid in (S_CONFIRM, S_EDIT_TARGET):
795
+ continue
796
+ options.append(_opt(sid, sid))
797
+ return Prompt(
798
+ step=S_EDIT_TARGET, kind="pick",
799
+ label="어느 step 으로 돌아갈까요?",
800
+ options=options,
801
+ echo_template="edit-target: {value}",
802
+ )
803
+
804
+
805
+ def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
806
+ if not any(s.id == value for s in STEPS):
807
+ raise WizardError(f"unknown step: {value!r}")
808
+ state.edit_target = value
809
+ _reset_from(state, value)
810
+ state.confirmed = None
811
+ state.edit_target = ""
812
+ return f"edit-target: {value} (rewinding)"
813
+
814
+
815
+ # --- step registry ---
816
+
817
+ STEPS: list[Step] = [
818
+ Step(S_TASK_PICK,
819
+ applies=lambda s: s.is_new_task is None,
820
+ build=_build_task_pick, submit=_submit_task_pick,
821
+ owns=("is_new_task", "task_group", "task_id", "task_type",
822
+ "existing_brief_path", "profile_workers")),
823
+ Step(S_TASK_GROUP,
824
+ applies=lambda s: bool(s.is_new_task) and not s.task_group,
825
+ build=_build_task_group, submit=_submit_task_group,
826
+ owns=("task_group",)),
827
+ Step(S_TASK_ID,
828
+ applies=lambda s: bool(s.is_new_task) and bool(s.task_group) and not s.task_id,
829
+ build=_build_task_id, submit=_submit_task_id,
830
+ owns=("task_id",)),
831
+ Step(S_TASK_TYPE,
832
+ applies=lambda s: (s.is_new_task is not None
833
+ and (s.is_new_task is False or bool(s.task_id))
834
+ and S_TASK_TYPE not in s.answered),
835
+ build=_build_task_type, submit=_submit_task_type,
836
+ owns=("task_type", "profile_workers", "reuse_worktree")),
837
+ Step(S_BRIEF_KEEP,
838
+ applies=lambda s: (not s.is_new_task
839
+ and bool(s.existing_brief_path)
840
+ and s.keep_existing_brief is None
841
+ and S_TASK_TYPE in s.answered),
842
+ build=_build_brief_keep, submit=_submit_brief_keep,
843
+ owns=("keep_existing_brief", "brief_path")),
844
+ Step(S_BRIEF_PATH,
845
+ applies=lambda s: (S_TASK_TYPE in s.answered
846
+ and not s.brief_path
847
+ and (s.is_new_task
848
+ or s.keep_existing_brief is False
849
+ or (not s.is_new_task and not s.existing_brief_path))),
850
+ build=_build_brief_path, submit=_submit_brief_path,
851
+ owns=("brief_path",)),
852
+ Step(S_BASE_REF_PICK,
853
+ applies=lambda s: (S_TASK_TYPE in s.answered
854
+ and s.reuse_worktree is False
855
+ and S_BASE_REF_PICK not in s.answered
856
+ and bool(s.brief_path)),
857
+ build=_build_base_ref_pick, submit=_submit_base_ref_pick,
858
+ owns=("base_ref", "base_ref_pending_text")),
859
+ Step(S_BASE_REF_TEXT,
860
+ applies=lambda s: s.base_ref_pending_text,
861
+ build=_build_base_ref_text, submit=_submit_base_ref_text,
862
+ owns=("base_ref", "base_ref_pending_text")),
863
+ Step(S_APPROVED_PLAN,
864
+ applies=lambda s: (s.task_type == "implementation"
865
+ and not s.approved_plan_path
866
+ and bool(s.brief_path)
867
+ and (s.reuse_worktree is True
868
+ or S_BASE_REF_PICK in s.answered)
869
+ and not s.base_ref_pending_text),
870
+ build=_build_approved_plan, submit=_submit_approved_plan,
871
+ owns=("approved_plan_path",)),
872
+ Step(S_EXECUTOR,
873
+ applies=lambda s: (s.task_type == "implementation"
874
+ and bool(s.approved_plan_path)
875
+ and not s.executor),
876
+ build=_build_executor, submit=_submit_executor,
877
+ owns=("executor",)),
878
+ Step(S_DEFAULTS_OR_CUSTOM,
879
+ applies=lambda s: (_identity_ready(s)
880
+ and s.use_defaults is None),
881
+ build=_build_defaults_or_custom, submit=_submit_defaults_or_custom,
882
+ owns=("use_defaults",)),
883
+ # Customize branch — workers override only when non-empty profile + not impl.
884
+ Step(S_WORKERS_OVERRIDE,
885
+ applies=lambda s: (s.use_defaults is False
886
+ and s.task_type != "implementation"
887
+ and bool(s.profile_workers)
888
+ and S_WORKERS_OVERRIDE not in s.answered),
889
+ build=_build_workers_override, submit=_submit_workers_override,
890
+ owns=("workers_override",)),
891
+ Step(S_LEAD_MODEL,
892
+ applies=lambda s: (s.use_defaults is False
893
+ and S_LEAD_MODEL not in s.answered),
894
+ build=_build_lead_model, submit=_submit_lead_model,
895
+ owns=("lead_model",)),
896
+ Step(S_EXECUTOR_MODEL,
897
+ applies=lambda s: (s.use_defaults is False
898
+ and s.task_type == "implementation"
899
+ and S_EXECUTOR_MODEL not in s.answered),
900
+ build=_build_executor_model, submit=_submit_executor_model,
901
+ owns=("claude_model", "codex_model", "gemini_model")),
902
+ Step(S_CLAUDE_MODEL,
903
+ applies=lambda s: (s.use_defaults is False
904
+ and s.task_type != "implementation"
905
+ and "claude" in _resolved_roster(s)
906
+ and S_CLAUDE_MODEL not in s.answered),
907
+ build=_build_claude_model, submit=_submit_claude_model,
908
+ owns=("claude_model",)),
909
+ Step(S_CODEX_MODEL,
910
+ applies=lambda s: (s.use_defaults is False
911
+ and s.task_type != "implementation"
912
+ and "codex" in _resolved_roster(s)
913
+ and S_CODEX_MODEL not in s.answered),
914
+ build=_build_codex_model, submit=_submit_codex_model,
915
+ owns=("codex_model",)),
916
+ Step(S_GEMINI_MODEL,
917
+ applies=lambda s: (s.use_defaults is False
918
+ and s.task_type != "implementation"
919
+ and "gemini" in _resolved_roster(s)
920
+ and S_GEMINI_MODEL not in s.answered),
921
+ build=_build_gemini_model, submit=_submit_gemini_model,
922
+ owns=("gemini_model",)),
923
+ Step(S_REPORT_WRITER_MODEL,
924
+ applies=lambda s: (s.use_defaults is False
925
+ and (s.task_type == "implementation"
926
+ or "report-writer" in _resolved_roster(s))
927
+ and S_REPORT_WRITER_MODEL not in s.answered),
928
+ build=_build_report_writer_model, submit=_submit_report_writer_model,
929
+ owns=("report_writer_model",)),
930
+ Step(S_DIRECTIVE,
931
+ applies=lambda s: (s.use_defaults is False
932
+ and S_DIRECTIVE not in s.answered),
933
+ build=_build_directive, submit=_submit_directive,
934
+ owns=("directive",)),
935
+ Step(S_RELATED_TASKS,
936
+ applies=lambda s: (s.use_defaults is False
937
+ and S_RELATED_TASKS not in s.answered),
938
+ build=_build_related_tasks, submit=_submit_related_tasks,
939
+ owns=("related_tasks_raw",)),
940
+ Step(S_CLARIFICATION,
941
+ applies=lambda s: (s.use_defaults is False
942
+ and S_CLARIFICATION not in s.answered),
943
+ build=_build_clarification, submit=_submit_clarification,
944
+ owns=("clarification_response_path",)),
945
+ Step(S_PR_TEMPLATE,
946
+ applies=lambda s: (s.use_defaults is False
947
+ and s.task_type == "release-handoff"
948
+ and S_PR_TEMPLATE not in s.answered),
949
+ build=_build_pr_template, submit=_submit_pr_template,
950
+ owns=("pr_template_path", "pr_template_scope")),
951
+ Step(S_PR_TEMPLATE_SCOPE,
952
+ applies=lambda s: (s.use_defaults is False
953
+ and s.task_type == "release-handoff"
954
+ and bool(s.pr_template_path)
955
+ and S_PR_TEMPLATE_SCOPE not in s.answered),
956
+ build=_build_pr_template_scope, submit=_submit_pr_template_scope,
957
+ owns=("pr_template_scope",)),
958
+ Step(S_CONFIRM,
959
+ applies=lambda s: _ready_for_confirm(s) and s.confirmed is None,
960
+ build=_build_confirm, submit=_submit_confirm,
961
+ owns=("confirmed", "edit_target")),
962
+ Step(S_EDIT_TARGET,
963
+ applies=lambda s: s.confirmed is False and not s.edit_target,
964
+ build=_build_edit_target, submit=_submit_edit_target,
965
+ owns=()),
966
+ ]
967
+
968
+ STEP_BY_ID = {s.id: s for s in STEPS}
969
+
970
+
971
+ def _identity_ready(s: WizardState) -> bool:
972
+ """All identity questions (task pick → executor for impl) answered."""
973
+ if not s.task_type:
974
+ return False
975
+ if not s.brief_path:
976
+ return False
977
+ if s.reuse_worktree is False and S_BASE_REF_PICK not in s.answered:
978
+ return False
979
+ if s.base_ref_pending_text:
980
+ return False
981
+ if s.task_type == "implementation":
982
+ if not s.approved_plan_path or not s.executor:
983
+ return False
984
+ return True
985
+
986
+
987
+ def _ready_for_confirm(s: WizardState) -> bool:
988
+ if s.use_defaults is None:
989
+ return False
990
+ if s.use_defaults:
991
+ return True
992
+ # customize: every customize-branch step must be answered or not-applicable.
993
+ custom_ids = [S_WORKERS_OVERRIDE, S_LEAD_MODEL, S_EXECUTOR_MODEL,
994
+ S_CLAUDE_MODEL, S_CODEX_MODEL, S_GEMINI_MODEL,
995
+ S_REPORT_WRITER_MODEL, S_DIRECTIVE, S_RELATED_TASKS,
996
+ S_CLARIFICATION, S_PR_TEMPLATE, S_PR_TEMPLATE_SCOPE]
997
+ for sid in custom_ids:
998
+ step = STEP_BY_ID[sid]
999
+ if step.applies(s):
1000
+ return False
1001
+ return True
1002
+
1003
+
1004
+ def _reset_from(state: WizardState, target_step: str) -> None:
1005
+ """Clear state owned by target_step and all later steps; remove their
1006
+ entries from `answered`. Used when the user picks Edit."""
1007
+ idx = next((i for i, s in enumerate(STEPS) if s.id == target_step), -1)
1008
+ if idx < 0:
1009
+ return
1010
+ cleared_ids: set[str] = set()
1011
+ for step in STEPS[idx:]:
1012
+ cleared_ids.add(step.id)
1013
+ for fname in step.owns:
1014
+ _reset_field(state, fname)
1015
+ state.answered = [a for a in state.answered if a not in cleared_ids]
1016
+
1017
+
1018
+ _FIELD_DEFAULTS: dict[str, Any] = {
1019
+ "is_new_task": None, "task_group": "", "task_id": "",
1020
+ "existing_brief_path": "", "task_type": "",
1021
+ "profile_workers": [], "keep_existing_brief": None,
1022
+ "brief_path": "", "reuse_worktree": None, "base_ref": "",
1023
+ "base_ref_pending_text": False, "approved_plan_path": "",
1024
+ "executor": "", "use_defaults": None, "workers_override": "",
1025
+ "lead_model": "", "claude_model": "", "codex_model": "",
1026
+ "gemini_model": "", "report_writer_model": "", "directive": "",
1027
+ "related_tasks_raw": "", "clarification_response_path": "",
1028
+ "pr_template_path": "", "pr_template_scope": "",
1029
+ "confirmed": None, "edit_target": "",
1030
+ }
1031
+
1032
+
1033
+ def _reset_field(state: WizardState, fname: str) -> None:
1034
+ if fname in _FIELD_DEFAULTS:
1035
+ default = _FIELD_DEFAULTS[fname]
1036
+ # copy mutable defaults
1037
+ if isinstance(default, list):
1038
+ setattr(state, fname, [])
1039
+ else:
1040
+ setattr(state, fname, default)
1041
+
1042
+
1043
+ # ---- Public API ---------------------------------------------------------
1044
+
1045
+ def init_state(
1046
+ *, workspace_root: str, project_root: str, project_id: str
1047
+ ) -> WizardState:
1048
+ """Bootstrap a new wizard state."""
1049
+ return WizardState(
1050
+ workspace_root=workspace_root,
1051
+ project_root=project_root,
1052
+ project_id=project_id,
1053
+ )
1054
+
1055
+
1056
+ def next_prompt(state: WizardState) -> Prompt:
1057
+ if state.confirmed:
1058
+ return Prompt(step=S_DONE, kind="done")
1059
+ for step in STEPS:
1060
+ if step.id in state.answered:
1061
+ continue
1062
+ if step.applies(state):
1063
+ return step.build(state)
1064
+ return Prompt(step=S_DONE, kind="done")
1065
+
1066
+
1067
+ def submit(state: WizardState, value: str) -> dict[str, Any]:
1068
+ """Validate the answer for the *currently active* step and advance.
1069
+
1070
+ Returns {"echo": "...", "next": <Prompt JSON>}. Raises WizardError on
1071
+ validation failure (caller may re-prompt).
1072
+ """
1073
+ prompt = next_prompt(state)
1074
+ if prompt.kind == "done":
1075
+ return {"echo": "", "next": prompt.to_json()}
1076
+ step = STEP_BY_ID[prompt.step]
1077
+ echo = step.submit(state, value or "")
1078
+ if prompt.step not in state.answered:
1079
+ state.answered.append(prompt.step)
1080
+ nxt = next_prompt(state)
1081
+ return {"echo": echo or "", "next": nxt.to_json()}
1082
+
1083
+
1084
+ def render_args(state: WizardState) -> dict[str, str]:
1085
+ """Convert finalized state into ``okstra render-bundle`` argument map."""
1086
+ workers = state.workers_override.strip()
1087
+ if state.task_type == "implementation":
1088
+ workers = "" # profile-default roster is mandatory for impl
1089
+ base_ref = "" if state.reuse_worktree else state.base_ref
1090
+ pr_template = (
1091
+ state.pr_template_path
1092
+ if state.task_type == "release-handoff"
1093
+ else ""
1094
+ )
1095
+ return {
1096
+ "project-root": state.project_root,
1097
+ "project-id": state.project_id,
1098
+ "task-group": state.task_group,
1099
+ "task-id": state.task_id,
1100
+ "task-type": state.task_type,
1101
+ "task-brief": state.brief_path,
1102
+ "executor": state.executor,
1103
+ "approved-plan": state.approved_plan_path,
1104
+ "base-ref": base_ref,
1105
+ "workers": workers,
1106
+ "directive": state.directive,
1107
+ "lead-model": state.lead_model,
1108
+ "claude-model": state.claude_model,
1109
+ "codex-model": state.codex_model,
1110
+ "gemini-model": state.gemini_model,
1111
+ "report-writer-model": state.report_writer_model,
1112
+ "related-tasks": state.related_tasks_raw,
1113
+ "clarification-response": state.clarification_response_path,
1114
+ "pr-template-path": pr_template,
1115
+ }
1116
+
1117
+
1118
+ def confirmation_block(state: WizardState) -> str:
1119
+ """Human-readable echo of the resolved selections (for the Confirm step)."""
1120
+ lines: list[str] = ["선택 확인:"]
1121
+ lines.append(f" task-type : {state.task_type}")
1122
+ lines.append(f" task-key : {state.task_group}/{state.task_id}")
1123
+ if state.reuse_worktree:
1124
+ lines.append(" base-ref : (reusing existing worktree)")
1125
+ else:
1126
+ lines.append(f" base-ref : {state.base_ref}")
1127
+ if state.task_type == "implementation":
1128
+ lines.append(f" executor : {state.executor or '(default)'}")
1129
+ lines.append(
1130
+ f" workers : (프로필 기본 — executor + verifier 2 + report-writer)"
1131
+ )
1132
+ else:
1133
+ roster = state.workers_override or ",".join(state.profile_workers) or "(profile default)"
1134
+ lines.append(f" workers : {roster}")
1135
+ lines.append(f" lead-model : {state.lead_model or 'default'}")
1136
+ if state.claude_model:
1137
+ lines.append(f" claude-model : {state.claude_model}")
1138
+ if state.codex_model:
1139
+ lines.append(f" codex-model : {state.codex_model}")
1140
+ if state.gemini_model:
1141
+ lines.append(f" gemini-model : {state.gemini_model}")
1142
+ if state.report_writer_model:
1143
+ lines.append(f" report-writer : {state.report_writer_model}")
1144
+ lines.append(f" directive : {state.directive or '(none)'}")
1145
+ if state.task_type == "implementation":
1146
+ lines.append(f" approved-plan : {state.approved_plan_path}")
1147
+ if state.clarification_response_path:
1148
+ lines.append(f" clarification : {state.clarification_response_path}")
1149
+ if state.task_type == "release-handoff" and state.pr_template_path:
1150
+ lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
1151
+ return "\n".join(lines)
1152
+
1153
+
1154
+ # ---- File I/O helpers (used by CLI) -------------------------------------
1155
+
1156
+ def load_state_file(path: Path) -> WizardState:
1157
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
1158
+ return WizardState.from_json(data)
1159
+
1160
+
1161
+ def save_state_file(path: Path, state: WizardState) -> None:
1162
+ Path(path).write_text(
1163
+ json.dumps(state.to_json(), indent=2, ensure_ascii=False),
1164
+ encoding="utf-8",
1165
+ )
1166
+
1167
+
1168
+ # ---- CLI entrypoint -----------------------------------------------------
1169
+
1170
+ def _cli(argv: list[str]) -> int:
1171
+ """``python3 -m okstra_ctl.wizard <subcmd>`` driver.
1172
+
1173
+ Subcommands:
1174
+ init --state-file PATH --workspace-root P --project-root P --project-id ID
1175
+ step --state-file PATH [--answer VALUE]
1176
+ render-args --state-file PATH
1177
+ confirmation --state-file PATH
1178
+ """
1179
+ import argparse
1180
+
1181
+ parser = argparse.ArgumentParser(prog="okstra_ctl.wizard")
1182
+ sub = parser.add_subparsers(dest="cmd", required=True)
1183
+
1184
+ p_init = sub.add_parser("init")
1185
+ p_init.add_argument("--state-file", required=True)
1186
+ p_init.add_argument("--workspace-root", required=True)
1187
+ p_init.add_argument("--project-root", required=True)
1188
+ p_init.add_argument("--project-id", required=True)
1189
+
1190
+ p_step = sub.add_parser("step")
1191
+ p_step.add_argument("--state-file", required=True)
1192
+ p_step.add_argument("--answer", default=None)
1193
+
1194
+ p_render = sub.add_parser("render-args")
1195
+ p_render.add_argument("--state-file", required=True)
1196
+
1197
+ p_conf = sub.add_parser("confirmation")
1198
+ p_conf.add_argument("--state-file", required=True)
1199
+
1200
+ args = parser.parse_args(argv)
1201
+ state_path = Path(args.state_file)
1202
+
1203
+ if args.cmd == "init":
1204
+ state = init_state(
1205
+ workspace_root=args.workspace_root,
1206
+ project_root=args.project_root,
1207
+ project_id=args.project_id,
1208
+ )
1209
+ save_state_file(state_path, state)
1210
+ first = next_prompt(state)
1211
+ print(json.dumps({"ok": True, "next": first.to_json()},
1212
+ ensure_ascii=False, indent=2))
1213
+ return 0
1214
+
1215
+ if args.cmd == "step":
1216
+ state = load_state_file(state_path)
1217
+ try:
1218
+ if args.answer is None:
1219
+ result = {"echo": "", "next": next_prompt(state).to_json()}
1220
+ else:
1221
+ result = submit(state, args.answer)
1222
+ except WizardError as exc:
1223
+ save_state_file(state_path, state)
1224
+ print(json.dumps({"ok": False, "error": str(exc),
1225
+ "current": next_prompt(state).to_json()},
1226
+ ensure_ascii=False, indent=2))
1227
+ return 0
1228
+ save_state_file(state_path, state)
1229
+ print(json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2))
1230
+ return 0
1231
+
1232
+ if args.cmd == "render-args":
1233
+ state = load_state_file(state_path)
1234
+ print(json.dumps({"ok": True, "args": render_args(state)},
1235
+ ensure_ascii=False, indent=2))
1236
+ return 0
1237
+
1238
+ if args.cmd == "confirmation":
1239
+ state = load_state_file(state_path)
1240
+ print(json.dumps({"ok": True, "text": confirmation_block(state)},
1241
+ ensure_ascii=False, indent=2))
1242
+ return 0
1243
+
1244
+ return 2
1245
+
1246
+
1247
+ if __name__ == "__main__":
1248
+ import sys
1249
+ raise SystemExit(_cli(sys.argv[1:]))