okstra 0.36.2 → 0.38.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 (66) hide show
  1. package/README.kr.md +6 -6
  2. package/README.md +6 -6
  3. package/bin/okstra +4 -2
  4. package/docs/kr/architecture.md +31 -31
  5. package/docs/kr/cli.md +10 -9
  6. package/docs/pr-template-usage.md +2 -2
  7. package/docs/project-structure-overview.md +4 -4
  8. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +159 -0
  9. package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +860 -0
  10. package/docs/task-process/common-flow.md +2 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/SKILL.md +16 -14
  14. package/runtime/agents/workers/claude-worker.md +1 -1
  15. package/runtime/prompts/profiles/_common-contract.md +7 -7
  16. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  17. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  18. package/runtime/prompts/profiles/final-verification.md +1 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +5 -5
  20. package/runtime/prompts/profiles/release-handoff.md +1 -1
  21. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  22. package/runtime/prompts/wizard/prompts.ko.json +80 -6
  23. package/runtime/python/lib/okstra/globals.sh +2 -2
  24. package/runtime/python/lib/okstra/interactive.sh +2 -2
  25. package/runtime/python/lib/okstra/project-resolver.sh +1 -1
  26. package/runtime/python/lib/okstra/usage.sh +9 -9
  27. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +1 -1
  28. package/runtime/python/okstra_ctl/backfill.py +5 -3
  29. package/runtime/python/okstra_ctl/migrate.py +408 -0
  30. package/runtime/python/okstra_ctl/paths.py +12 -3
  31. package/runtime/python/okstra_ctl/pr_template.py +4 -2
  32. package/runtime/python/okstra_ctl/render.py +8 -6
  33. package/runtime/python/okstra_ctl/run.py +7 -7
  34. package/runtime/python/okstra_ctl/seeding.py +12 -6
  35. package/runtime/python/okstra_ctl/sequence.py +3 -1
  36. package/runtime/python/okstra_ctl/wizard.py +412 -77
  37. package/runtime/python/okstra_ctl/worktree.py +8 -6
  38. package/runtime/python/okstra_project/__init__.py +35 -5
  39. package/runtime/python/okstra_project/dirs.py +67 -0
  40. package/runtime/python/okstra_project/resolver.py +8 -8
  41. package/runtime/python/okstra_project/state.py +11 -9
  42. package/runtime/python/okstra_token_usage/collect.py +3 -1
  43. package/runtime/skills/okstra-brief/SKILL.md +30 -30
  44. package/runtime/skills/okstra-context-loader/SKILL.md +7 -7
  45. package/runtime/skills/okstra-inspect/SKILL.md +25 -25
  46. package/runtime/skills/okstra-run/templates/pr-body.template.md +1 -1
  47. package/runtime/skills/okstra-schedule/SKILL.md +7 -7
  48. package/runtime/skills/okstra-setup/SKILL.md +8 -8
  49. package/runtime/skills/okstra-team-contract/SKILL.md +4 -4
  50. package/runtime/templates/okstra.CLAUDE.md +4 -4
  51. package/runtime/templates/reports/brief.template.md +5 -5
  52. package/runtime/templates/reports/task-brief.template.md +1 -1
  53. package/runtime/validators/lib/fixtures.sh +2 -2
  54. package/runtime/validators/lib/paths.sh +9 -3
  55. package/runtime/validators/validate-brief.py +2 -2
  56. package/runtime/validators/validate-brief.sh +1 -1
  57. package/runtime/validators/validate-run.py +3 -1
  58. package/runtime/validators/validate-workflow.sh +2 -2
  59. package/src/check-project.mjs +3 -3
  60. package/src/config.mjs +6 -5
  61. package/src/install.mjs +5 -5
  62. package/src/migrate.mjs +163 -0
  63. package/src/okstra-dirs.mjs +37 -0
  64. package/src/paths.mjs +17 -0
  65. package/src/setup.mjs +8 -4
  66. package/src/uninstall.mjs +3 -3
