okstra 0.34.1 → 0.36.1

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 (108) hide show
  1. package/README.kr.md +27 -19
  2. package/README.md +27 -19
  3. package/docs/kr/architecture.md +59 -45
  4. package/docs/kr/cli.md +61 -18
  5. package/docs/pr-template-usage.md +65 -0
  6. package/docs/project-structure-overview.md +353 -354
  7. package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +1 -1
  8. package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +1 -1
  9. package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +1 -1
  10. package/docs/superpowers/plans/2026-05-20-final-report-language.md +1501 -0
  11. package/docs/superpowers/plans/2026-05-20-implementation-planning-multi-stage.md +1267 -0
  12. package/docs/superpowers/plans/2026-05-20-okstra-run-prompt-sot-b1.md +1007 -0
  13. package/docs/superpowers/plans/2026-05-20-wizard-messages-json-sot.md +720 -0
  14. package/docs/superpowers/plans/2026-05-20-wizard-prompt-json-sot-a1.md +681 -0
  15. package/docs/superpowers/plans/2026-05-21-improvement-discovery-task-type.md +1691 -0
  16. package/docs/superpowers/plans/2026-05-24-implementation-lead-context-slimming.md +1700 -0
  17. package/docs/superpowers/specs/2026-05-20-final-report-language-design.md +383 -0
  18. package/docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md +320 -0
  19. package/docs/superpowers/specs/2026-05-20-okstra-run-prompt-sot-design.md +299 -0
  20. package/docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md +335 -0
  21. package/docs/task-process/README.md +74 -0
  22. package/docs/task-process/common-flow.md +166 -0
  23. package/docs/task-process/error-analysis.md +101 -0
  24. package/docs/task-process/final-verification.md +167 -0
  25. package/docs/task-process/implementation-planning.md +128 -0
  26. package/docs/task-process/implementation.md +149 -0
  27. package/docs/task-process/release-handoff.md +206 -0
  28. package/docs/task-process/requirements-discovery.md +115 -0
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/SKILL.md +30 -7
  32. package/runtime/agents/workers/claude-worker.md +31 -6
  33. package/runtime/agents/workers/codex-worker.md +37 -10
  34. package/runtime/agents/workers/gemini-worker.md +34 -7
  35. package/runtime/agents/workers/report-writer-worker.md +19 -10
  36. package/runtime/bin/okstra-central.sh +6 -6
  37. package/runtime/bin/okstra-codex-exec.sh +49 -28
  38. package/runtime/bin/okstra-gemini-exec.sh +39 -21
  39. package/runtime/bin/okstra-render-final-report.py +13 -2
  40. package/runtime/bin/okstra-wrapper-status.py +155 -0
  41. package/runtime/bin/okstra.sh +2 -2
  42. package/runtime/prompts/launch.template.md +1 -0
  43. package/runtime/prompts/profiles/_common-contract.md +11 -6
  44. package/runtime/prompts/profiles/_implementation-deliverable.md +53 -0
  45. package/runtime/prompts/profiles/_implementation-executor.md +60 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +76 -0
  47. package/runtime/prompts/profiles/error-analysis.md +3 -7
  48. package/runtime/prompts/profiles/implementation-planning.md +22 -21
  49. package/runtime/prompts/profiles/implementation.md +28 -118
  50. package/runtime/prompts/profiles/improvement-discovery.md +42 -0
  51. package/runtime/prompts/profiles/release-handoff.md +1 -1
  52. package/runtime/prompts/profiles/requirements-discovery.md +8 -12
  53. package/runtime/prompts/wizard/prompts.ko.json +230 -0
  54. package/runtime/python/lib/okstra/cli.sh +2 -49
  55. package/runtime/python/lib/okstra/globals.sh +21 -21
  56. package/runtime/python/lib/okstra/interactive.sh +7 -7
  57. package/runtime/python/okstra_ctl/clarification_items.py +3 -9
  58. package/runtime/python/okstra_ctl/consumers.py +53 -0
  59. package/runtime/python/okstra_ctl/final_report_schema.py +0 -7
  60. package/runtime/python/okstra_ctl/i18n.py +73 -0
  61. package/runtime/python/okstra_ctl/improvement_lenses.py +44 -0
  62. package/runtime/python/okstra_ctl/index.py +1 -1
  63. package/runtime/python/okstra_ctl/paths.py +26 -20
  64. package/runtime/python/okstra_ctl/render.py +166 -207
  65. package/runtime/python/okstra_ctl/render_final_report.py +53 -10
  66. package/runtime/python/okstra_ctl/run.py +299 -108
  67. package/runtime/python/okstra_ctl/run_context.py +22 -0
  68. package/runtime/python/okstra_ctl/seeding.py +186 -0
  69. package/runtime/python/okstra_ctl/session.py +65 -7
  70. package/runtime/python/okstra_ctl/wizard.py +348 -127
  71. package/runtime/python/okstra_ctl/workflow.py +21 -2
  72. package/runtime/python/okstra_ctl/worktree.py +54 -1
  73. package/runtime/python/okstra_project/resolver.py +4 -3
  74. package/runtime/python/okstra_token_usage/report.py +2 -2
  75. package/runtime/schemas/final-report-v1.0.schema.json +22 -16
  76. package/runtime/skills/okstra-brief/SKILL.md +102 -218
  77. package/runtime/skills/okstra-convergence/SKILL.md +2 -3
  78. package/runtime/skills/okstra-inspect/SKILL.md +581 -0
  79. package/runtime/skills/okstra-report-writer/SKILL.md +35 -15
  80. package/runtime/skills/okstra-run/SKILL.md +8 -7
  81. package/runtime/skills/okstra-schedule/SKILL.md +14 -157
  82. package/runtime/skills/okstra-setup/SKILL.md +28 -1
  83. package/runtime/skills/okstra-team-contract/SKILL.md +16 -107
  84. package/runtime/templates/okstra.CLAUDE.md +104 -0
  85. package/runtime/templates/reports/brief.template.md +204 -0
  86. package/runtime/templates/reports/final-report.template.md +93 -98
  87. package/runtime/templates/reports/i18n/en.json +135 -0
  88. package/runtime/templates/reports/i18n/ko.json +135 -0
  89. package/runtime/templates/reports/implementation-planning-input.template.md +18 -0
  90. package/runtime/templates/reports/improvement-discovery-input.template.md +78 -0
  91. package/runtime/templates/reports/schedule.template.md +12 -3
  92. package/runtime/templates/reports/task-brief.template.md +2 -2
  93. package/runtime/templates/worker-prompt-preamble.md +108 -0
  94. package/runtime/validators/lib/fixtures.sh +30 -0
  95. package/runtime/validators/lib/runners.sh +1 -1
  96. package/runtime/validators/validate-implementation-plan-stages.py +211 -0
  97. package/runtime/validators/validate-run.py +121 -26
  98. package/runtime/validators/validate-workflow.sh +2 -2
  99. package/runtime/validators/validate_improvement_report.py +275 -0
  100. package/src/config.mjs +18 -0
  101. package/src/install.mjs +41 -14
  102. package/src/setup.mjs +133 -1
  103. package/src/uninstall.mjs +27 -3
  104. package/runtime/skills/okstra-history/SKILL.md +0 -165
  105. package/runtime/skills/okstra-logs/SKILL.md +0 -173
  106. package/runtime/skills/okstra-report-finder/SKILL.md +0 -111
  107. package/runtime/skills/okstra-status/SKILL.md +0 -246
  108. package/runtime/skills/okstra-time-summary/SKILL.md +0 -172
