okstra 0.49.0 → 0.51.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 (86) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +23 -24
  5. package/docs/kr/cli.md +6 -6
  6. package/docs/project-structure-overview.md +13 -9
  7. package/docs/superpowers/plans/2026-06-05-wizard-batch-prompts.md +559 -0
  8. package/docs/superpowers/specs/2026-06-05-wizard-batch-prompts-design.md +121 -0
  9. package/docs/task-process/error-analysis.md +1 -1
  10. package/docs/task-process/final-verification.md +1 -1
  11. package/docs/task-process/release-handoff.md +1 -1
  12. package/docs/task-process/requirements-discovery.md +1 -1
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/SKILL.md +18 -14
  16. package/runtime/agents/workers/claude-worker.md +4 -4
  17. package/runtime/agents/workers/codex-worker.md +3 -3
  18. package/runtime/agents/workers/gemini-worker.md +3 -3
  19. package/runtime/agents/workers/report-writer-worker.md +3 -3
  20. package/runtime/bin/lib/okstra/cli.sh +8 -1
  21. package/runtime/bin/lib/okstra/globals.sh +3 -0
  22. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  23. package/runtime/bin/lib/okstra/usage.sh +6 -0
  24. package/runtime/bin/okstra-render-report-views.py +1 -1
  25. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  26. package/runtime/bin/okstra.sh +2 -0
  27. package/runtime/prompts/launch.template.md +4 -2
  28. package/runtime/prompts/profiles/_common-contract.md +15 -15
  29. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  30. package/runtime/prompts/profiles/_implementation-executor.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  32. package/runtime/prompts/profiles/error-analysis.md +1 -1
  33. package/runtime/prompts/profiles/final-verification.md +2 -2
  34. package/runtime/prompts/profiles/implementation-planning.md +10 -9
  35. package/runtime/prompts/profiles/implementation.md +1 -1
  36. package/runtime/prompts/profiles/improvement-discovery.md +5 -5
  37. package/runtime/prompts/profiles/release-handoff.md +2 -2
  38. package/runtime/prompts/profiles/requirements-discovery.md +2 -2
  39. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  40. package/runtime/python/okstra_ctl/clarification_items.py +11 -11
  41. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  42. package/runtime/python/okstra_ctl/migrate.py +2 -12
  43. package/runtime/python/okstra_ctl/paths.py +22 -0
  44. package/runtime/python/okstra_ctl/render.py +285 -126
  45. package/runtime/python/okstra_ctl/render_final_report.py +32 -1
  46. package/runtime/python/okstra_ctl/report_views.py +12 -12
  47. package/runtime/python/okstra_ctl/run.py +510 -248
  48. package/runtime/python/okstra_ctl/sequence.py +2 -5
  49. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  50. package/runtime/python/okstra_ctl/wizard.py +219 -136
  51. package/runtime/python/okstra_ctl/workflow.py +1 -1
  52. package/runtime/python/okstra_ctl/worktree.py +13 -5
  53. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  54. package/runtime/skills/okstra-brief/SKILL.md +1 -1
  55. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  56. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  57. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  58. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  59. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  60. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  61. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  62. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  63. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  64. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  65. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  66. package/runtime/skills/okstra-convergence/SKILL.md +8 -8
  67. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  68. package/runtime/skills/okstra-report-writer/SKILL.md +27 -23
  69. package/runtime/skills/okstra-run/SKILL.md +3 -1
  70. package/runtime/skills/okstra-team-contract/SKILL.md +8 -5
  71. package/runtime/templates/reports/final-report.template.md +188 -187
  72. package/runtime/templates/reports/i18n/en.json +4 -4
  73. package/runtime/templates/reports/i18n/ko.json +4 -4
  74. package/runtime/templates/reports/implementation-planning-input.template.md +1 -1
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/templates/reports/user-response.template.md +1 -1
  77. package/runtime/templates/worker-prompt-preamble.md +4 -4
  78. package/runtime/validators/lib/fixtures.sh +2 -2
  79. package/runtime/validators/validate-implementation-plan-stages.py +9 -9
  80. package/runtime/validators/validate-report-views.py +10 -10
  81. package/runtime/validators/validate-run.py +36 -36
  82. package/runtime/validators/validate_improvement_report.py +8 -8
  83. package/src/_python-helper.mjs +3 -3
  84. package/src/context-cost.mjs +27 -0
  85. package/src/install.mjs +1 -0
  86. package/src/uninstall.mjs +1 -0
@@ -5,10 +5,9 @@ import re as _re
5
5
  from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
- from okstra_project.dirs import tasks_root
9
-
10
8
  from .ids import slugify_task_segment
11
9
  from .jsonl import read_jsonl
10
+ from .paths import task_runs_dir
12
11
 
13
12
 
14
13
  def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