@@ -0,0 +1,860 @@
1
+ # wizard.py 모든 사용자 입력 단계 3-옵션 picker 통일 Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** `okstra-run` 진행 중 사용자 입력이 필요한 모든 wizard 단계를 "추천 1~2개 + 직접 입력 = 총 3개 옵션" picker로 통일한다. 마지막 옵션은 항상 "직접 입력"이며, 추천 후보가 마땅치 않은 단계는 더미라도 1개를 박아 형태를 유지한다.
6
+
7
+ **Architecture:** `scripts/okstra_ctl/wizard.py`가 단일 권위(single authority). 모든 prompt 정의(`label`, `options`)는 `prompts/wizard/prompts.ko.json` SSOT에서 읽고, 추천 후보 생성 로직은 wizard.py 의 `_build_*` 함수에 추가한다. `okstra-run` 스킬 본문은 picker를 발명하지 않고 wizard가 내려준 prompt를 그대로 릴레이한다. 사용자가 마지막 "직접 입력" 옵션을 고르면 별도의 `*_text` step으로 전이하여 자유 텍스트를 받는다 — 기존 `S_BASE_REF_PICK → S_BASE_REF_TEXT` 패턴을 모든 단계에 일반화하는 작업이다.
8
+
9
+ **Tech Stack:** Python 3 (wizard 본체), JSON (prompt 정의 SSOT), pytest (단위 테스트), bash (e2e), Conventional Commits + release-please.
10
+
11
+ ---
12
+
13
+ ## 변경 대상 단계 요약
14
+
15
+ | # | 단계 | 현 상태 | 목표 |
16
+ |---|------|---------|------|
17
+ | 1 | `S_BRIEF_PATH` | text only | `S_BRIEF_PATH_PICK`(3-옵션) + 기존 `S_BRIEF_PATH`(직접 입력 후 진입) |
18
+ | 2 | `S_TASK_GROUP` (suggestion 없음 분기) | text 직진 | 항상 picker로 — 최근 task-group 추천 + 직접 입력 |
19
+ | 3 | `S_TASK_ID` (suggestion 없음 분기) | text 직진 | 항상 picker로 — 같은 group 의 기존 task-id 추천 + 직접 입력 |
20
+ | 4 | `S_APPROVED_PLAN_PICK` | 2-옵션 (default/other) | 3-옵션 — default + 같은 task의 다른 final-report 후보 + 직접 입력 |
21
+ | 5 | `S_DIRECTIVE_PICK` | 2-옵션 (skip/enter) | 3-옵션 — skip(=빈 줄, phase default) + 이전 run directive 재사용 + 직접 입력 |
22
+ | 6 | `S_RELATED_TASKS_PICK` | 2-옵션 (skip/enter) | 3-옵션 — skip + 같은 task-group 의 다른 task-id 자동 후보 + 직접 입력 |
23
+ | 7 | `S_CLARIFICATION_PICK` | 2-옵션 (skip/enter) | 3-옵션 — skip + 최근 final-report-* 자동 추출 + 직접 입력 |
24
+ | 8 | `S_PR_TEMPLATE_PICK` | 2-옵션 (skip/enter) | 3-옵션 — auto-resolve(=skip) + project.json 등록 경로 + 직접 입력 |
25
+
26
+ 영향 받지 않는 단계:
27
+ - `S_TASK_PICK`, `S_TASK_TYPE`, `S_BASE_REF_PICK`, `S_BRIEF_KEEP`, `S_EXECUTOR`, `S_DEFAULTS_OR_CUSTOM`, `S_STAGE_PICK`, `S_WORKERS_OVERRIDE`, `S_PR_TEMPLATE_SCOPE`, `S_CONFIRM`, `S_EDIT_TARGET` — 이미 3개 이상 옵션이거나 자유 텍스트 입력 자체가 없는 단계.
28
+ - `S_TASK_GROUP_TEXT`, `S_TASK_ID_TEXT`, `S_BASE_REF_TEXT`, `S_BRIEF_PATH`(개편 후), `S_APPROVED_PLAN`, `S_DIRECTIVE`, `S_RELATED_TASKS`, `S_CLARIFICATION`, `S_PR_TEMPLATE` — "직접 입력" 분기 뒤의 free-text step. 사용자가 picker 의 마지막 옵션을 명시적으로 골랐을 때만 진입하므로 그대로 둔다.
29
+
30
+ ## File Structure
31
+
32
+ - Modify: `scripts/okstra_ctl/wizard.py` — `_build_*` / `_submit_*` 함수 8개 변경, step sequence 1곳 추가, 새 상수 토큰 추가.
33
+ - Modify: `prompts/wizard/prompts.ko.json` — 단계별 `options` 블록 추가/수정.
34
+ - Test: `tests/test_okstra_ctl_wizard.py` — 각 단계 picker 옵션 수 / 마지막이 "직접 입력" / 추천 분기 동작 검증.
35
+ - Test: `tests/test_wizard_prompts.py` — SSOT JSON 무결성 검증 (옵션 수, 토큰 명).
36
+ - E2E (선택): `tests-e2e/scenario-10-wizard-3-option-picker.sh` — 새 시나리오 추가 여부는 Task 9에서 결정.
37
+
38
+ ## 공통 규약
39
+
40
+ - **마지막 옵션 값 토큰**: 신규 단계는 일관된 토큰 `__free_input__` 을 사용 (이미 `S_BASE_REF_PICK`, `S_TASK_GROUP`, `S_TASK_ID` 에서 같은 토큰 사용 중). `PICK_TYPE_CUSTOM = "__free_input__"` 상수가 이미 정의됨.
41
+ - **마지막 옵션 라벨**: 모든 picker 에서 한국어 "직접 입력" 으로 통일 — 학습 비용 0.
42
+ - **추천이 없는 경우의 더미 채우기**: 의미 있는 자동 추출 후보가 0개라면 "건너뛰기 (빈 줄 = phase default)" 1개를 추천으로 박는다. 이 경우 picker 는 (skip + 직접 입력) 2-옵션이 아니라 (skip + 비추천 안내 + 직접 입력) 3-옵션이 되도록 하지 않는다 — 사용자 결정(메모리 `feedback_okstra_run_prompts_offer_recommendations.md`)에서 "단계별 합리적 자동 추출이 안 되는 경우 더미 추천 1개를 박는다"고 정해진 바에 따라 진짜 의미 있는 후보가 1개라도 잡히면 그것을 추가하고, 진짜로 0개일 때만 2-옵션으로 폴백한다. **단, 폴백 시에도 라벨은 "직접 입력"으로 통일**.
43
+ - **추천 후보 자동 추출 헬퍼**: 신규 추출 로직은 `wizard.py` 안의 module-level `def _suggest_<step>(state: WizardState) -> list[tuple[str, str]]:` 형태로 분리 — `_build_*` 가 호출. 테스트 가능성 확보.
44
+ - **언어**: prompt label / option label 은 한국어, value 토큰은 영문 (`__free_input__`, `__skip__`, `__use_default__` 등 기존 컨벤션 유지).
45
+ - **Pre-1.0**: 기존 2-옵션 토큰(`__skip__`, `__enter__`)은 그대로 유지하되, `__enter__` 의미가 "(직접 입력으로 분기)" 라는 것을 유지. 새 추천 토큰은 단계별로 `__recent__` / `__latest_report__` / `__project_default__` 등 의미 있는 이름.
46
+
47
+ ---
48
+
49
+ ### Task 1: `S_BRIEF_PATH` 를 picker → text 2-단계 흐름으로 분해
50
+
51
+ **Files:**
52
+ - Modify: `scripts/okstra_ctl/wizard.py:171` (S_BRIEF_PATH 상수 근처 → S_BRIEF_PATH_PICK 추가), `:824-844` (`_build_brief_path` / `_submit_brief_path`), `:1423` (step sequence 등록부)
53
+ - Modify: `prompts/wizard/prompts.ko.json:69-72` (brief_path 블록 → `brief_path_pick` 추가)
54
+ - Test: `tests/test_okstra_ctl_wizard.py` (새 테스트 함수 `test_brief_path_pick_offers_three_options`)
55
+
56
+ - [ ] **Step 1.1: 실패 테스트 작성**
57
+
58
+ `tests/test_okstra_ctl_wizard.py` 끝에 추가:
59
+
60
+ ```python
61
+ def test_brief_path_pick_offers_three_options(tmp_path, monkeypatch):
62
+ """S_BRIEF_PATH_PICK 은 반드시 3개 옵션(추천 1~2 + 직접 입력) 을 제시한다."""
63
+ from scripts.okstra_ctl.wizard import (
64
+ WizardState, _build_brief_path_pick, S_BRIEF_PATH_PICK,
65
+ )
66
+
67
+ # 기존 brief 가 존재하는 경우: existing brief 추천 + 새 표준 경로 추천 + 직접 입력
68
+ project_root = tmp_path / "proj"
69
+ (project_root / ".okstra" / "tasks" / "tg" / "tid").mkdir(parents=True)
70
+ existing = project_root / ".okstra" / "tasks" / "tg" / "tid" / "brief.md"
71
+ existing.write_text("---\napproved: false\n---\n", encoding="utf-8")
72
+ state = WizardState(
73
+ workspace_root="/Volumes/Workspaces/workspace/projects/Okstra",
74
+ project_root=str(project_root),
75
+ project_id="proj",
76
+ task_group="tg",
77
+ task_id="tid",
78
+ existing_brief_path=str(existing.relative_to(project_root)),
79
+ )
80
+ p = _build_brief_path_pick(state)
81
+
82
+ assert p.step == S_BRIEF_PATH_PICK
83
+ assert p.kind == "pick"
84
+ assert len(p.options) >= 3
85
+ assert p.options[-1].value == "__free_input__"
86
+ assert p.options[-1].label == "직접 입력"
87
+ ```
88
+
89
+ - [ ] **Step 1.2: 실패 확인**
90
+
91
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py::test_brief_path_pick_offers_three_options -v`
92
+ Expected: FAIL with `ImportError: cannot import name 'S_BRIEF_PATH_PICK'` 또는 `AttributeError`.
93
+
94
+ - [ ] **Step 1.3: 상수 + SSOT JSON 추가**
95
+
96
+ `scripts/okstra_ctl/wizard.py` 상단 (S_BRIEF_PATH 상수 근처, 약 171줄) 에 추가:
97
+
98
+ ```python
99
+ S_BRIEF_PATH_PICK = "brief_path_pick"
100
+ ```
101
+
102
+ `prompts/wizard/prompts.ko.json` 의 `"brief_path"` 블록 바로 위에 추가:
103
+
104
+ ```json
105
+ "brief_path_pick": {
106
+ "label": "task brief markdown 의 경로를 선택해주세요",
107
+ "echo_template": "brief(pick): {value}",
108
+ "options": {
109
+ "__existing__": "기존 brief 사용: {existing}",
110
+ "__standard__": "표준 경로 사용: {standard}",
111
+ "__free_input__": "직접 입력"
112
+ },
113
+ "errors": {
114
+ "existing_missing": "기존 brief 가 없습니다. 다른 옵션을 선택하세요."
115
+ }
116
+ },
117
+ ```
118
+
119
+ - [ ] **Step 1.4: `_build_brief_path_pick` / `_submit_brief_path_pick` 구현**
120
+
121
+ `scripts/okstra_ctl/wizard.py` 의 `_build_brief_path` 정의 위에 추가 (대략 824줄 근처):
122
+
123
+ ```python
124
+ def _suggest_brief_path(state: WizardState) -> tuple[str, str]:
125
+ """Return (existing_brief_relpath_or_empty, standard_relpath).
126
+ standard_relpath = ".okstra/tasks/<task-group>/<task-id>/brief.md"."""
127
+ existing = state.existing_brief_path or ""
128
+ tg = slugify_task_segment(state.task_group) if state.task_group else ""
129
+ tid = slugify_task_segment(state.task_id) if state.task_id else ""
130
+ if tg and tid:
131
+ standard = str(Path(".okstra") / "tasks" / tg / tid / "brief.md")
132
+ else:
133
+ standard = ""
134
+ return existing, standard
135
+
136
+
137
+ def _build_brief_path_pick(state: WizardState) -> Prompt:
138
+ existing, standard = _suggest_brief_path(state)
139
+ t = _p(state.workspace_root, "brief_path_pick",
140
+ existing=existing or "(없음)",
141
+ standard=standard or "(타깃 미정)")
142
+ options: list[Option] = []
143
+ if existing:
144
+ options.append(_opt("__existing__",
145
+ t["options"]["__existing__"].format(existing=existing)))
146
+ if standard and standard != existing:
147
+ options.append(_opt("__standard__",
148
+ t["options"]["__standard__"].format(standard=standard)))
149
+ options.append(_opt("__free_input__", t["options"]["__free_input__"]))
150
+ return Prompt(step=S_BRIEF_PATH_PICK, kind="pick",
151
+ label=t["label"], options=options,
152
+ echo_template=t["echo_template"])
153
+
154
+
155
+ def _submit_brief_path_pick(state: WizardState, value: str) -> Optional[str]:
156
+ existing, standard = _suggest_brief_path(state)
157
+ if value == "__existing__":
158
+ if not existing:
159
+ t = _p(state.workspace_root, "brief_path_pick",
160
+ existing="(없음)", standard=standard or "(타깃 미정)")
161
+ raise WizardError(t["errors"]["existing_missing"])
162
+ p = _require_file(existing, Path(state.project_root), "task brief")
163
+ state.brief_path = str(p)
164
+ state.brief_path_pending_text = False
165
+ return f"brief: {p}"
166
+ if value == "__standard__":
167
+ p = _require_file(standard, Path(state.project_root), "task brief")
168
+ state.brief_path = str(p)
169
+ state.brief_path_pending_text = False
170
+ return f"brief: {p}"
171
+ if value == "__free_input__":
172
+ state.brief_path_pending_text = True
173
+ state.brief_path = ""
174
+ return None
175
+ raise WizardError(
176
+ f"expected '__existing__', '__standard__', or '__free_input__', "
177
+ f"got: {value!r}"
178
+ )
179
+ ```
180
+
181
+ `WizardState` dataclass(파일 상단 정의부) 에 새 플래그 필드 추가:
182
+
183
+ ```python
184
+ brief_path_pending_text: bool = False
185
+ ```
186
+
187
+ - [ ] **Step 1.5: step sequence 에 등록**
188
+
189
+ `wizard.py:1423` 근처 `Step(S_BRIEF_PATH, ...)` 정의 바로 위에 추가:
190
+
191
+ ```python
192
+ Step(S_BRIEF_PATH_PICK,
193
+ applies=lambda s: (s.is_new_task or not s.keep_existing_brief)
194
+ and S_BRIEF_PATH_PICK not in s.answered
195
+ and not s.brief_path_pending_text,
196
+ build=_build_brief_path_pick,
197
+ submit=_submit_brief_path_pick,
198
+ owns=("brief_path_pending_text",)),
199
+ ```
200
+
201
+ 기존 `Step(S_BRIEF_PATH, ...)` 의 `applies` 람다를 다음과 같이 수정 (해당 단계는 직접 입력 분기에서만 활성화):
202
+
203
+ ```python
204
+ Step(S_BRIEF_PATH,
205
+ applies=lambda s: s.brief_path_pending_text
206
+ and S_BRIEF_PATH not in s.answered,
207
+ build=_build_brief_path,
208
+ submit=_submit_brief_path,
209
+ owns=("brief_path",)),
210
+ ```
211
+
212
+ - [ ] **Step 1.6: 테스트 통과 확인**
213
+
214
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py::test_brief_path_pick_offers_three_options -v`
215
+ Expected: PASS.
216
+
217
+ 추가로 기존 `tests/test_okstra_ctl_wizard.py` 의 brief 관련 테스트들이 깨지지 않는지 확인:
218
+
219
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py -v -k brief`
220
+ Expected: 모든 brief 테스트 PASS — 필요 시 기존 테스트가 `brief_path_pending_text=True` 를 미리 세팅하도록 픽스 (한 줄 추가).
221
+
222
+ - [ ] **Step 1.7: 커밋**
223
+
224
+ ```bash
225
+ git add scripts/okstra_ctl/wizard.py prompts/wizard/prompts.ko.json tests/test_okstra_ctl_wizard.py
226
+ git commit -m "feat(wizard): brief-path를 3-옵션 picker로 분해"
227
+ ```
228
+
229
+ ---
230
+
231
+ ### Task 2: `S_TASK_GROUP` (suggestion 없을 때) 항상 picker 로
232
+
233
+ **Files:**
234
+ - Modify: `scripts/okstra_ctl/wizard.py:660-697`
235
+ - Modify: `prompts/wizard/prompts.ko.json:13-19, 20-27`
236
+ - Test: `tests/test_okstra_ctl_wizard.py` 끝에 `test_task_group_without_suggestion_offers_picker`
237
+
238
+ - [ ] **Step 2.1: 실패 테스트 작성**
239
+
240
+ ```python
241
+ def test_task_group_without_suggestion_offers_picker(tmp_path):
242
+ """task_group_suggestion 이 없어도 picker 가 떠야 한다."""
243
+ from scripts.okstra_ctl.wizard import WizardState, _build_task_group
244
+
245
+ state = WizardState(
246
+ workspace_root="/Volumes/Workspaces/workspace/projects/Okstra",
247
+ project_root=str(tmp_path),
248
+ project_id="proj",
249
+ is_new_task=True,
250
+ task_group_suggestion="",
251
+ )
252
+ p = _build_task_group(state)
253
+
254
+ assert p.kind == "pick"
255
+ assert len(p.options) >= 2
256
+ assert p.options[-1].value == "__free_input__"
257
+ assert p.options[-1].label == "직접 입력"
258
+ ```
259
+
260
+ - [ ] **Step 2.2: 실패 확인**
261
+
262
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py::test_task_group_without_suggestion_offers_picker -v`
263
+ Expected: FAIL — `_build_task_group` 가 `kind="text"` 를 반환.
264
+
265
+ - [ ] **Step 2.3: 추천 후보 헬퍼 추가**
266
+
267
+ `_build_task_group` 위에 추가:
268
+
269
+ ```python
270
+ def _suggest_recent_task_groups(state: WizardState, limit: int = 2) -> list[str]:
271
+ """프로젝트 catalog 에서 최근 task-group 후보를 limit 개까지 반환."""
272
+ if not state.project_root:
273
+ return []
274
+ try:
275
+ tasks = list_project_tasks(Path(state.project_root))
276
+ except Exception:
277
+ return []
278
+ seen: list[str] = []
279
+ for entry in tasks:
280
+ tg = entry.get("taskGroup") or ""
281
+ if tg and tg not in seen:
282
+ seen.append(tg)
283
+ if len(seen) >= limit:
284
+ break
285
+ return seen
286
+ ```
287
+
288
+ - [ ] **Step 2.4: `_build_task_group` 재작성**
289
+
290
+ `wizard.py:660-677` 의 `_build_task_group` 을 다음으로 교체:
291
+
292
+ ```python
293
+ def _build_task_group(state: WizardState) -> Prompt:
294
+ sugg = state.task_group_suggestion
295
+ if sugg:
296
+ t = _p(state.workspace_root, "task_group_with_suggestion",
297
+ suggestion=sugg)
298
+ options = [
299
+ _opt(k, v.format(suggestion=sugg))
300
+ for k, v in t["options"].items()
301
+ ]
302
+ return Prompt(
303
+ step=S_TASK_GROUP, kind="pick",
304
+ label=t["label"], options=options,
305
+ echo_template=t["echo_template"],
306
+ )
307
+ # suggestion 이 없으면 catalog 의 최근 task-group 을 후보로 노출 + 직접 입력
308
+ recent = _suggest_recent_task_groups(state, limit=2)
309
+ t = _p(state.workspace_root, "task_group_no_suggestion")
310
+ options: list[Option] = []
311
+ for tg in recent:
312
+ options.append(_opt(f"__recent:{tg}", f"최근 사용: {tg}"))
313
+ options.append(_opt("__free_input__", t["options"]["__free_input__"]))
314
+ return Prompt(
315
+ step=S_TASK_GROUP, kind="pick",
316
+ label=t["label"], options=options,
317
+ echo_template=t["echo_template"],
318
+ )
319
+ ```
320
+
321
+ - [ ] **Step 2.5: `_submit_task_group` 확장**
322
+
323
+ `wizard.py:680-698` 의 `_submit_task_group` 에 `__recent:` 분기 추가:
324
+
325
+ ```python
326
+ def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
327
+ if state.task_group_suggestion:
328
+ if value == PICK_USE_SUGGESTED:
329
+ state.task_group = _slug_or_die(
330
+ state.task_group_suggestion, "task_group"
331
+ )
332
+ state.task_group_pending_text = False
333
+ return f"task-group: {state.task_group} (brief)"
334
+ if value == PICK_TYPE_CUSTOM:
335
+ state.task_group_pending_text = True
336
+ t = _p(state.workspace_root, "task_group_with_suggestion",
337
+ suggestion=state.task_group_suggestion)
338
+ return t["echo_variants"]["free_input"]
339
+ raise WizardError(
340
+ f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
341
+ f"got: {value!r}"
342
+ )
343
+ if value.startswith("__recent:"):
344
+ tg = value[len("__recent:"):]
345
+ state.task_group = _slug_or_die(tg, "task_group")
346
+ state.task_group_pending_text = False
347
+ return f"task-group: {state.task_group} (recent)"
348
+ if value == PICK_TYPE_CUSTOM:
349
+ state.task_group_pending_text = True
350
+ return "task-group: (직접 입력)"
351
+ # 기존 호환: free-text 값이 직접 들어오는 케이스(폴백)
352
+ state.task_group = _slug_or_die(value, "task_group")
353
+ state.task_group_pending_text = False
354
+ return f"task-group: {state.task_group}"
355
+ ```
356
+
357
+ - [ ] **Step 2.6: SSOT JSON 에 새 step 정의 추가**
358
+
359
+ `prompts/wizard/prompts.ko.json` 의 `"task_group"` 블록 다음에 추가:
360
+
361
+ ```json
362
+ "task_group_no_suggestion": {
363
+ "label": "Task group? (예: backend-api, INV-1234)",
364
+ "echo_template": "task-group: {value}",
365
+ "options": {
366
+ "__free_input__": "직접 입력"
367
+ }
368
+ },
369
+ ```
370
+
371
+ - [ ] **Step 2.7: 테스트 통과 확인**
372
+
373
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py -v -k task_group`
374
+ Expected: 모든 task_group 테스트 PASS.
375
+
376
+ - [ ] **Step 2.8: 커밋**
377
+
378
+ ```bash
379
+ git add scripts/okstra_ctl/wizard.py prompts/wizard/prompts.ko.json tests/test_okstra_ctl_wizard.py
380
+ git commit -m "feat(wizard): task-group suggestion 없을 때도 3-옵션 picker"
381
+ ```
382
+
383
+ ---
384
+
385
+ ### Task 3: `S_TASK_ID` (suggestion 없을 때) 항상 picker 로
386
+
387
+ **Files:**
388
+ - Modify: `scripts/okstra_ctl/wizard.py:714-750`
389
+ - Modify: `prompts/wizard/prompts.ko.json:32-46`
390
+ - Test: `tests/test_okstra_ctl_wizard.py` — `test_task_id_without_suggestion_offers_picker`
391
+
392
+ - [ ] **Step 3.1: 실패 테스트 작성**
393
+
394
+ ```python
395
+ def test_task_id_without_suggestion_offers_picker(tmp_path):
396
+ from scripts.okstra_ctl.wizard import WizardState, _build_task_id
397
+
398
+ state = WizardState(
399
+ workspace_root="/Volumes/Workspaces/workspace/projects/Okstra",
400
+ project_root=str(tmp_path),
401
+ project_id="proj",
402
+ task_group="backend-api",
403
+ is_new_task=True,
404
+ task_id_suggestion="",
405
+ )
406
+ p = _build_task_id(state)
407
+
408
+ assert p.kind == "pick"
409
+ assert len(p.options) >= 1 # 최소 "직접 입력" 1개
410
+ assert p.options[-1].value == "__free_input__"
411
+ assert p.options[-1].label == "직접 입력"
412
+ ```
413
+
414
+ - [ ] **Step 3.2: 실패 확인**
415
+
416
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py::test_task_id_without_suggestion_offers_picker -v`
417
+ Expected: FAIL — kind == "text".
418
+
419
+ - [ ] **Step 3.3: 추천 후보 헬퍼 + `_build_task_id` 재작성**
420
+
421
+ `_build_task_id` 위에 추가:
422
+
423
+ ```python
424
+ def _suggest_recent_task_ids(state: WizardState, limit: int = 2) -> list[str]:
425
+ """현재 task_group 내의 최근 task-id 후보를 limit 개까지 반환."""
426
+ if not state.project_root or not state.task_group:
427
+ return []
428
+ try:
429
+ tasks = list_project_tasks(Path(state.project_root))
430
+ except Exception:
431
+ return []
432
+ seen: list[str] = []
433
+ for entry in tasks:
434
+ tg = entry.get("taskGroup") or ""
435
+ if tg != state.task_group:
436
+ continue
437
+ tid = entry.get("taskId") or ""
438
+ if tid and tid not in seen:
439
+ seen.append(tid)
440
+ if len(seen) >= limit:
441
+ break
442
+ return seen
443
+ ```
444
+
445
+ `_build_task_id` 교체:
446
+
447
+ ```python
448
+ def _build_task_id(state: WizardState) -> Prompt:
449
+ sugg = state.task_id_suggestion
450
+ if sugg:
451
+ t = _p(state.workspace_root, "task_id_with_suggestion",
452
+ suggestion=sugg)
453
+ options = [
454
+ _opt(k, v.format(suggestion=sugg))
455
+ for k, v in t["options"].items()
456
+ ]
457
+ return Prompt(
458
+ step=S_TASK_ID, kind="pick",
459
+ label=t["label"], options=options,
460
+ echo_template=t["echo_template"],
461
+ )
462
+ recent = _suggest_recent_task_ids(state, limit=2)
463
+ t = _p(state.workspace_root, "task_id_no_suggestion")
464
+ options: list[Option] = []
465
+ for tid in recent:
466
+ options.append(_opt(f"__recent:{tid}", f"같은 group 의 기존: {tid}"))
467
+ options.append(_opt("__free_input__", t["options"]["__free_input__"]))
468
+ return Prompt(
469
+ step=S_TASK_ID, kind="pick",
470
+ label=t["label"], options=options,
471
+ echo_template=t["echo_template"],
472
+ )
473
+ ```
474
+
475
+ - [ ] **Step 3.4: `_submit_task_id` 확장**
476
+
477
+ Task 2 의 `_submit_task_group` 과 동일 패턴으로 `__recent:` 분기 추가, `PICK_TYPE_CUSTOM` 직접 입력 분기 추가.
478
+
479
+ - [ ] **Step 3.5: SSOT JSON 에 `task_id_no_suggestion` 추가**
480
+
481
+ ```json
482
+ "task_id_no_suggestion": {
483
+ "label": "Task id? (예: login-error-analysis, dev-9043)",
484
+ "echo_template": "task-id: {value}",
485
+ "options": {
486
+ "__free_input__": "직접 입력"
487
+ }
488
+ },
489
+ ```
490
+
491
+ - [ ] **Step 3.6: 테스트 통과 확인 + 커밋**
492
+
493
+ Run: `python3 -m pytest tests/test_okstra_ctl_wizard.py -v -k task_id`
494
+ Expected: PASS.
495
+
496
+ ```bash
497
+ git commit -m "feat(wizard): task-id suggestion 없을 때도 3-옵션 picker"
498
+ ```
499
+
500
+ ---
501
+
502
+ ### Task 4: `S_APPROVED_PLAN_PICK` 를 3-옵션으로 확장
503
+
504
+ **Files:**
505
+ - Modify: `scripts/okstra_ctl/wizard.py:930-962`
506
+ - Modify: `prompts/wizard/prompts.ko.json:85-94`
507
+
508
+ - [ ] **Step 4.1: 실패 테스트 작성**
509
+
510
+ ```python
511
+ def test_approved_plan_pick_lists_other_reports(tmp_path):
512
+ """latest 외에 같은 task 의 다른 final-report 도 후보로 노출되어야 한다."""
513
+ from scripts.okstra_ctl.wizard import (
514
+ WizardState, _build_approved_plan_pick,
515
+ )
516
+ # 가짜 final-report 3개를 만들고 build 가 최소 3개 옵션(default + 다른 후보 1+ + 직접 입력) 을 노출하는지 확인.
517
+ ... # 세부 setup 은 기존 _latest_implementation_planning_report 헬퍼 패턴 참고
518
+ ```
519
+
520
+ (테스트 세부는 `_latest_implementation_planning_report` 가 사용하는 디렉토리 레이아웃을 그대로 재현)
521
+
522
+ - [ ] **Step 4.2: 실패 확인 → 구현**
523
+
524
+ `_latest_implementation_planning_report` 옆에 `_list_implementation_planning_reports(state, limit=3)` 헬퍼 추가:
525
+
526
+ ```python
527
+ def _list_implementation_planning_reports(state: WizardState, limit: int = 3) -> list[Path]:
528
+ """task 의 implementation-planning runs 디렉토리에서 최신순으로 final-report 경로를 limit 개까지 반환."""
529
+ if not state.task_group or not state.task_id or not state.project_root:
530
+ return []
531
+ base = (tasks_root(state.project_root)
532
+ / slugify_task_segment(state.task_group)
533
+ / slugify_task_segment(state.task_id)
534
+ / "runs" / "implementation-planning")
535
+ if not base.is_dir():
536
+ return []
537
+ pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
538
+ found: list[tuple[int, Path]] = []
539
+ for run_dir in base.iterdir():
540
+ reports = run_dir / "reports"
541
+ if not reports.is_dir():
542
+ continue
543
+ for child in reports.iterdir():
544
+ m = pat.match(child.name)
545
+ if not m:
546
+ continue
547
+ found.append((int(m.group(1)), child))
548
+ found.sort(key=lambda x: -x[0])
549
+ out: list[Path] = []
550
+ for _, p in found[:limit]:
551
+ try:
552
+ out.append(p.relative_to(Path(state.project_root)))
553
+ except ValueError:
554
+ out.append(p)
555
+ return out
556
+ ```
557
+
558
+ `_build_approved_plan_pick` 재작성:
559
+
560
+ ```python
561
+ def _build_approved_plan_pick(state: WizardState) -> Prompt:
562
+ reports = _list_implementation_planning_reports(state, limit=3)
563
+ default = reports[0] if reports else None
564
+ t = _p(state.workspace_root, "approved_plan_pick",
565
+ default=str(default) if default is not None else "")
566
+ options: list[Option] = []
567
+ if default is not None:
568
+ options.append(_opt(PICK_USE_DEFAULT,
569
+ t["options"][PICK_USE_DEFAULT].format(default=str(default))))
570
+ for p in reports[1:]:
571
+ options.append(_opt(f"__report:{p}", f"이전 보고서: {p}"))
572
+ options.append(_opt(PICK_OTHER, t["options"][PICK_OTHER]))
573
+ return Prompt(
574
+ step=S_APPROVED_PLAN_PICK, kind="pick",
575
+ label=t["label"], options=options,
576
+ echo_template=t["echo_template"],
577
+ )
578
+ ```
579
+
580
+ `_submit_approved_plan_pick` 에 `__report:` 분기 추가 (default 분기와 동일 구조, path 만 다름).
581
+
582
+ - [ ] **Step 4.3: SSOT JSON 의 `__other__` 라벨을 "직접 입력" 으로 통일**
583
+
584
+ ```json
585
+ "__other__": "직접 입력"
586
+ ```
587
+
588
+ - [ ] **Step 4.4: 테스트 통과 확인 + 커밋**
589
+
590
+ ```bash
591
+ git commit -m "feat(wizard): approved-plan picker에 과거 보고서 후보 노출"
592
+ ```
593
+
594
+ ---
595
+
596
+ ### Task 5: `S_DIRECTIVE_PICK` 를 3-옵션으로
597
+
598
+ **Files:**
599
+ - Modify: `scripts/okstra_ctl/wizard.py:1028-1046`
600
+ - Modify: `prompts/wizard/prompts.ko.json:107-114`
601
+
602
+ 추천 후보 정책:
603
+ - `__skip__`: "건너뛰기 (phase 기본값 사용 = 빈 줄)" — 항상 노출
604
+ - `__reuse_last__`: 같은 task 의 가장 최근 run-inputs JSON 에서 `directive` 값을 자동 추출, 비어있지 않으면 노출 — 없으면 옵션 미노출
605
+ - `__free_input__` (= 기존 `__enter__`): "직접 입력" — 항상 마지막
606
+
607
+ `__enter__` 토큰은 `__free_input__` 으로 리네임 (라벨도 "직접 입력" 통일). Pre-1.0 정책에 따라 호환 토큰은 두지 않음.
608
+
609
+ - [ ] **Step 5.1: 헬퍼 추가**
610
+
611
+ ```python
612
+ def _suggest_last_directive(state: WizardState) -> str:
613
+ """같은 task 의 가장 최근 run-inputs-*.json 에서 directive 값을 자동 추출."""
614
+ if not state.task_group or not state.task_id or not state.project_root:
615
+ return ""
616
+ runs_base = (tasks_root(state.project_root)
617
+ / slugify_task_segment(state.task_group)
618
+ / slugify_task_segment(state.task_id) / "runs")
619
+ if not runs_base.is_dir():
620
+ return ""
621
+ candidates: list[tuple[float, Path]] = []
622
+ for phase_dir in runs_base.iterdir():
623
+ if not phase_dir.is_dir():
624
+ continue
625
+ for run_dir in phase_dir.iterdir():
626
+ for p in run_dir.glob("run-inputs-*.json"):
627
+ try:
628
+ candidates.append((p.stat().st_mtime, p))
629
+ except OSError:
630
+ continue
631
+ if not candidates:
632
+ return ""
633
+ candidates.sort(key=lambda x: -x[0])
634
+ import json as _json
635
+ try:
636
+ data = _json.loads(candidates[0][1].read_text(encoding="utf-8"))
637
+ except Exception:
638
+ return ""
639
+ val = data.get("directive") or ""
640
+ return val if isinstance(val, str) else ""
641
+ ```
642
+
643
+ - [ ] **Step 5.2: `_build_directive_pick` 재작성**
644
+
645
+ ```python
646
+ def _build_directive_pick(state: WizardState) -> Prompt:
647
+ last = _suggest_last_directive(state)
648
+ t = _p(state.workspace_root, "directive_pick")
649
+ options: list[Option] = []
650
+ options.append(_opt("__skip__", t["options"]["__skip__"]))
651
+ if last:
652
+ snippet = last[:60] + ("…" if len(last) > 60 else "")
653
+ options.append(_opt("__reuse_last__", f"이전 run 의 directive 재사용: {snippet}"))
654
+ state.last_directive_cached = last
655
+ options.append(_opt("__free_input__", t["options"]["__free_input__"]))
656
+ return Prompt(step=S_DIRECTIVE_PICK, kind="pick",
657
+ label=t["label"], options=options,
658
+ echo_template=t["echo_template"])
659
+ ```
660
+
661
+ `WizardState` 에 `last_directive_cached: str = ""` 필드 추가 (재사용 분기에서 값을 그대로 쓰기 위해).
662
+
663
+ - [ ] **Step 5.3: `_submit_directive_pick` 재작성**
664
+
665
+ ```python
666
+ def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
667
+ if value == "__skip__":
668
+ state.directive = ""
669
+ state.directive_pending_text = False
670
+ return "directive: (none)"
671
+ if value == "__reuse_last__":
672
+ state.directive = state.last_directive_cached
673
+ state.directive_pending_text = False
674
+ return f"directive: {state.directive} (재사용)"
675
+ if value == "__free_input__":
676
+ state.directive_pending_text = True
677
+ return None
678
+ raise WizardError(
679
+ f"expected '__skip__', '__reuse_last__', or '__free_input__', "
680
+ f"got: {value!r}"
681
+ )
682
+ ```
683
+
684
+ - [ ] **Step 5.4: SSOT JSON 옵션 토큰/라벨 통일**
685
+
686
+ ```json
687
+ "directive_pick": {
688
+ "label": "추가 directive 가 있나요?",
689
+ "echo_template": "directive(pick): {value}",
690
+ "options": {
691
+ "__skip__": "없음 (건너뛰기)",
692
+ "__free_input__": "직접 입력"
693
+ }
694
+ },
695
+ ```
696
+
697
+ (`__reuse_last__` 옵션은 런타임 추가 시에만 노출되므로 SSOT 에 두지 않거나 옵션으로만 표기 — 라벨은 build 시 동적 결정 필요. 본 plan 에서는 SSOT 라벨은 base 옵션만 둔다)
698
+
699
+ - [ ] **Step 5.5: 테스트 통과 + 커밋**
700
+
701
+ ```bash
702
+ git commit -m "feat(wizard): directive picker에 이전 run 재사용 옵션 추가"
703
+ ```
704
+
705
+ ---
706
+
707
+ ### Task 6: `S_RELATED_TASKS_PICK` 를 3-옵션으로
708
+
709
+ **Files:**
710
+ - Modify: `scripts/okstra_ctl/wizard.py:1049-1067`
711
+ - Modify: `prompts/wizard/prompts.ko.json:115-122`
712
+
713
+ 추천 후보 정책:
714
+ - `__skip__`: 없음 — 항상
715
+ - `__siblings__`: 같은 task-group 의 다른 task-id 들을 CSV 로 자동 추출 (비어있지 않으면 노출)
716
+ - `__free_input__`: 직접 입력 — 항상 마지막
717
+
718
+ - [ ] **Step 6.1~6.4: 헬퍼/build/submit/SSOT 동일 패턴으로 구현**
719
+
720
+ `_suggest_sibling_task_ids(state)` 헬퍼는 Task 3 의 `_suggest_recent_task_ids` 와 유사하되 limit 제한 없이 전부 모아 CSV 조립.
721
+
722
+ - [ ] **Step 6.5: 커밋**
723
+
724
+ ```bash
725
+ git commit -m "feat(wizard): related-tasks picker에 sibling task-id 자동 추천"
726
+ ```
727
+
728
+ ---
729
+
730
+ ### Task 7: `S_CLARIFICATION_PICK` 를 3-옵션으로
731
+
732
+ **Files:**
733
+ - Modify: `scripts/okstra_ctl/wizard.py:1070-1088`
734
+ - Modify: `prompts/wizard/prompts.ko.json:123-130`
735
+
736
+ 추천 후보 정책:
737
+ - `__skip__`: 없음 — 항상
738
+ - `__latest_report__`: 같은 task 의 가장 최근 `final-report-*.md` 경로 (clarification 은 follow-up 시 직전 보고서를 참조하는 경우가 흔함). `_list_implementation_planning_reports` 를 일반화해 phase 무관 latest report 헬퍼 추가 (`_latest_final_report(state)`).
739
+ - `__free_input__`: 직접 입력 — 항상 마지막
740
+
741
+ - [ ] **Step 7.1~7.5: 같은 패턴으로 구현 + 커밋**
742
+
743
+ ```bash
744
+ git commit -m "feat(wizard): clarification picker에 최근 final-report 자동 추천"
745
+ ```
746
+
747
+ ---
748
+
749
+ ### Task 8: `S_PR_TEMPLATE_PICK` 를 3-옵션으로
750
+
751
+ **Files:**
752
+ - Modify: `scripts/okstra_ctl/wizard.py:1091-1110`
753
+ - Modify: `prompts/wizard/prompts.ko.json:131-138`
754
+
755
+ 추천 후보 정책:
756
+ - `__skip__`: "자동 해석 (project.json → config → 기본)" — 항상 (auto-resolve = 빈 줄)
757
+ - `__project_default__`: project.json 에 `prTemplatePath` 가 등록되어 있으면 그 경로 노출 — 없으면 옵션 미노출
758
+ - `__free_input__`: 직접 입력 — 항상 마지막
759
+
760
+ `okstra config get pr-template-path --scope project --json` 같은 기존 API 가 있는지 우선 확인 (Task 8.1 의 사전 조사). 없다면 `<project_root>/.okstra/project.json` 을 직접 읽어 `prTemplatePath` 필드 검사.
761
+
762
+ - [ ] **Step 8.1~8.5: 동일 패턴 + 커밋**
763
+
764
+ ```bash
765
+ git commit -m "feat(wizard): pr-template picker에 project default 후보 노출"
766
+ ```
767
+
768
+ ---
769
+
770
+ ### Task 9: 통합 검증 + e2e 시나리오 + 문서 업데이트
771
+
772
+ **Files:**
773
+ - Modify: `tests/test_wizard_prompts.py` — 모든 `*_pick` 단계가 최소 옵션 수와 마지막 옵션 토큰을 충족하는지 검증
774
+ - (선택) Create: `tests-e2e/scenario-10-wizard-3-option-picker.sh` — wizard init → 각 단계 picker prompt JSON 검사
775
+ - Modify: `CHANGES.md` — 사용자 영향 한 줄 추가
776
+ - Modify: `skills/okstra-run/SKILL.md` — picker 가 표준이라는 점 한 줄 보강 (선택)
777
+
778
+ - [ ] **Step 9.1: 옵션 수/마지막 토큰 통합 테스트**
779
+
780
+ `tests/test_wizard_prompts.py` 끝에 추가:
781
+
782
+ ```python
783
+ def test_every_pick_step_ends_with_free_input(tmp_path):
784
+ """모든 *_pick 단계는 최소 2개 옵션이며 마지막은 __free_input__ 토큰이어야 한다."""
785
+ from scripts.okstra_ctl.wizard import (
786
+ WizardState, _build_brief_path_pick, _build_task_group, _build_task_id,
787
+ _build_approved_plan_pick, _build_directive_pick,
788
+ _build_related_tasks_pick, _build_clarification_pick,
789
+ _build_pr_template_pick,
790
+ )
791
+ # 각 단계마다 합리적 state 를 만들어서 build → 옵션 마지막 검사
792
+ ...
793
+ ```
794
+
795
+ - [ ] **Step 9.2: 전체 회귀 테스트**
796
+
797
+ Run: `python3 -m pytest tests/ -v`
798
+ Expected: 모두 PASS. 깨지는 테스트가 있으면 그 자리에서 수정.
799
+
800
+ Run: `bash validators/validate-workflow.sh`
801
+ Expected: PASS.
802
+
803
+ - [ ] **Step 9.3: 빌드 + 스모크**
804
+
805
+ ```bash
806
+ npm run build
807
+ node bin/okstra --version
808
+ node bin/okstra doctor
809
+ ```
810
+
811
+ - [ ] **Step 9.4: e2e 시나리오 (선택)**
812
+
813
+ 여력이 되면 `tests-e2e/scenario-10-wizard-3-option-picker.sh` 추가:
814
+
815
+ ```bash
816
+ #!/usr/bin/env bash
817
+ set -euo pipefail
818
+ OKSTRA_HOME="$(mktemp -d)"
819
+ export OKSTRA_HOME
820
+ trap 'rm -rf "$OKSTRA_HOME"' EXIT
821
+
822
+ # wizard init → 8개 picker 모두에서 kind=="pick" 및 마지막 option value==__free_input__ 확인
823
+ ...
824
+ ```
825
+
826
+ - [ ] **Step 9.5: CHANGES.md 업데이트 (한국어, 사용자 영향)**
827
+
828
+ ```markdown
829
+ ## [Unreleased]
830
+ - wizard 모든 입력 단계를 "추천 1~2개 + 직접 입력" 3-옵션 picker 로 통일
831
+ - 사용자 영향: okstra run 의 brief 경로, task-group/id, approved-plan, directive, related-tasks, clarification, pr-template 단계가 모두 picker 로 표시됨. 마지막 옵션은 항상 "직접 입력".
832
+ ```
833
+
834
+ - [ ] **Step 9.6: 최종 커밋**
835
+
836
+ ```bash
837
+ git add CHANGES.md tests/test_wizard_prompts.py tests-e2e/scenario-10-wizard-3-option-picker.sh
838
+ git commit -m "test(wizard): 모든 picker 마지막 옵션이 직접 입력인지 검증"
839
+ ```
840
+
841
+ ---
842
+
843
+ ## Self-Review Checklist
844
+
845
+ 작업 종료 직전 다음을 확인:
846
+
847
+ 1. **Spec coverage** — 8개 단계 모두 picker(추천 1~2개 + 직접 입력)로 전환되었는가? Task 표와 대조하여 빠진 게 없는지 점검.
848
+ 2. **마지막 옵션 일관성** — 모든 `*_pick` 단계에서 `options[-1].value == "__free_input__"` 그리고 `options[-1].label == "직접 입력"` 인가? Task 9.1 의 통합 테스트가 이것을 강제.
849
+ 3. **단일 권위 유지** — `okstra-run` 스킬 본문이 picker 를 발명하는 추가 코드를 도입하지 않았는가? wizard.py 가 내려준 prompt 만 릴레이.
850
+ 4. **추천 후보 0개 폴백** — 의미 있는 자동 추출이 0개인 경우 폴백은 어떤 모습인가? `skip + 직접 입력` 2-옵션 또는 `skip + 더미 안내 + 직접 입력` 중 어느 쪽? 본 plan 은 2-옵션 폴백을 허용 (메모리 정책: "추천이 0개이면 폴백 OK") — 단, 마지막 옵션은 항상 "직접 입력".
851
+ 5. **Pre-1.0** — `__enter__` 토큰 제거 시 호환 shim 두지 말 것. CHANGES.md 에 명시.
852
+ 6. **테스트 격리** — 새 테스트가 `OKSTRA_HOME` 을 별도 tmp_path 로 사용하는가? `tests/conftest.py` 가 자동 격리하지만 e2e 는 명시적 `mktemp -d`.
853
+
854
+ ## 실행 핸드오프
855
+
856
+ Plan 작성 완료. 실행 옵션:
857
+
858
+ **1. Subagent-Driven (권장)** — Task 마다 신선한 subagent 를 띄워 두 단계 review checkpoint 로 점진 진행. 각 task 가 독립 commit 으로 끝나므로 적합.
859
+
860
+ **2. Inline Execution** — 현 세션에서 `superpowers:executing-plans` 로 batch 진행. 8개 task × 평균 6-step = 약 50 step 이므로 컨텍스트 부담은 있으나 단일 세션 일관성 유지.