@@ -32,7 +32,10 @@ from okstra_ctl.models import (
32
32
  resolve_model_metadata,
33
33
  )
34
34
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
35
- from okstra_ctl.run import APPROVED_PLAN_PATTERN
35
+ from okstra_ctl.run import (
36
+ APPROVED_FRONTMATTER_PATTERN,
37
+ _extract_frontmatter_block,
38
+ )
36
39
  from okstra_ctl.workers import (
37
40
  ALLOWED_WORKERS,
38
41
  WorkersError,
@@ -54,11 +57,12 @@ from okstra_project.state import (
54
57
 
55
58
  TASK_TYPES: list[tuple[str, str]] = [
56
59
  ("requirements-discovery", "Classify request and route to next safe phase"),
60
+ ("improvement-discovery", "Find improvement candidates within a codebase scope and lens whitelist"),
57
61
  ("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
58
62
  ("implementation-planning", "Plan options + request user approval"),
59
63
  ("implementation", "Execute approved plan (requires approved final-report)"),
60
64
  ("final-verification", "Acceptance + residual-risk review"),
61
- ("release-handoff", "Drive commit/push/PR after accepted final-verification"),
65
+ ("release-handoff", "Drive commit/push/PR reuse the implementation task-key (new keys fail the empty-commits gate)"),
62
66
  ]
63
67
  TASK_TYPE_VALUES = [tt for tt, _ in TASK_TYPES]
64
68
 
@@ -168,6 +172,7 @@ S_BASE_REF_PICK = "base_ref_pick"
168
172
  S_BASE_REF_TEXT = "base_ref_text"
169
173
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
170
174
  S_APPROVED_PLAN = "approved_plan"
175
+ S_STAGE_PICK = "stage_pick"
171
176
  S_EXECUTOR = "executor"
172
177
  S_DEFAULTS_OR_CUSTOM = "defaults_or_custom"
173
178
  S_WORKERS_OVERRIDE = "workers_override"
@@ -230,6 +235,7 @@ class WizardState:
230
235
  # impl extras
231
236
  approved_plan_path: str = ""
232
237
  approved_plan_pending_text: bool = False
238
+ selected_stage: str = "auto"
233
239
  executor: str = ""
234
240
 
235
241
  # customize
@@ -330,10 +336,24 @@ def _require_file(path_str: str, project_root: Path, label: str) -> Path:
330
336
  def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
331
337
  p = _require_file(path_str, project_root, "approved plan")
332
338
  body = p.read_text(encoding="utf-8", errors="replace")
333
- if not APPROVED_PLAN_PATTERN.search(body):
339
+ frontmatter = _extract_frontmatter_block(body)
340
+ if frontmatter is None:
341
+ raise WizardError(
342
+ f"approved plan has no YAML frontmatter block: {p}\n"
343
+ " expected the report to begin with `---\\n...\\n---\\n`."
344
+ )
345
+ m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
346
+ if not m:
347
+ raise WizardError(
348
+ f"approved plan frontmatter has no `approved:` field: {p}\n"
349
+ " expected `approved: true` (report-writer emits `approved: false` "
350
+ "by default; flip it once approved)."
351
+ )
352
+ if m.group(1).lower() != "true":
334
353
  raise WizardError(
335
- f"approved plan has no APPROVED marker: {p}\n"
336
- ' canonical form (top of report): "- [x] Approved"'
354
+ f"approved plan is not yet approved (frontmatter `approved: {m.group(1)}`): {p}\n"
355
+ " edit the report and change the line to `approved: true`, or re-run "
356
+ "okstra with `--approve` to flip it from the CLI."
337
357
  )
338
358
  return p
339
359
 
@@ -411,6 +431,122 @@ def _load_profile_optional_workers(
411
431
  return resolve_optional_workers(_profile_path(workspace_root, task_type))
412
432
 
413
433
 
434
+ # --------------------------------------------------------------------------- #
435
+ # Wizard prompt JSON SOT (Phase A1)
436
+ # --------------------------------------------------------------------------- #
437
+
438
+ _WIZARD_ROOT_CACHE: dict[str, dict] = {}
439
+
440
+
441
+ def _load_wizard_root(workspace_root: str) -> dict:
442
+ """Load and cache the full wizard prompts JSON root for the given workspace_root.
443
+
444
+ Returns the entire parsed JSON object (with `schema_version`, `locale`, `steps`,
445
+ optional `confirmation` etc. at top level). Callers that only need the steps
446
+ dict should index `["steps"]` on the return value; `_msg` reads top-level
447
+ sections like `confirmation`.
448
+ """
449
+ if workspace_root in _WIZARD_ROOT_CACHE:
450
+ return _WIZARD_ROOT_CACHE[workspace_root]
451
+ path = Path(workspace_root) / "prompts" / "wizard" / "prompts.ko.json"
452
+ if not path.is_file():
453
+ raise WizardError(
454
+ f"wizard prompt SOT not found: {path}. "
455
+ "Re-run `okstra install` or check the workspace_root."
456
+ )
457
+ try:
458
+ raw = json.loads(path.read_text(encoding="utf-8"))
459
+ except json.JSONDecodeError as exc:
460
+ raise WizardError(f"wizard prompt JSON malformed: {path}: {exc}") from exc
461
+ if not isinstance(raw, dict):
462
+ raise WizardError(f"wizard prompt JSON root must be an object: {path}")
463
+ if raw.get("schema_version") != 1:
464
+ raise WizardError(
465
+ f"wizard prompt schema_version mismatch (expected 1): {path}"
466
+ )
467
+ if raw.get("locale") != "ko":
468
+ raise WizardError(
469
+ f"wizard prompt locale unsupported (expected 'ko'): {path}"
470
+ )
471
+ steps = raw.get("steps")
472
+ if not isinstance(steps, dict):
473
+ raise WizardError(f"wizard prompt 'steps' missing or not a dict: {path}")
474
+ _WIZARD_ROOT_CACHE[workspace_root] = raw
475
+ return raw
476
+
477
+
478
+ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
479
+ """Look up a wizard prompt entry by step_id and interpolate placeholders.
480
+
481
+ Returns a dict with keys: 'label' (str, possibly interpolated),
482
+ 'echo_template' (str, raw — contains `{value}` for the user's answer),
483
+ 'options' (dict[value → label], may be empty).
484
+
485
+ Returned dict also includes echo_variants and errors sub-dicts (may be empty).
486
+
487
+ Raises WizardError if the step_id is unknown or a required placeholder
488
+ is missing from the provided vars.
489
+ """
490
+ steps = _load_wizard_root(workspace_root)["steps"]
491
+ raw = steps.get(step_id)
492
+ if raw is None:
493
+ raise WizardError(f"unknown wizard step_id: {step_id!r}")
494
+ label_template = raw.get("label", "")
495
+ try:
496
+ label = label_template.format(**vars)
497
+ except KeyError as exc:
498
+ missing = exc.args[0] if exc.args else "<unknown>"
499
+ raise WizardError(
500
+ f"missing placeholder {missing!r} for wizard step {step_id!r}"
501
+ ) from exc
502
+ return {
503
+ "label": label,
504
+ "echo_template": raw.get("echo_template", ""),
505
+ "options": raw.get("options", {}),
506
+ "echo_variants": raw.get("echo_variants", {}),
507
+ "errors": raw.get("errors", {}),
508
+ }
509
+
510
+
511
+ def _msg(workspace_root: str, section: str, key: str, **vars: str) -> str:
512
+ """Look up a top-level message (e.g., `confirmation.header`) and interpolate.
513
+
514
+ Returns the string value at `<section>.<key>`. Raises WizardError if the
515
+ section or key is unknown, or if a required placeholder is missing.
516
+
517
+ Use for wizard-level messages (confirmation block etc.). For step-scoped
518
+ messages (echo_variants, errors), use `_p()` instead.
519
+ """
520
+ root = _load_wizard_root(workspace_root)
521
+ sect = root.get(section)
522
+ if not isinstance(sect, dict):
523
+ raise WizardError(f"unknown wizard section: {section!r}")
524
+ template = sect.get(key)
525
+ if template is None:
526
+ raise WizardError(f"unknown wizard message: {section}.{key!r}")
527
+ try:
528
+ return template.format(**vars)
529
+ except KeyError as exc:
530
+ missing = exc.args[0] if exc.args else "<unknown>"
531
+ raise WizardError(
532
+ f"missing placeholder {missing!r} for wizard message {section}.{key!r}"
533
+ ) from exc
534
+
535
+
536
+ def _static_options(t: dict) -> list[tuple[str, str]]:
537
+ """Return (value, label) pairs from the SOT's static options dict, excluding
538
+ suffix-decoration tokens (`_<NAME>_SUFFIX` / `_<NAME>_LABEL`).
539
+
540
+ Used by hybrid `_build_*` functions that mix dynamic and static options to
541
+ avoid leaking `_RECOMMENDED_SUFFIX` / `_OPTIONAL_SUFFIX` / `_DEFAULT_SUFFIX`
542
+ as a literal option entry.
543
+ """
544
+ return [
545
+ (k, v) for k, v in t.get("options", {}).items()
546
+ if not (k.startswith("_") and (k.endswith("_SUFFIX") or k.endswith("_LABEL")))
547
+ ]
548
+
549
+
414
550
  def _resolved_roster(state: WizardState) -> list[str]:
415
551
  """Effective worker list AFTER override. Implementation: profile default
416
552
  (caller never asks for override). Others: override or profile default."""
@@ -464,24 +600,26 @@ def _opt(value: str, label: str = "", description: str = "") -> Option:
464
600
  # --- builders ---
465
601
 
466
602
  def _build_task_pick(state: WizardState) -> Prompt:
603
+ t = _p(state.workspace_root, "task_pick")
467
604
  project_root = Path(state.project_root)
468
605
  tasks = list_project_tasks(project_root)
469
606
  latest = read_latest_task(project_root) or {}
470
607
  latest_key = latest.get("taskKey") or ""
608
+ latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
471
609
  options: list[Option] = []
472
610
  for entry in tasks[:8]:
473
611
  key = entry.get("taskKey") or ""
474
612
  ttype = entry.get("taskType") or ""
475
613
  phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
476
614
  nxt = (entry.get("workflow") or {}).get("nextRecommendedPhase") or ""
477
- suffix = " (latest)" if key == latest_key else ""
615
+ suffix = latest_suffix if key == latest_key else ""
478
616
  label = f"{key} · {phase} · next: {nxt}{suffix}"
479
617
  options.append(_opt(value=key, label=label))
480
- options.append(_opt(value=TASK_PICK_NEW_TOKEN,
481
- label="Start a brand-new task"))
618
+ for value, label in _static_options(t):
619
+ options.append(_opt(value=value, label=label))
482
620
  return Prompt(step=S_TASK_PICK, kind="pick",
483
- label="어느 task?", options=options,
484
- echo_template="task: {value}")
621
+ label=t["label"], options=options,
622
+ echo_template=t["echo_template"])
485
623
 
486
624
 
487
625
  def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
@@ -521,18 +659,21 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
521
659
  def _build_task_group(state: WizardState) -> Prompt:
522
660
  sugg = state.task_group_suggestion
523
661
  if sugg:
662
+ t = _p(state.workspace_root, "task_group_with_suggestion",
663
+ suggestion=sugg)
664
+ options = [
665
+ _opt(k, v.format(suggestion=sugg))
666
+ for k, v in t["options"].items()
667
+ ]
524
668
  return Prompt(
525
669
  step=S_TASK_GROUP, kind="pick",
526
- label=f"Task group? (brief 추천: {sugg})",
527
- options=[
528
- _opt(PICK_USE_SUGGESTED, f"brief 값 사용: {sugg}"),
529
- _opt(PICK_TYPE_CUSTOM, "다른 값 입력"),
530
- ],
531
- echo_template="task-group: {value}",
670
+ label=t["label"], options=options,
671
+ echo_template=t["echo_template"],
532
672
  )
673
+ t = _p(state.workspace_root, "task_group")
533
674
  return Prompt(step=S_TASK_GROUP, kind="text",
534
- label="Task group 을 알려주세요 (예: backend-api, INV-1234, refactor)",
535
- echo_template="task-group: {value}")
675
+ label=t["label"],
676
+ echo_template=t["echo_template"])
536
677
 
537
678
 
538
679
  def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
@@ -545,7 +686,8 @@ def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
545
686
  return f"task-group: {state.task_group} (brief)"
546
687
  if value == PICK_TYPE_CUSTOM:
547
688
  state.task_group_pending_text = True
548
- return f"task-group: (직접 입력)"
689
+ t = _p(state.workspace_root, "task_group")
690
+ return t["echo_variants"]["free_input"]
549
691
  raise WizardError(
550
692
  f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
551
693
  f"got: {value!r}"
@@ -556,9 +698,10 @@ def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
556
698
 
557
699
 
558
700
  def _build_task_group_text(state: WizardState) -> Prompt:
701
+ t = _p(state.workspace_root, "task_group_text")
559
702
  return Prompt(step=S_TASK_GROUP_TEXT, kind="text",
560
- label="Task group 을 입력해주세요 (예: backend-api, INV-1234, refactor)",
561
- echo_template="task-group: {value}")
703
+ label=t["label"],
704
+ echo_template=t["echo_template"])
562
705
 
563
706
 
564
707
  def _submit_task_group_text(state: WizardState, value: str) -> Optional[str]:
@@ -570,18 +713,21 @@ def _submit_task_group_text(state: WizardState, value: str) -> Optional[str]:
570
713
  def _build_task_id(state: WizardState) -> Prompt:
571
714
  sugg = state.task_id_suggestion
572
715
  if sugg:
716
+ t = _p(state.workspace_root, "task_id_with_suggestion",
717
+ suggestion=sugg)
718
+ options = [
719
+ _opt(k, v.format(suggestion=sugg))
720
+ for k, v in t["options"].items()
721
+ ]
573
722
  return Prompt(
574
723
  step=S_TASK_ID, kind="pick",
575
- label=f"Task id? (brief 추천: {sugg})",
576
- options=[
577
- _opt(PICK_USE_SUGGESTED, f"brief 값 사용: {sugg}"),
578
- _opt(PICK_TYPE_CUSTOM, "다른 값 입력"),
579
- ],
580
- echo_template="task-id: {value}",
724
+ label=t["label"], options=options,
725
+ echo_template=t["echo_template"],
581
726
  )
727
+ t = _p(state.workspace_root, "task_id")
582
728
  return Prompt(step=S_TASK_ID, kind="text",
583
- label="Task id 를 알려주세요 (예: login-error-analysis, dev-9043)",
584
- echo_template="task-id: {value}")
729
+ label=t["label"],
730
+ echo_template=t["echo_template"])
585
731
 
586
732
 
587
733
  def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
@@ -592,7 +738,8 @@ def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
592
738
  return f"task-id: {state.task_id} (brief)"
593
739
  if value == PICK_TYPE_CUSTOM:
594
740
  state.task_id_pending_text = True
595
- return f"task-id: (직접 입력)"
741
+ t = _p(state.workspace_root, "task_id")
742
+ return t["echo_variants"]["free_input"]
596
743
  raise WizardError(
597
744
  f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
598
745
  f"got: {value!r}"
@@ -603,9 +750,10 @@ def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
603
750
 
604
751
 
605
752
  def _build_task_id_text(state: WizardState) -> Prompt:
753
+ t = _p(state.workspace_root, "task_id_text")
606
754
  return Prompt(step=S_TASK_ID_TEXT, kind="text",
607
- label="Task id 를 입력해주세요 (예: login-error-analysis, dev-9043)",
608
- echo_template="task-id: {value}")
755
+ label=t["label"],
756
+ echo_template=t["echo_template"])
609
757
 
610
758
 
611
759
  def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
@@ -615,20 +763,22 @@ def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
615
763
 
616
764
 
617
765
  def _build_task_type(state: WizardState) -> Prompt:
766
+ t = _p(state.workspace_root, "task_type")
767
+ recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
618
768
  options: list[Option] = []
619
769
  recommended = state.task_type if not state.is_new_task else ""
620
770
  seen: list[str] = []
621
771
  if recommended and recommended in TASK_TYPE_VALUES:
622
772
  d = dict(TASK_TYPES)[recommended]
623
- options.append(_opt(recommended, f"{recommended} (recommended)", d))
773
+ options.append(_opt(recommended, f"{recommended}{recommended_suffix}", d))
624
774
  seen.append(recommended)
625
775
  for tt, desc in TASK_TYPES:
626
776
  if tt in seen:
627
777
  continue
628
778
  options.append(_opt(tt, tt, desc))
629
779
  return Prompt(step=S_TASK_TYPE, kind="pick",
630
- label="Task type?", options=options,
631
- echo_template="task-type: {value}")
780
+ label=t["label"], options=options,
781
+ echo_template=t["echo_template"])
632
782
 