@@ -26,9 +25,7 @@ def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
26
25
  이 셋의 max + 1 이 안전한 다음 seq.
27
26
  """
28
27
  task_type_segment = slugify_task_segment(task_type)
29
- base = (tasks_root(project_root)
30
- / slugify_task_segment(task_group) / slugify_task_segment(task_id)
31
- / "runs" / task_type_segment)
28
+ base = task_runs_dir(project_root, task_group, task_id) / task_type_segment
32
29
  max_seq = 0
33
30
  rep_pat = _re.compile(rf"^final-report-{_re.escape(task_type_segment)}-(\d+)\.md$")
34
31
  man_pat = _re.compile(rf"^run-manifest-{_re.escape(task_type_segment)}-(\d+)\.json$")
@@ -0,0 +1,131 @@
1
+ """Stale team-member reconciliation for run-end teardown.
2
+
3
+ A Claude Code team member clears its own ``isActive`` flag in
4
+ ``~/.claude/teams/<team>/config.json`` when its ``Agent()`` dispatch returns, so
5
+ by Phase 7 every worker is normally already inactive and ``TeamDelete()``
6
+ succeeds immediately. The one failure mode is a member whose tmux pane died
7
+ WITHOUT clearing the flag (killed mid-turn): it stays ``isActive: true`` forever,
8
+ and ``TeamDelete`` then refuses the whole team with an "active members" error
9
+ that re-sending ``shutdown_request`` cannot clear — the addressee is already
10
+ gone.
11
+
12
+ This module reconciles exactly that case: a member with ``isActive`` truthy
13
+ whose recorded tmux pane is no longer live is flipped to inactive. It NEVER
14
+ touches a member whose pane is still live, the lead, or a member with no
15
+ recorded pane — those are left for graceful shutdown.
16
+
17
+ Note: the active-member flag is the harness's source of truth, but whether
18
+ ``TeamDelete`` re-reads ``config.json`` at delete time (vs. caching the roster in
19
+ the owning session) is a harness behaviour that can only be confirmed inside a
20
+ real run — see _common-contract.md "Run-end team teardown".
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from pathlib import Path
30
+
31
+
32
+ def live_pane_ids() -> tuple[bool, set[str]]:
33
+ """Return ``(tmux_available, live_pane_ids)``.
34
+
35
+ ``tmux_available`` is False when no tmux server is reachable; the caller must
36
+ then skip reconciliation rather than treat every pane as dead.
37
+ """
38
+ try:
39
+ out = subprocess.run(
40
+ ["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
41
+ capture_output=True, text=True,
42
+ )
43
+ except FileNotFoundError:
44
+ return False, set()
45
+ if out.returncode != 0:
46
+ return False, set()
47
+ return True, {ln.strip() for ln in out.stdout.splitlines() if ln.strip()}
48
+
49
+
50
+ def reconcile_members(config: dict, live_panes: set[str]) -> list[str]:
51
+ """Flip dead-pane stale-active members to inactive in-place.
52
+
53
+ Returns the names of members that were flipped. A member qualifies only when
54
+ it is not the lead, ``isActive`` is truthy, it has a recorded ``tmuxPaneId``,
55
+ and that pane is not among ``live_panes``.
56
+ """
57
+ lead = config.get("leadAgentId")
58
+ flipped: list[str] = []
59
+ for member in config.get("members", []):
60
+ if member.get("agentId") == lead:
61
+ continue
62
+ pane = (member.get("tmuxPaneId") or "").strip()
63
+ if not member.get("isActive") or not pane:
64
+ continue
65
+ if pane not in live_panes:
66
+ member["isActive"] = False
67
+ flipped.append(member.get("name", member.get("agentId", "?")))
68
+ return flipped
69
+
70
+
71
+ def _config_path(team_arg: str) -> Path:
72
+ """Resolve a team name, a team dir, or a config.json path to the config file."""
73
+ p = Path(team_arg)
74
+ if p.name == "config.json":
75
+ return p
76
+ if p.is_dir():
77
+ return p / "config.json"
78
+ return Path.home() / ".claude" / "teams" / team_arg / "config.json"
79
+
80
+
81
+ def _atomic_write(path: Path, text: str) -> None:
82
+ """Replace ``path`` atomically — config.json is load-bearing for the next
83
+ TeamDelete, so a partial write on crash must never be observable."""
84
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".reconcile-")
85
+ try:
86
+ with os.fdopen(fd, "w") as handle:
87
+ handle.write(text)
88
+ os.replace(tmp, path)
89
+ except BaseException:
90
+ try:
91
+ os.unlink(tmp)
92
+ except OSError:
93
+ pass
94
+ raise
95
+
96
+
97
+ def main(argv: list[str]) -> int:
98
+ dry_run = False
99
+ rest: list[str] = []
100
+ for arg in argv:
101
+ if arg in ("--list", "--dry-run"):
102
+ dry_run = True
103
+ else:
104
+ rest.append(arg)
105
+ if len(rest) != 1:
106
+ print("usage: okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>",
107
+ file=sys.stderr)
108
+ return 2
109
+
110
+ cfg_path = _config_path(rest[0])
111
+ if not cfg_path.is_file():
112
+ print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
113
+ return 1
114
+
115
+ config = json.loads(cfg_path.read_text())
116
+ available, live = live_pane_ids()
117
+ if not available:
118
+ print("team-reconcile: tmux unavailable — skipped (no flip)")
119
+ return 0
120
+
121
+ flipped = reconcile_members(config, live)
122
+ if not flipped:
123
+ print("team-reconcile: no stale-active members")
124
+ return 0
125
+ if dry_run:
126
+ print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
127
+ return 0
128
+
129
+ _atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
130
+ print(f"team-reconcile: deactivated {len(flipped)} stale member(s): " + ", ".join(flipped))
131
+ return 0
@@ -46,8 +46,13 @@ from okstra_ctl.workers import (
46
46
  validate_workers_against_profile,
47
47
  )
