okstra 0.36.2 → 0.37.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.
- package/README.kr.md +6 -6
- package/README.md +6 -6
- package/bin/okstra +4 -2
- package/docs/kr/architecture.md +29 -29
- package/docs/kr/cli.md +7 -6
- package/docs/pr-template-usage.md +2 -2
- package/docs/project-structure-overview.md +4 -4
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +159 -0
- package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +860 -0
- package/docs/task-process/common-flow.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +5 -5
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +80 -6
- package/runtime/python/lib/okstra/interactive.sh +2 -2
- package/runtime/python/lib/okstra/project-resolver.sh +1 -1
- package/runtime/python/lib/okstra/usage.sh +5 -5
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/python/okstra_ctl/backfill.py +5 -3
- package/runtime/python/okstra_ctl/migrate.py +408 -0
- package/runtime/python/okstra_ctl/paths.py +12 -3
- package/runtime/python/okstra_ctl/pr_template.py +4 -2
- package/runtime/python/okstra_ctl/render.py +8 -6
- package/runtime/python/okstra_ctl/run.py +5 -5
- package/runtime/python/okstra_ctl/seeding.py +12 -6
- package/runtime/python/okstra_ctl/sequence.py +3 -1
- package/runtime/python/okstra_ctl/wizard.py +412 -77
- package/runtime/python/okstra_ctl/worktree.py +8 -6
- package/runtime/python/okstra_project/__init__.py +35 -5
- package/runtime/python/okstra_project/dirs.py +67 -0
- package/runtime/python/okstra_project/resolver.py +8 -8
- package/runtime/python/okstra_project/state.py +11 -9
- package/runtime/python/okstra_token_usage/collect.py +3 -1
- package/runtime/skills/okstra-brief/SKILL.md +30 -30
- package/runtime/skills/okstra-context-loader/SKILL.md +7 -7
- package/runtime/skills/okstra-inspect/SKILL.md +25 -25
- package/runtime/skills/okstra-run/templates/pr-body.template.md +1 -1
- package/runtime/skills/okstra-schedule/SKILL.md +7 -7
- package/runtime/skills/okstra-setup/SKILL.md +8 -8
- package/runtime/templates/okstra.CLAUDE.md +4 -4
- package/runtime/templates/reports/brief.template.md +5 -5
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/lib/fixtures.sh +2 -2
- package/runtime/validators/lib/paths.sh +9 -3
- package/runtime/validators/validate-brief.py +2 -2
- package/runtime/validators/validate-brief.sh +1 -1
- package/runtime/validators/validate-run.py +3 -1
- package/runtime/validators/validate-workflow.sh +2 -2
- package/src/check-project.mjs +3 -3
- package/src/config.mjs +6 -5
- package/src/install.mjs +5 -5
- package/src/migrate.mjs +163 -0
- package/src/okstra-dirs.mjs +37 -0
- package/src/paths.mjs +17 -0
- package/src/setup.mjs +8 -4
- 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 이므로 컨텍스트 부담은 있으나 단일 세션 일관성 유지.
|