633
783
 
634
784
  def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
@@ -648,11 +798,13 @@ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
648
798
 
649
799
 
650
800
  def _build_brief_keep(state: WizardState) -> Prompt:
801
+ t = _p(state.workspace_root, "brief_keep",
802
+ existing_brief_path=state.existing_brief_path)
651
803
  return Prompt(
652
804
  step=S_BRIEF_KEEP, kind="pick",
653
- label=f"기존 brief 경로 [{state.existing_brief_path}] 를 유지할까요?",
654
- options=[_opt("keep", "유지"), _opt("change", "변경")],
655
- echo_template="brief: {value}",
805
+ label=t["label"],
806
+ options=[_opt(k, v) for k, v in t["options"].items()],
807
+ echo_template=t["echo_template"],
656
808
  )
657
809
 
658
810
 
@@ -662,15 +814,18 @@ def _submit_brief_keep(state: WizardState, value: str) -> Optional[str]:
662
814
  state.keep_existing_brief = value == "keep"
663
815
  if state.keep_existing_brief:
664
816
  state.brief_path = state.existing_brief_path
665
- return f"brief: {state.brief_path} (유지)"
817
+ t = _p(state.workspace_root, "brief_keep",
818
+ existing_brief_path=state.existing_brief_path)
819
+ return t["echo_variants"]["kept"].format(brief_path=state.brief_path)
666
820
  return None # next prompt is S_BRIEF_PATH