48
48
  from okstra_ctl import worktree_registry
49
- from okstra_ctl.worktree import preview_worktree_decision
50
- from okstra_project.dirs import project_json_path, tasks_root
49
+ from okstra_ctl.worktree import (
50
+ is_git_work_tree,
51
+ main_worktree_path,
52
+ preview_worktree_decision,
53
+ )
54
+ from okstra_ctl.paths import task_runs_dir
55
+ from okstra_project.dirs import project_json_path
51
56
  from okstra_project.state import (
52
57
  StateError,
53
58
  list_project_tasks,
@@ -205,6 +210,28 @@ S_CONFIRM = "confirm"
205
210
  S_EDIT_TARGET = "edit_target"
206
211
  S_DONE = "done"
207
212
 
213
+ # ---- 멀티탭 배치 프롬프트 그룹 (방출 계층 전용) ----
214
+ # 그룹 id 는 S_* 가 아니므로 prompts JSON SOT / step-id 동기화 검사 대상이 아니다.
215
+ GROUP_MODELS = "models"
216
+ GROUP_OPTIONS = "options"
217
+ GROUP_MAX_TABS = 4 # AskUserQuestion 의 질문(탭) 수 한도
218
+
219
+ # 멤버는 모두 서로 의존이 없는 단일선택 픽 step 이어야 한다.
220
+ # *_TEXT 후속 / workers_override / pr_template_scope 는 의존성 때문에 개별 유지.
221
+ PROMPT_GROUPS: dict[str, tuple[str, ...]] = {
222
+ GROUP_MODELS: (S_LEAD_MODEL, S_EXECUTOR_MODEL, S_CLAUDE_MODEL,
223
+ S_CODEX_MODEL, S_GEMINI_MODEL, S_REPORT_WRITER_MODEL),
224
+ GROUP_OPTIONS: (S_DIRECTIVE_PICK, S_RELATED_TASKS_PICK,
225
+ S_CLARIFICATION_PICK, S_PR_TEMPLATE_PICK),
226
+ }
227
+ GROUP_LABELS: dict[str, str] = {
228
+ GROUP_MODELS: "모델 선택 (탭별로 선택)",
229
+ GROUP_OPTIONS: "추가 옵션 (탭별로 선택)",
230
+ }
231
+ _STEP_TO_GROUP: dict[str, str] = {
232
+ sid: gid for gid, ids in PROMPT_GROUPS.items() for sid in ids
233
+ }
234
+
208
235
 
209
236
  # ---- Data types ----------------------------------------------------------
210
237
 
@@ -305,9 +332,11 @@ class Prompt:
305
332
  help: str = ""
306
333
  echo_template: str = "" # e.g. "task-group: {value}"
307
334
  multi: bool = False # only meaningful when kind == "pick"
335
+ # only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
336
+ questions: list["Prompt"] = field(default_factory=list)
308
337
 
309
338
  def to_json(self) -> dict[str, Any]:
310
- return {
339
+ out = {
311
340
  "step": self.step,
312
341
  "kind": self.kind,
313
342
  "label": self.label,
@@ -316,6 +345,14 @@ class Prompt:
316
345
  "echoTemplate": self.echo_template,
317
346
  "multi": self.multi,
318
347
  }
348
+ if self.kind == "pick_group":
349
+ out["questions"] = [
350
+ {"step": q.step, "label": q.label,
351
+ "options": [asdict(o) for o in q.options],
352
+ "multi": q.multi}
353
+ for q in self.questions
354
+ ]
355
+ return out
319
356
 
320
357
 
321
358
  class WizardError(Exception):
@@ -373,12 +410,12 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
373
410
  " edit the report and change the line to `approved: true`, or re-run "
374
411
  "okstra with `--approve` to flip it from the CLI."
375
412
  )
376
- # frontmatter approved == true 라도 §5 의 Blocks=approval 행이 미해결이면
413
+ # frontmatter approved == true 라도 §1 의 Blocks=approval 행이 미해결이면
377
414
  # 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
378
415
  blockers = unresolved_approval_blockers(body)
379
416
  if blockers:
380
417
  lines = [
381
- f"approved plan frontmatter has `approved: true` but §5 has {len(blockers)} "
418
+ f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
382
419
  f"unresolved `Blocks=approval` row(s); resolve them or mark them obsolete first:",
383
420
  ]
384
421
  for b in blockers:
@@ -389,15 +426,12 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
389
426
 
390
427
 
391
428
  def _git_main_worktree(project_root: Path) -> Path:
392
- try:
393
- common = subprocess.check_output(
394
- ["git", "-C", str(project_root), "rev-parse",
395
- "--path-format=absolute", "--git-common-dir"],
396
- stderr=subprocess.DEVNULL,
397
- ).decode().strip()
398
- except (subprocess.CalledProcessError, FileNotFoundError) as exc:
399
- raise WizardError(f"git unavailable in {project_root}: {exc}")
400
- return Path(common).parent
429
+ # main worktree 해소는 worktree 모듈의 public seam(main_worktree_path)에
430
+ # 위임한다. base-ref 검증은 git 이 없으면 의미가 없으므로, 자체 메시지로
431
+ # 먼저 fail-fast 한다 (과거 자체 `--git-common-dir` 구현을 대체).
432
+ if not is_git_work_tree(project_root):
433
+ raise WizardError(f"git unavailable or not a work tree in {project_root}")
434
+ return main_worktree_path(project_root)
401
435
 
402
436
 
403
437
  def _validate_base_ref(ref: str, project_root: Path) -> str:
@@ -1075,10 +1109,8 @@ def _list_implementation_planning_reports(
1075
1109
  # Run seq lives in the filename, not a per-run subdirectory: every
1076
1110
  # implementation-planning run writes into the same flat `reports/`
1077
1111
  # dir (see paths.py — `run_reports = runs/<task-type>/reports`).
1078
- reports_dir = (tasks_root(state.project_root)
1079
- / slugify_task_segment(state.task_group)
1080
- / slugify_task_segment(state.task_id)
1081
- / "runs" / "implementation-planning" / "reports")
1112
+ reports_dir = (task_runs_dir(state.project_root, state.task_group, state.task_id)
1113
+ / "implementation-planning" / "reports")
1082
1114
  if not reports_dir.is_dir():
1083
1115
  return []
1084
1116
  pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
@@ -1224,9 +1256,7 @@ def _suggest_latest_final_report(state: WizardState) -> str:
1224
1256
  """
1225
1257
  if not state.task_group or not state.task_id or not state.project_root:
1226
1258
  return ""
1227
- runs_base = (tasks_root(state.project_root)
1228
- / slugify_task_segment(state.task_group)
1229
- / slugify_task_segment(state.task_id) / "runs")
1259
+ runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
1230
1260
  if not runs_base.is_dir():
1231
1261
  return ""
1232
1262
  candidates = [
@@ -1253,9 +1283,7 @@ def _suggest_last_directive(state: WizardState) -> str:
1253
1283
  """같은 task 의 가장 최근 run-inputs-*.json 에서 directive 값을 자동 추출."""
1254
1284
  if not state.task_group or not state.task_id or not state.project_root:
1255
1285
  return ""
1256
- runs_base = (tasks_root(state.project_root)
1257
- / slugify_task_segment(state.task_group)
1258
- / slugify_task_segment(state.task_id) / "runs")
1286
+ runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
1259
1287
  if not runs_base.is_dir():
1260
1288
  return ""
1261
1289
  candidates: list[tuple[float, Path]] = []
@@ -1279,43 +1307,96 @@ def _suggest_last_directive(state: WizardState) -> str:
1279
1307
  return val if isinstance(val, str) else ""
1280
1308
 
1281
1309
 
1282
- def _build_directive_pick(state: WizardState) -> Prompt:
1283
- last = _suggest_last_directive(state)
1284
- t = _p(state.workspace_root, "directive_pick")
1285
- options: list[Option] = []
1286
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1287
- if last:
1288
- snippet = last[:60] + ("…" if len(last) > 60 else "")
1289
- label_template = t["labels"]["reuse_last"]
1290
- options.append(_opt(_REUSE_LAST_TOKEN, label_template.format(snippet=snippet)))
1291
- state.last_directive_cached = last
1310
+ # ---------------------------------------------------------------------------
1311
+ # "optional cached pick" seam.
1312
+ #
1313
+ # directive / related-tasks / clarification / pr-template 단계는 모두 동일한
1314
+ # 3-옵션 picker 구조를 갖는다: [건너뛰기 / 추천값(이전 directive·siblings·최근
1315
+ # 리포트·프로젝트 기본) / 직접 입력]. 추천값은 디스크에서 suggest 하고, 선택 시
1316
+ # state cache 필드에 담아 submit 에서 꺼낸다. 과거에는 이 구조가 네 곳에
1317
+ # 기계적으로 복제돼 한 곳을 고치면 나머지가 drift 했다. 변이점만 담은 선언적
1318
+ # spec + 제네릭 build/submit 으로 단일 seam 화한다.
1319
+ # ---------------------------------------------------------------------------
1320
+
1321
+
1322
+ @dataclass(frozen=True)
1323
+ class _OptionalCachedPickSpec:
1324
+ step: str
1325
+ prompt_key: str # _p() 의 step_id
1326
+ recommend_token: str # 추천 옵션의 sentinel value
1327
+ label_key: str # t["labels"] 의 추천 라벨 키
1328
+ echo_suffix_key: str # t["echo_suffixes"] 의 추천 echo 키
1329
+ suggest: Callable[[WizardState], str]
1330
+ snippet_style: str # "prefix" (앞 60자+…) | "suffix" (…+뒤 60자)
1331
+ cache_attr: str # 추천값을 담아둘 state 속성
1332
+ target_attr: str # 확정값을 쓸 state 속성
1333
+ pending_attr: str # 직접 입력 대기 플래그 state 속성
1334
+ extra_clear_attrs: tuple[str, ...] = () # skip 시 추가로 비울 속성
1335
+
1336
+
1337
+ def _pick_snippet(value: str, style: str) -> str:
1338
+ if len(value) <= 60:
1339
+ return value
1340
+ return value[:60] + "…" if style == "prefix" else "…" + value[-60:]
1341
+
1342
+
1343
+ def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
1344
+ suggestion = spec.suggest(state)
1345
+ t = _p(state.workspace_root, spec.prompt_key)
1346
+ options: list[Option] = [_opt(PICK_SKIP, t["options"][PICK_SKIP])]
1347
+ if suggestion:
1348
+ snippet = _pick_snippet(suggestion, spec.snippet_style)
1349
+ options.append(_opt(spec.recommend_token, t["labels"][spec.label_key].format(snippet=snippet)))
1350
+ setattr(state, spec.cache_attr, suggestion)
1292
1351
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1293
1352
  return Prompt(
1294
- step=S_DIRECTIVE_PICK, kind="pick",
1353
+ step=spec.step, kind="pick",
1295
1354
  label=t["label"], options=options,
1296
1355
  echo_template=t["echo_template"],
1297
1356
  )
1298
1357
 
1299
1358
 
1300
- def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
1301
- t = _p(state.workspace_root, "directive_pick")
1359
+ def _submit_optional_cached_pick(
1360
+ state: WizardState, value: str, spec: _OptionalCachedPickSpec
1361
+ ) -> Optional[str]:
1362
+ t = _p(state.workspace_root, spec.prompt_key)
1302
1363
  if value == PICK_SKIP:
1303
- state.directive = ""
1304
- state.directive_pending_text = False
1364
+ setattr(state, spec.target_attr, "")
1365
+ for attr in spec.extra_clear_attrs:
1366
+ setattr(state, attr, "")
1367
+ setattr(state, spec.pending_attr, False)
1305
1368
  return t["echo_suffixes"]["skip"]
1306
- if value == _REUSE_LAST_TOKEN:
1307
- state.directive = state.last_directive_cached
1308
- state.directive_pending_text = False
1309
- return t["echo_suffixes"]["reuse"].format(value=state.directive)
1369
+ if value == spec.recommend_token:
1370
+ cached = getattr(state, spec.cache_attr)
1371
+ setattr(state, spec.target_attr, cached)
1372
+ setattr(state, spec.pending_attr, False)
1373
+ return t["echo_suffixes"][spec.echo_suffix_key].format(value=cached)
1310
1374
  if value == PICK_TYPE_CUSTOM:
1311
- state.directive_pending_text = True
1375
+ setattr(state, spec.pending_attr, True)
1312
1376
  return None
1313
1377
  raise WizardError(
1314
- f"unexpected directive value: {value!r} "
1315
- f"(expected {PICK_SKIP!r}, {_REUSE_LAST_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1378
+ f"unexpected {spec.prompt_key} value: {value!r} "
1379
+ f"(expected {PICK_SKIP!r}, {spec.recommend_token!r}, or {PICK_TYPE_CUSTOM!r})"
1316
1380
  )
1317
1381
 
1318
1382
 
1383
+ _DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
1384
+ step=S_DIRECTIVE_PICK, prompt_key="directive_pick",
1385
+ recommend_token=_REUSE_LAST_TOKEN, label_key="reuse_last", echo_suffix_key="reuse",
1386
+ suggest=_suggest_last_directive, snippet_style="prefix",
1387
+ cache_attr="last_directive_cached", target_attr="directive",
1388
+ pending_attr="directive_pending_text",
1389
+ )
1390
+
1391
+
1392
+ def _build_directive_pick(state: WizardState) -> Prompt:
1393
+ return _build_optional_cached_pick(state, _DIRECTIVE_PICK_SPEC)
1394
+
1395
+
1396
+ def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
1397
+ return _submit_optional_cached_pick(state, value, _DIRECTIVE_PICK_SPEC)
1398
+
1399
+
1319
1400
  def _suggest_sibling_task_ids(state: WizardState) -> str:
1320
1401
  """같은 task-group 의 다른 task-id 를 CSV 로 반환 (현재 task 제외, 빈 결과면 '')."""
1321
1402
  if not state.project_root or not state.task_group:
@@ -1337,74 +1418,39 @@ def _suggest_sibling_task_ids(state: WizardState) -> str:
1337
1418
  return ",".join(siblings)
1338
1419
 
1339
1420
 
1421
+ _RELATED_TASKS_PICK_SPEC = _OptionalCachedPickSpec(
1422
+ step=S_RELATED_TASKS_PICK, prompt_key="related_tasks_pick",
1423
+ recommend_token=_SIBLINGS_TOKEN, label_key="siblings", echo_suffix_key="siblings",
1424
+ suggest=_suggest_sibling_task_ids, snippet_style="prefix",
1425
+ cache_attr="last_siblings_cached", target_attr="related_tasks_raw",
1426
+ pending_attr="related_tasks_pending_text",
1427
+ )
1428
+
1429
+
1340
1430
  def _build_related_tasks_pick(state: WizardState) -> Prompt:
1341
- siblings = _suggest_sibling_task_ids(state)
1342
- t = _p(state.workspace_root, "related_tasks_pick")
1343
- options: list[Option] = []
1344
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1345
- if siblings:
1346
- snippet = siblings if len(siblings) <= 60 else siblings[:60] + "…"
1347
- label_template = t["labels"]["siblings"]
1348
- options.append(_opt(_SIBLINGS_TOKEN, label_template.format(snippet=snippet)))
1349
- state.last_siblings_cached = siblings
1350
- options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1351
- return Prompt(step=S_RELATED_TASKS_PICK, kind="pick",
1352
- label=t["label"], options=options,
1353
- echo_template=t["echo_template"])
1431
+ return _build_optional_cached_pick(state, _RELATED_TASKS_PICK_SPEC)
1354
1432
 
1355
1433
 
1356
1434
  def _submit_related_tasks_pick(state: WizardState, value: str) -> Optional[str]:
1357
- t = _p(state.workspace_root, "related_tasks_pick")
1358
- if value == PICK_SKIP:
1359
- state.related_tasks_raw = ""
1360
- state.related_tasks_pending_text = False
1361
- return t["echo_suffixes"]["skip"]
1362
- if value == _SIBLINGS_TOKEN:
1363
- state.related_tasks_raw = state.last_siblings_cached
1364
- state.related_tasks_pending_text = False
1365
- return t["echo_suffixes"]["siblings"].format(value=state.related_tasks_raw)
1366
- if value == PICK_TYPE_CUSTOM:
1367
- state.related_tasks_pending_text = True
1368
- return None
1369
- raise WizardError(
1370
- f"unexpected related-tasks value: {value!r} "
1371
- f"(expected {PICK_SKIP!r}, {_SIBLINGS_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1372
- )
1435
+ return _submit_optional_cached_pick(state, value, _RELATED_TASKS_PICK_SPEC)
1436
+
1437
+
1438
+ _CLARIFICATION_PICK_SPEC = _OptionalCachedPickSpec(
1439
+ step=S_CLARIFICATION_PICK, prompt_key="clarification_pick",
1440
+ recommend_token=_LATEST_REPORT_TOKEN, label_key="latest_report",
1441
+ echo_suffix_key="latest_report",
1442
+ suggest=_suggest_latest_final_report, snippet_style="suffix",
1443
+ cache_attr="last_final_report_cached", target_attr="clarification_response_path",
1444
+ pending_attr="clarification_pending_text",
1445
+ )
1373
1446
 
1374
1447
 
1375
1448
  def _build_clarification_pick(state: WizardState) -> Prompt:
1376
- latest = _suggest_latest_final_report(state)
1377
- t = _p(state.workspace_root, "clarification_pick")
1378
- options: list[Option] = []
1379
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1380
- if latest:
1381
- snippet = latest if len(latest) <= 60 else "…" + latest[-60:]
1382
- label_template = t["labels"]["latest_report"]
1383
- options.append(_opt(_LATEST_REPORT_TOKEN, label_template.format(snippet=snippet)))
1384
- state.last_final_report_cached = latest
1385
- options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1386
- return Prompt(step=S_CLARIFICATION_PICK, kind="pick",
1387
- label=t["label"], options=options,
1388
- echo_template=t["echo_template"])
1449
+ return _build_optional_cached_pick(state, _CLARIFICATION_PICK_SPEC)
1389
1450
 
1390
1451
 
1391
1452
  def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
1392
- t = _p(state.workspace_root, "clarification_pick")
1393
- if value == PICK_SKIP:
1394
- state.clarification_response_path = ""
1395
- state.clarification_pending_text = False
1396
- return t["echo_suffixes"]["skip"]
1397
- if value == _LATEST_REPORT_TOKEN:
1398
- state.clarification_response_path = state.last_final_report_cached
1399
- state.clarification_pending_text = False
1400
- return t["echo_suffixes"]["latest_report"].format(value=state.clarification_response_path)
1401
- if value == PICK_TYPE_CUSTOM:
1402
- state.clarification_pending_text = True
1403
- return None
1404
- raise WizardError(
1405
- f"unexpected clarification value: {value!r} "
1406
- f"(expected {PICK_SKIP!r}, {_LATEST_REPORT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1407
- )
1453
+ return _submit_optional_cached_pick(state, value, _CLARIFICATION_PICK_SPEC)
1408
1454
 
1409
1455
 
1410
1456
  def _suggest_project_pr_template(state: WizardState) -> str:
@@ -1425,42 +1471,24 @@ def _suggest_project_pr_template(state: WizardState) -> str:
1425
1471
  return val if isinstance(val, str) else ""
1426
1472
 
1427
1473
 
1474
+ _PR_TEMPLATE_PICK_SPEC = _OptionalCachedPickSpec(
1475
+ step=S_PR_TEMPLATE_PICK, prompt_key="pr_template_pick",
1476
+ recommend_token=_PROJECT_DEFAULT_TOKEN, label_key="project_default",
1477
+ echo_suffix_key="project_default",
1478
+ suggest=_suggest_project_pr_template, snippet_style="suffix",
1479
+ cache_attr="last_pr_template_cached", target_attr="pr_template_path",
1480
+ pending_attr="pr_template_pending_text",
1481
+ # skip 시 scope 도 함께 비운다 (원래 _submit_pr_template_pick 의 동작).
1482
+ extra_clear_attrs=("pr_template_scope",),
1483
+ )
1484
+
1485
+
1428
1486
  def _build_pr_template_pick(state: WizardState) -> Prompt:
1429
- project_default = _suggest_project_pr_template(state)
1430
- t = _p(state.workspace_root, "pr_template_pick")
1431
- options: list[Option] = []
1432
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1433
- if project_default:
1434
- snippet = project_default if len(project_default) <= 60 else "…" + project_default[-60:]
1435
- label_template = t["labels"]["project_default"]
1436
- options.append(_opt(_PROJECT_DEFAULT_TOKEN, label_template.format(snippet=snippet)))
1437
- state.last_pr_template_cached = project_default
1438
- options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1439
- return Prompt(
1440
- step=S_PR_TEMPLATE_PICK, kind="pick",
1441
- label=t["label"], options=options,
1442
- echo_template=t["echo_template"],
1443
- )
1487
+ return _build_optional_cached_pick(state, _PR_TEMPLATE_PICK_SPEC)
1444
1488
 
1445
1489
 
1446
1490
  def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
1447
- t = _p(state.workspace_root, "pr_template_pick")
1448
- if value == PICK_SKIP:
1449
- state.pr_template_path = ""
1450
- state.pr_template_scope = ""
1451
- state.pr_template_pending_text = False
1452
- return t["echo_suffixes"]["skip"]
1453
- if value == _PROJECT_DEFAULT_TOKEN:
1454
- state.pr_template_path = state.last_pr_template_cached
1455
- state.pr_template_pending_text = False
1456
- return t["echo_suffixes"]["project_default"].format(value=state.pr_template_path)
1457
- if value == PICK_TYPE_CUSTOM:
1458
- state.pr_template_pending_text = True
1459
- return None
1460
- raise WizardError(
1461
- f"unexpected pr-template value: {value!r} "
1462
- f"(expected {PICK_SKIP!r}, {_PROJECT_DEFAULT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1463
- )
1491
+ return _submit_optional_cached_pick(state, value, _PR_TEMPLATE_PICK_SPEC)
1464
1492
 
1465
1493
 
1466
1494
  CRITIC_CHOICES = ["off", "claude", "codex", "gemini"]
@@ -2218,6 +2246,30 @@ def init_state(
2218
2246
  )
2219
2247
 
2220
2248
 
2249
+ def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
2250
+ """그룹의 적용가능·미답변 픽 멤버를 최대 GROUP_MAX_TABS 개 모은다.
2251
+
2252
+ 멤버가 1개뿐이면 멀티탭 UI가 불필요하므로 그 멤버의 평범한 픽을 반환한다.
2253
+ 호출부(next_prompt)는 적용 가능한 멤버가 최소 1개일 때만 진입하므로 빈 그룹은
2254
+ 도달 불가다.
2255
+ """
2256
+ members: list[Prompt] = []
2257
+ for sid in PROMPT_GROUPS[group_id]:
2258
+ if sid in state.answered:
2259
+ continue
2260
+ step = STEP_BY_ID[sid]
2261
+ if not step.applies(state):
2262
+ continue
2263
+ members.append(step.build(state))
2264
+ if len(members) >= GROUP_MAX_TABS:
2265
+ break
2266
+ assert members, f"group {group_id!r} reached with no applicable members"
2267
+ if len(members) == 1:
2268
+ return members[0]
2269
+ return Prompt(step=group_id, kind="pick_group",
2270
+ label=GROUP_LABELS[group_id], questions=members)
2271
+
2272
+
2221
2273
  def next_prompt(state: WizardState) -> Prompt:
2222
2274
  if state.confirmed:
2223
2275
  return Prompt(step=S_DONE, kind="done")
@@ -2225,10 +2277,39 @@ def next_prompt(state: WizardState) -> Prompt:
2225
2277
  if step.id in state.answered:
2226
2278
  continue
2227
2279
  if step.applies(state):
2280
+ group_id = _STEP_TO_GROUP.get(step.id)
2281
+ if group_id is not None:
2282
+ return _build_group_prompt(state, group_id)
2228
2283
  return step.build(state)
2229
2284
  return Prompt(step=S_DONE, kind="done")
2230
2285
 
2231
2286
 
2287
+ def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, Any]:
2288
+ """pick_group 답(JSON 객체)을 각 멤버 submit() 으로 라우팅한다.
2289
+
2290
+ 멤버 submit 이 WizardError 를 던지면 그대로 전파되어 같은 그룹을 재-프롬프트한다.
2291
+ answered 마킹은 모든 멤버 submit 이 통과한 뒤에만 일괄 수행한다(answered 단위의
2292
+ 전부-아니면-전무). 개별 멤버가 변경한 state 필드는 롤백하지 않지만, 재-프롬프트 시
2293
+ 같은 그룹이 다시 나와 사용자 입력으로 덮어쓰므로 무해하다.
2294
+ """
2295
+ try:
2296
+ answers = json.loads(value or "{}")
2297
+ except json.JSONDecodeError as exc:
2298
+ raise WizardError(f"pick_group answer must be a JSON object: {exc}")
2299
+ if not isinstance(answers, dict):
2300
+ raise WizardError("pick_group answer must be a JSON object")
2301
+ echoes: list[str] = []
2302
+ for q in prompt.questions:
2303
+ echo = STEP_BY_ID[q.step].submit(state, str(answers.get(q.step, "") or ""))
2304
+ if echo:
2305
+ echoes.append(echo)
2306
+ for q in prompt.questions:
2307
+ if q.step not in state.answered:
2308
+ state.answered.append(q.step)
2309
+ nxt = next_prompt(state)
2310
+ return {"echo": "; ".join(echoes), "next": nxt.to_json()}
2311
+
2312
+
2232
2313
  def submit(state: WizardState, value: str) -> dict[str, Any]:
2233
2314
  """Validate the answer for the *currently active* step and advance.
2234
2315
 
@@ -2238,6 +2319,8 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2238
2319
  prompt = next_prompt(state)
2239
2320
  if prompt.kind == "done":
2240
2321
  return {"echo": "", "next": prompt.to_json()}
2322
+ if prompt.kind == "pick_group":
2323
+ return _submit_group(state, prompt, value)
2241
2324
  step = STEP_BY_ID[prompt.step]
2242
2325
  echo = step.submit(state, value or "")
2243
2326
  if prompt.step not in state.answered:
@@ -87,7 +87,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
87
87
  " - trade-off matrix across options (complexity, risk, reversibility, test cost, rollout cost) and recommended option with rationale tied to isolation / single-responsibility / YAGNI principles\n"
88
88
  " - bite-sized stepwise execution order for the recommended option (each step ~2-5 min, exact file paths and commands, TDD ordering when applicable, no placeholders)\n"
89
89
  " - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
90
- " - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 5. Clarification Items` table (do NOT create a separate `Open Questions` block under `4.5.x` — the unified table is the single home)\n"
90
+ " - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
91
91
  " - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
92
92
  " - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)"
93
93
  ),