667
821
 
668
822
 
669
823
  def _build_brief_path(state: WizardState) -> Prompt:
824
+ t = _p(state.workspace_root, "brief_path")
670
825
  return Prompt(
671
826
  step=S_BRIEF_PATH, kind="text",
672
- label="task brief markdown 의 경로를 알려주세요 (project root 기준 상대경로 또는 절대경로)",
673
- echo_template="brief: {value}",
827
+ label=t["label"],
828
+ echo_template=t["echo_template"],
674
829
  )
675
830
 
676
831
 
@@ -689,14 +844,17 @@ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
689
844
 
690
845
 
691
846
  def _build_base_ref_pick(state: WizardState) -> Prompt:
692
- options = [_opt(r, "main (recommended)" if r == "main" else r)
847
+ t = _p(state.workspace_root, "base_ref_pick")
848
+ recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
849
+ options = [_opt(r, f"main{recommended_suffix}" if r == "main" else r)
693
850
  for r in CANONICAL_BASE_REFS]
694
- options.append(_opt(BASE_REF_FREE_INPUT_TOKEN, "직접 입력"))
851
+ for value, label in _static_options(t):
852
+ options.append(_opt(value=value, label=label))
695
853
  return Prompt(
696
854
  step=S_BASE_REF_PICK, kind="pick",
697
- label="이 task worktree 의 base branch?",
855
+ label=t["label"],
698
856
  options=options,
699
- echo_template="base-ref: {value}",
857
+ echo_template=t["echo_template"],
700
858
  )
701
859
 
702
860
 
@@ -712,10 +870,11 @@ def _submit_base_ref_pick(state: WizardState, value: str) -> Optional[str]:
712
870
 
713
871
 
714
872
  def _build_base_ref_text(state: WizardState) -> Prompt:
873
+ t = _p(state.workspace_root, "base_ref_text")
715
874
  return Prompt(
716
875
  step=S_BASE_REF_TEXT, kind="text",
717
- label="base ref 를 입력해주세요 (branch, tag, 또는 short/full SHA)",
718
- echo_template="base-ref: {value}",
876
+ label=t["label"],
877
+ echo_template=t["echo_template"],
719
878
  )
720
879
 
721
880
 
@@ -769,15 +928,17 @@ def _latest_implementation_planning_report(state: WizardState) -> Optional[Path]
769
928
 
770
929
  def _build_approved_plan_pick(state: WizardState) -> Prompt:
771
930
  default = _latest_implementation_planning_report(state)
931
+ t = _p(state.workspace_root, "approved_plan_pick",
932
+ default=str(default) if default is not None else "")
772
933
  options = [
773
- _opt(PICK_USE_DEFAULT, f"기본 경로 사용: {default}"),
774
- _opt(PICK_OTHER, "다른 경로 입력"),
934
+ _opt(k, v.format(default=str(default) if default is not None else ""))
935
+ for k, v in t["options"].items()
775
936
  ]
776
937
  return Prompt(
777
938
  step=S_APPROVED_PLAN_PICK, kind="pick",
778
- label=f"approved final-report 경로 (기본: {default})",
939
+ label=t["label"],
779
940
  options=options,
780
- echo_template="approved-plan(pick): {value}",
941
+ echo_template=t["echo_template"],
781
942
  )
782
943
 
783
944
 
@@ -785,9 +946,8 @@ def _submit_approved_plan_pick(state: WizardState, value: str) -> Optional[str]:
785
946
  if value == PICK_USE_DEFAULT:
786
947
  default = _latest_implementation_planning_report(state)
787
948
  if default is None:
788
- raise WizardError(
789
- "기본 approved-plan 경로를 찾을 수 없습니다. '다른 경로 입력'을 선택하세요."
790
- )
949
+ t = _p(state.workspace_root, "approved_plan_pick", default="")
950
+ raise WizardError(t["errors"]["default_not_found"])
791
951
  p = _validate_approved_plan(str(default), Path(state.project_root))
792
952
  state.approved_plan_path = str(p)
793
953
  state.approved_plan_pending_text = False
@@ -802,10 +962,11 @@ def _submit_approved_plan_pick(state: WizardState, value: str) -> Optional[str]:
802
962
 
803
963
 
804
964
  def _build_approved_plan(state: WizardState) -> Prompt:
965
+ t = _p(state.workspace_root, "approved_plan")
805
966
  return Prompt(
806
967
  step=S_APPROVED_PLAN, kind="text",
807
- label="approved final-report.md 의 경로를 알려주세요 (APPROVED 마커 필수)",
808
- echo_template="approved-plan: {value}",
968
+ label=t["label"],
969
+ echo_template=t["echo_template"],
809
970
  )
810
971
 
811
972
 
@@ -816,15 +977,60 @@ def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
816
977
  return f"approved-plan: {p}"
817
978
 
818
979
 
980
+ def _build_stage_pick(state: WizardState) -> Prompt:
981
+ """Parse the Stage Map from the approved plan and build the stage picker."""
982
+ import importlib.util as _ilu
983
+ import sys as _sys
984
+ t = _p(state.workspace_root, "stage_pick")
985
+ plan_text = Path(state.approved_plan_path).read_text(encoding="utf-8")
986
+ validator_path = (
987
+ Path(state.workspace_root) / "validators"
988
+ / "validate-implementation-plan-stages.py"
989
+ )
990
+ spec = _ilu.spec_from_file_location("_ip_stage_v_wizard", str(validator_path))
991
+ mod = _ilu.module_from_spec(spec) # type: ignore[arg-type]
992
+ _sys.modules["_ip_stage_v_wizard"] = mod
993
+ try:
994
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
995
+ stages, _errs = mod._parse_stage_map(plan_text)
996
+ finally:
997
+ _sys.modules.pop("_ip_stage_v_wizard", None)
998
+ options = [_opt(k, v) for k, v in t["options"].items()]
999
+ for s in stages:
1000
+ depends = ",".join(map(str, s.depends_on)) or "(none)"
1001
+ options.append(_opt(
1002
+ str(s.stage_number),
1003
+ f"{s.stage_number}: {s.title} [depends-on: {depends} | steps: {s.step_count}]",
1004
+ ))
1005
+ return Prompt(
1006
+ step=S_STAGE_PICK, kind="pick",
1007
+ label=t["label"],
1008
+ options=options,
1009
+ echo_template=t["echo_template"],
1010
+ )
1011
+
1012
+
1013
+ def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1014
+ if not answer:
1015
+ raise WizardError("value required")
1016
+ if answer != "auto":
1017
+ try:
1018
+ int(answer)
1019
+ except ValueError:
1020
+ raise WizardError(
1021
+ f"answer must be 'auto' or a stage number, got {answer!r}"
1022
+ )
1023
+ state.selected_stage = answer
1024
+ return f"stage: {answer}"
1025
+
1026
+
819
1027
  def _build_directive_pick(state: WizardState) -> Prompt:
1028
+ t = _p(state.workspace_root, "directive_pick")
820
1029
  return Prompt(
821
1030
  step=S_DIRECTIVE_PICK, kind="pick",
822
- label="추가 directive 가 있나요?",
823
- options=[
824
- _opt(PICK_SKIP, "없음 (건너뛰기)"),
825
- _opt(PICK_ENTER, "있음 (입력)"),
826
- ],
827
- echo_template="directive(pick): {value}",
1031
+ label=t["label"],
1032
+ options=[_opt(k, v) for k, v in t["options"].items()],
1033
+ echo_template=t["echo_template"],
828
1034
  )
829
1035
 
830
1036
 
@@ -840,14 +1046,12 @@ def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
840
1046
 
841
1047
 
842
1048
  def _build_related_tasks_pick(state: WizardState) -> Prompt:
1049
+ t = _p(state.workspace_root, "related_tasks_pick")
843
1050
  return Prompt(
844
1051
  step=S_RELATED_TASKS_PICK, kind="pick",
845
- label="관련 task id 목록이 있나요?",
846
- options=[
847
- _opt(PICK_SKIP, "없음 (건너뛰기)"),
848
- _opt(PICK_ENTER, "있음 (입력)"),
849
- ],
850
- echo_template="related-tasks(pick): {value}",
1052
+ label=t["label"],
1053
+ options=[_opt(k, v) for k, v in t["options"].items()],
1054
+ echo_template=t["echo_template"],
851
1055
  )
852
1056
 
853
1057
 
@@ -863,14 +1067,12 @@ def _submit_related_tasks_pick(state: WizardState, value: str) -> Optional[str]:
863
1067
 
864
1068
 
865
1069
  def _build_clarification_pick(state: WizardState) -> Prompt:
1070
+ t = _p(state.workspace_root, "clarification_pick")
866
1071
  return Prompt(
867
1072
  step=S_CLARIFICATION_PICK, kind="pick",
868
- label="clarification-response 파일 경로가 있나요? (follow-up 시에만)",
869
- options=[
870
- _opt(PICK_SKIP, "없음 (건너뛰기)"),
871
- _opt(PICK_ENTER, "있음 (입력)"),
872
- ],
873
- echo_template="clarification(pick): {value}",
1073
+ label=t["label"],
1074
+ options=[_opt(k, v) for k, v in t["options"].items()],
1075
+ echo_template=t["echo_template"],
874
1076
  )
875
1077
 
876
1078
 
@@ -886,14 +1088,12 @@ def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
886
1088
 
887
1089
 
888
1090
  def _build_pr_template_pick(state: WizardState) -> Prompt:
1091
+ t = _p(state.workspace_root, "pr_template_pick")
889
1092
  return Prompt(
890
1093
  step=S_PR_TEMPLATE_PICK, kind="pick",
891
- label="PR 본문 템플릿 경로를 직접 지정할까요?",
892
- options=[
893
- _opt(PICK_SKIP, "자동 해석 (project.json → config → 기본)"),
894
- _opt(PICK_ENTER, "직접 경로 입력 (1회성 override)"),
895
- ],
896
- echo_template="pr-template(pick): {value}",
1094
+ label=t["label"],
1095
+ options=[_opt(k, v) for k, v in t["options"].items()],
1096
+ echo_template=t["echo_template"],
897
1097
  )
898
1098
 
899
1099
 
@@ -910,13 +1110,15 @@ def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
910
1110
 
911
1111
 
912
1112
  def _build_executor(state: WizardState) -> Prompt:
913
- options = [_opt(e, e + (" (default)" if e == "claude" else ""))
1113
+ t = _p(state.workspace_root, "executor")
1114
+ default_suffix = t["options"].get("_DEFAULT_SUFFIX", "")
1115
+ options = [_opt(e, e + (default_suffix if e == "claude" else ""))
914
1116
  for e in EXECUTORS]
915
1117
  return Prompt(
916
1118
  step=S_EXECUTOR, kind="pick",
917
- label="실행자 (executor)?",
1119
+ label=t["label"],
918
1120
  options=options,
919
- echo_template="executor: {value}",
1121
+ echo_template=t["echo_template"],
920
1122
  )
921
1123
 
922
1124
 
@@ -928,12 +1130,12 @@ def _submit_executor(state: WizardState, value: str) -> Optional[str]:
928
1130
 
929
1131
 
930
1132
  def _build_defaults_or_custom(state: WizardState) -> Prompt:
1133
+ t = _p(state.workspace_root, "defaults_or_custom")
931
1134
  return Prompt(
932
1135
  step=S_DEFAULTS_OR_CUSTOM, kind="pick",
933
- label="기본 워커/모델로 진행할까요, 아니면 커스터마이즈할까요?",
934
- options=[_opt("defaults", "Use defaults"),
935
- _opt("customize", "Customize")],
936
- echo_template="customize: {value}",
1136
+ label=t["label"],
1137
+ options=[_opt(k, v) for k, v in t["options"].items()],
1138
+ echo_template=t["echo_template"],
937
1139
  )
938
1140
 
939
1141
 
@@ -947,6 +1149,8 @@ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
947
1149
  def _build_workers_override(state: WizardState) -> Prompt:
948
1150
  """분석 워커 멀티픽. report-writer 는 옵션에서 빼고 항상 결과에 강제
949
1151
  포함시킨다(프로필이 report-writer 를 Required 로 가질 때)."""
1152
+ t = _p(state.workspace_root, "workers_override")
1153
+ optional_suffix = t["options"].get("_OPTIONAL_SUFFIX", "")
950
1154
  analyser_choices = [
951
1155
  w for w in (state.profile_workers + state.profile_optional_workers)
952
1156
  if w != "report-writer"
@@ -954,14 +1158,13 @@ def _build_workers_override(state: WizardState) -> Prompt:
954
1158
  options: list[Option] = []
955
1159
  for w in analyser_choices:
956
1160
  is_optional = w in state.profile_optional_workers
957
- label = f"{w} (옵션)" if is_optional else w
1161
+ label = f"{w}{optional_suffix}" if is_optional else w
958
1162
  options.append(_opt(value=w, label=label))
959
1163
  return Prompt(
960
1164
  step=S_WORKERS_OVERRIDE, kind="pick", multi=True,
961
- label=("참여시킬 분석 워커를 선택해주세요 (최소 1개). "
962
- "report-writer 는 항상 포함됩니다."),
1165
+ label=t["label"],
963
1166
  options=options,
964
- echo_template="workers: {value}",
1167
+ echo_template=t["echo_template"],
965
1168
  )
966
1169
 
967
1170
 
@@ -970,7 +1173,8 @@ def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
970
1173
  try:
971
1174
  chosen = normalize_workers(raw) if raw else []
972
1175
  if not chosen:
973
- raise WizardError("워커를 최소 1개 선택해주세요")
1176
+ t = _p(state.workspace_root, "workers_override")
1177
+ raise WizardError(t["errors"]["min_one_required"])
974
1178
  validate_workers_against_profile(
975
1179
  chosen,
976
1180
  state.profile_workers,
@@ -993,8 +1197,9 @@ def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
993
1197
 
994
1198
 
995
1199
  def _build_lead_model(state: WizardState) -> Prompt:
996
- return _model_pick(S_LEAD_MODEL, "리더(Claude lead) 모델?",
997
- CLAUDE_MODEL_OPTIONS, "lead-model: {value}")
1200
+ t = _p(state.workspace_root, "lead_model")
1201
+ return _model_pick(S_LEAD_MODEL, t["label"],
1202
+ CLAUDE_MODEL_OPTIONS, t["echo_template"])
998
1203
 
999
1204
 
1000
1205
  def _submit_lead_model(state: WizardState, value: str) -> Optional[str]:
@@ -1003,11 +1208,12 @@ def _submit_lead_model(state: WizardState, value: str) -> Optional[str]:
1003
1208
 
1004
1209
 
1005
1210
  def _build_executor_model(state: WizardState) -> Prompt:
1211
+ t = _p(state.workspace_root, "executor_model", executor=state.executor)
1006
1212
  return _model_pick(
1007
1213
  S_EXECUTOR_MODEL,
1008
- f"실행자({state.executor}) 모델?",
1214
+ t["label"],
1009
1215
  _executor_model_options(state.executor),
1010
- f"{state.executor}-model: {{value}}",
1216
+ t["echo_template"].replace("{executor}", state.executor),
1011
1217
  )
1012
1218
 
1013
1219
 
@@ -1018,8 +1224,9 @@ def _submit_executor_model(state: WizardState, value: str) -> Optional[str]:
1018
1224
 
1019
1225
 
1020
1226
  def _build_claude_model(state: WizardState) -> Prompt:
1021
- return _model_pick(S_CLAUDE_MODEL, "claude 워커 모델?",
1022
- CLAUDE_MODEL_OPTIONS, "claude-model: {value}")
1227
+ t = _p(state.workspace_root, "claude_model")
1228
+ return _model_pick(S_CLAUDE_MODEL, t["label"],
1229
+ CLAUDE_MODEL_OPTIONS, t["echo_template"])
1023
1230
 
1024
1231
 
1025
1232
  def _submit_claude_model(state: WizardState, value: str) -> Optional[str]:
@@ -1028,8 +1235,9 @@ def _submit_claude_model(state: WizardState, value: str) -> Optional[str]:
1028
1235
 
1029
1236
 
1030
1237
  def _build_codex_model(state: WizardState) -> Prompt:
1031
- return _model_pick(S_CODEX_MODEL, "codex 워커 모델?",
1032
- CODEX_MODEL_OPTIONS, "codex-model: {value}")
1238
+ t = _p(state.workspace_root, "codex_model")
1239
+ return _model_pick(S_CODEX_MODEL, t["label"],
1240
+ CODEX_MODEL_OPTIONS, t["echo_template"])
1033
1241
 
1034
1242
 
1035
1243
  def _submit_codex_model(state: WizardState, value: str) -> Optional[str]:
@@ -1038,8 +1246,9 @@ def _submit_codex_model(state: WizardState, value: str) -> Optional[str]:
1038
1246
 
1039
1247
 
1040
1248
  def _build_gemini_model(state: WizardState) -> Prompt:
1041
- return _model_pick(S_GEMINI_MODEL, "gemini 워커 모델?",
1042
- GEMINI_MODEL_OPTIONS, "gemini-model: {value}")
1249
+ t = _p(state.workspace_root, "gemini_model")
1250
+ return _model_pick(S_GEMINI_MODEL, t["label"],
1251
+ GEMINI_MODEL_OPTIONS, t["echo_template"])
1043
1252
 
1044
1253
 
1045
1254
  def _submit_gemini_model(state: WizardState, value: str) -> Optional[str]:
@@ -1048,10 +1257,11 @@ def _submit_gemini_model(state: WizardState, value: str) -> Optional[str]:
1048
1257
 
1049
1258
 
1050
1259
  def _build_report_writer_model(state: WizardState) -> Prompt:
1260
+ t = _p(state.workspace_root, "report_writer_model")
1051
1261
  return _model_pick(S_REPORT_WRITER_MODEL,
1052
- "리포트 작성자(report-writer) 모델?",
1262
+ t["label"],
1053
1263
  CLAUDE_MODEL_OPTIONS,
1054
- "report-writer-model: {value}")
1264
+ t["echo_template"])
1055
1265
 
1056
1266
 
1057
1267
  def _submit_report_writer_model(state: WizardState, value: str) -> Optional[str]:
@@ -1060,10 +1270,11 @@ def _submit_report_writer_model(state: WizardState, value: str) -> Optional[str]
1060
1270
 
1061
1271
 
1062
1272
  def _build_directive(state: WizardState) -> Prompt:
1273
+ t = _p(state.workspace_root, "directive")
1063
1274
  return Prompt(
1064
1275
  step=S_DIRECTIVE, kind="text",
1065
- label="추가 directive 가 있으면 적어주세요 (없으면 빈 줄)",
1066
- echo_template="directive: {value}",
1276
+ label=t["label"],
1277
+ echo_template=t["echo_template"],
1067
1278
  )
1068
1279
 
1069
1280
 
@@ -1074,10 +1285,11 @@ def _submit_directive(state: WizardState, value: str) -> Optional[str]:
1074
1285
 
1075
1286
 
1076
1287
  def _build_related_tasks(state: WizardState) -> Prompt:
1288
+ t = _p(state.workspace_root, "related_tasks")
1077
1289
  return Prompt(
1078
1290
  step=S_RELATED_TASKS, kind="text",
1079
- label="관련 task id 목록을 쉼표로 구분해서 적어주세요 (없으면 빈 줄)",
1080
- echo_template="related-tasks: {value}",
1291
+ label=t["label"],
1292
+ echo_template=t["echo_template"],
1081
1293
  )
1082
1294
 
1083
1295
 
@@ -1088,10 +1300,11 @@ def _submit_related_tasks(state: WizardState, value: str) -> Optional[str]:
1088
1300
 
1089
1301
 
1090
1302
  def _build_clarification(state: WizardState) -> Prompt:
1303
+ t = _p(state.workspace_root, "clarification")
1091
1304
  return Prompt(
1092
1305
  step=S_CLARIFICATION, kind="text",
1093
- label="clarification-response 파일 경로 (follow-up 시에만, 없으면 빈 줄)",
1094
- echo_template="clarification: {value}",
1306
+ label=t["label"],
1307
+ echo_template=t["echo_template"],
1095
1308
  )
1096
1309
 
1097
1310
 
@@ -1107,11 +1320,11 @@ def _submit_clarification(state: WizardState, value: str) -> Optional[str]:
1107
1320
 
1108
1321
 
1109
1322
  def _build_pr_template(state: WizardState) -> Prompt:
1323
+ t = _p(state.workspace_root, "pr_template")
1110
1324
  return Prompt(
1111
1325
  step=S_PR_TEMPLATE, kind="text",
1112
- label=("PR 본문 템플릿 경로 1회성 override (빈 줄이면 project.json → "
1113
- "~/.okstra/config.json → 스킬 디폴트 순으로 자동 해석)"),
1114
- echo_template="pr-template: {value}",
1326
+ label=t["label"],
1327
+ echo_template=t["echo_template"],
1115
1328
  )
1116
1329
 
1117
1330
 
@@ -1134,15 +1347,12 @@ def _submit_pr_template(state: WizardState, value: str) -> Optional[str]:
1134
1347
 
1135
1348
 
1136
1349
  def _build_pr_template_scope(state: WizardState) -> Prompt:
1350
+ t = _p(state.workspace_root, "pr_template_scope")
1137
1351
  return Prompt(
1138
1352
  step=S_PR_TEMPLATE_SCOPE, kind="pick",
1139
- label="방금 입력한 경로를 영구 저장할까요?",
1140
- options=[
1141
- _opt("once", "이번 run 만 (1회성)"),
1142
- _opt("project", "프로젝트에 저장 (project scope)"),
1143
- _opt("global", "전역에 저장 (global scope)"),
1144
- ],
1145
- echo_template="pr-template-scope: {value}",
1353
+ label=t["label"],
1354
+ options=[_opt(k, v) for k, v in t["options"].items()],
1355
+ echo_template=t["echo_template"],
1146
1356
  )
1147
1357
 
1148
1358
 
@@ -1156,11 +1366,12 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
1156
1366
 
1157
1367
 
1158
1368
  def _build_confirm(state: WizardState) -> Prompt:
1369
+ t = _p(state.workspace_root, "confirm")
1159
1370
  return Prompt(
1160
1371
  step=S_CONFIRM, kind="pick",
1161
- label="이대로 진행할까요?",
1162
- options=[_opt("proceed", "Proceed"), _opt("edit", "Edit")],
1163
- echo_template="confirm: {value}",
1372
+ label=t["label"],
1373
+ options=[_opt(k, v) for k, v in t["options"].items()],
1374
+ echo_template=t["echo_template"],
1164
1375
  )
1165
1376
 
1166
1377
 
@@ -1172,6 +1383,7 @@ def _submit_confirm(state: WizardState, value: str) -> Optional[str]:
1172
1383
 
1173
1384
 
1174
1385
  def _build_edit_target(state: WizardState) -> Prompt:
1386
+ t = _p(state.workspace_root, "edit_target")
1175
1387
  # offer every step that has been answered.
1176
1388
  options: list[Option] = []
1177
1389
  for sid in state.answered:
@@ -1180,9 +1392,9 @@ def _build_edit_target(state: WizardState) -> Prompt:
1180
1392
  options.append(_opt(sid, sid))
1181
1393
  return Prompt(
1182
1394
  step=S_EDIT_TARGET, kind="pick",
1183
- label="어느 step 으로 돌아갈까요?",
1395
+ label=t["label"],
1184
1396
  options=options,
1185
- echo_template="edit-target: {value}",
1397
+ echo_template=t["echo_template"],
1186
1398
  )
1187
1399
 
1188
1400
 
@@ -1303,6 +1515,12 @@ STEPS: list[Step] = [
1303
1515
  or _latest_implementation_planning_report(s) is None)),
1304
1516
  build=_build_approved_plan, submit=_submit_approved_plan,
1305
1517
  owns=("approved_plan_path", "approved_plan_pending_text")),
1518
+ Step(S_STAGE_PICK,
1519
+ applies=lambda s: (s.task_type == "implementation"
1520
+ and bool(s.approved_plan_path)
1521
+ and S_STAGE_PICK not in s.answered),
1522
+ build=_build_stage_pick, submit=_submit_stage_pick,
1523
+ owns=("selected_stage",)),
1306
1524
  Step(S_EXECUTOR,
1307
1525
  applies=lambda s: (s.task_type == "implementation"
1308
1526
  and bool(s.approved_plan_path)
@@ -1500,6 +1718,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
1500
1718
  "brief_path": "", "reuse_worktree": None, "base_ref": "",
1501
1719
  "base_ref_pending_text": False, "approved_plan_path": "",
1502
1720
  "approved_plan_pending_text": False,
1721
+ "selected_stage": "auto",
1503
1722
  "executor": "", "use_defaults": None, "workers_override": "",
1504
1723
  "lead_model": "", "claude_model": "", "codex_model": "",
1505
1724
  "gemini_model": "", "report_writer_model": "", "directive": "",
@@ -1583,6 +1802,7 @@ def render_args(state: WizardState) -> dict[str, str]:
1583
1802
  "task-brief": state.brief_path,
1584
1803
  "executor": state.executor,
1585
1804
  "approved-plan": state.approved_plan_path,
1805
+ "stage": (state.selected_stage or "auto") if state.task_type == "implementation" else "",
1586
1806
  "base-ref": base_ref,
1587
1807
  "workers": workers,
1588
1808
  "directive": state.directive,
@@ -1599,7 +1819,8 @@ def render_args(state: WizardState) -> dict[str, str]:
1599
1819
 
1600
1820
  def confirmation_block(state: WizardState) -> str:
1601
1821
  """Human-readable echo of the resolved selections (for the Confirm step)."""
1602
- lines: list[str] = ["선택 확인:"]
1822
+ header = _msg(state.workspace_root, "confirmation", "header")
1823
+ lines: list[str] = [header]
1603
1824
  lines.append(f" task-type : {state.task_type}")
1604
1825
  lines.append(f" task-key : {state.task_group}/{state.task_id}")
1605
1826
  if state.reuse_worktree:
@@ -1609,7 +1830,7 @@ def confirmation_block(state: WizardState) -> str:
1609
1830
  if state.task_type == "implementation":
1610
1831
  lines.append(f" executor : {state.executor or '(default)'}")
1611
1832
  lines.append(
1612
- f" workers : (프로필 기본 — executor + verifier 2 + report-writer)"
1833
+ _msg(state.workspace_root, "confirmation", "workers_implementation_default")
1613
1834
  )
1614
1835
  else:
1615
1836
  roster = state.workers_override or ",".join(state.profile_workers) or "(profile default)"