okstra 0.70.0 → 0.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,8 +52,10 @@ from okstra_ctl.workers import (
52
52
  from okstra_ctl.workflow import PHASE_SEQUENCE
53
53
  from okstra_ctl import worktree_registry
54
54
  from okstra_ctl.worktree import (
55
+ compute_worktree_path,
55
56
  is_git_work_tree,
56
57
  main_worktree_path,
58
+ preview_stage_worktree_decision,
57
59
  preview_worktree_decision,
58
60
  )
59
61
  from okstra_ctl.paths import task_runs_dir
@@ -88,6 +90,13 @@ EXECUTORS = ["claude", "codex", "gemini"]
88
90
  # (or the whole task via `auto`). Both gate the approved-plan + stage steps.
89
91
  _STAGE_SCOPED_TASK_TYPES = ("implementation", "final-verification")
90
92
 
93
+ # brief 는 entry phase 의 입력물(비개발자/타 팀의 아이디어·에러 초안)이다.
94
+ # downstream phase 는 task manifest 의 taskBriefPath 를 자동 carry-in 하며
95
+ # 위저드에서 brief 를 묻지 않는다 (release-handoff 는 prepare 가 검증 보고서
96
+ # 인용 input 문서를 자동 생성하므로 brief 자체가 없다).
97
+ _BRIEF_ENTRY_TASK_TYPES = ("requirements-discovery", "improvement-discovery",
98
+ "error-analysis")
99
+
91
100
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
92
101
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
93
102
 
@@ -230,12 +239,14 @@ S_TASK_TYPE_TEXT = "task_type_text"
230
239
  S_BRIEF_KEEP = "brief_keep"
231
240
  S_BRIEF_PATH_PICK = "brief_path_pick"
232
241
  S_BRIEF_PATH = "brief_path"
242
+ S_BRIEF_CARRY = "brief_carry"
233
243
  S_BASE_REF_PICK = "base_ref_pick"
234
244
  S_BASE_REF_TEXT = "base_ref_text"
235
245
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
236
246
  S_APPROVED_PLAN = "approved_plan"
237
247
  S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
238
248
  S_STAGE_PICK = "stage_pick"
249
+ S_HANDOFF_STAGE_PICK = "handoff_stage_pick"
239
250
  S_EXECUTOR = "executor"
240
251
  S_CRITIC_PICK = "critic_pick"
241
252
  S_CRITIC_TEXT = "critic_text"
@@ -334,6 +345,11 @@ class WizardState:
334
345
  critic: str = ""
335
346
  critic_pending_text: bool = False
336
347
 
348
+ # release-handoff: PR 로 내보낼 범위. mode 는 stages 선택의 파생값이다 —
349
+ # whole-task = 빈 stages(whole-task 검증 기반 단일 PR), stage-group = csv.
350
+ handoff_mode: str = "" # "" | "whole-task" | "stage-group"
351
+ handoff_stages: str = "" # csv ("2,3"), whole-task 면 ""
352
+
337
353
  # resume: 직전 run-inputs 재사용 여부 (None=미응답, True=재사용, False=재입력)
338
354
  reuse_previous: Optional[bool] = None
339
355
 
@@ -767,6 +783,11 @@ def _base_ref_required(state: WizardState) -> bool:
767
783
  return state.task_type != "final-verification" and state.reuse_worktree is False
768
784
 
769
785
 
786
+ def _brief_resolved(state: WizardState) -> bool:
787
+ """brief 입력이 끝났는가. release-handoff 는 brief 가 없는 phase 라 항상 True."""
788
+ return bool(state.brief_path) or state.task_type == "release-handoff"
789
+
790
+
770
791
  def _base_ref_ready(state: WizardState) -> bool:
771
792
  return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
772
793
 
@@ -1215,6 +1236,23 @@ def _build_task_type(state: WizardState) -> Prompt:
1215
1236
  echo_template=t["echo_template"])
1216
1237
 
1217
1238
 
1239
+ def _carry_in_existing_brief(state: WizardState) -> str:
1240
+ """downstream task-type 이 등록된 brief 를 자동 carry-in 한다. 주입한 경로(또는 '')."""
1241
+ if state.task_type in _BRIEF_ENTRY_TASK_TYPES:
1242
+ return ""
1243
+ if state.task_type == "release-handoff":
1244
+ return "" # prepare 가 검증 보고서 인용 input 문서를 자동 생성한다
1245
+ if state.brief_path or not state.existing_brief_path:
1246
+ return ""
1247
+ p = Path(state.existing_brief_path)
1248
+ if not p.is_absolute():
1249
+ p = Path(state.project_root) / p
1250
+ if not p.is_file():
1251
+ return "" # manifest 경로가 깨졌으면 brief_carry 단계가 처리한다
1252
+ state.brief_path = state.existing_brief_path
1253
+ return state.brief_path
1254
+
1255
+
1218
1256
  def _apply_task_type(state: WizardState, value: str) -> str:
1219
1257
  if value not in TASK_TYPE_VALUES:
1220
1258
  raise WizardError(
@@ -1222,6 +1260,11 @@ def _apply_task_type(state: WizardState, value: str) -> str:
1222
1260
  f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
1223
1261
  )
1224
1262
  state.task_type = value
1263
+ # brief_carry 의 "entry phase 로 전환" 이 task-type 을 리셋한 뒤 submit() 이
1264
+ # brief_carry 를 answered 로 되돌려 놓는다 — task-type 을 다시 고르는 시점에
1265
+ # 퍼지해야 downstream 재선택 시 carry 단계가 다시 나온다.
1266
+ state.answered = [a for a in state.answered if a != S_BRIEF_CARRY]
1267
+ carried = _carry_in_existing_brief(state)
1225
1268
  state.profile_workers = _load_profile_workers(
1226
1269
  Path(state.workspace_root), value
1227
1270
  )
@@ -1231,6 +1274,8 @@ def _apply_task_type(state: WizardState, value: str) -> str:
1231
1274
  # Reuse-worktree is decided once identity is final. Recompute here so
1232
1275
  # subsequent base-ref step knows whether to apply.
1233
1276
  state.reuse_worktree = _resolve_reuse_worktree(state)
1277
+ if carried:
1278
+ return f"task-type: {value}\nbrief (carry-in): {carried}"
1234
1279
  return f"task-type: {value}"
1235
1280
 
1236
1281
 
@@ -1357,6 +1402,37 @@ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
1357
1402
  return f"brief: {p}"
1358
1403
 
1359
1404
 
1405
+ BRIEF_CARRY_SWITCH_ENTRY = "__switch_entry__"
1406
+ BRIEF_CARRY_ABORT = "__abort__"
1407
+
1408
+
1409
+ def _build_brief_carry(state: WizardState) -> Prompt:
1410
+ t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
1411
+ return Prompt(
1412
+ step=S_BRIEF_CARRY, kind="pick",
1413
+ label=t["label"],
1414
+ options=[_opt(k, v) for k, v in t["options"].items()],
1415
+ echo_template=t["echo_template"],
1416
+ )
1417
+
1418
+
1419
+ def _submit_brief_carry(state: WizardState, value: str) -> Optional[str]:
1420
+ t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
1421
+ if value == BRIEF_CARRY_SWITCH_ENTRY:
1422
+ _reset_from(state, S_TASK_TYPE)
1423
+ return t["echo_variants"]["switch_entry"]
1424
+ if value == PICK_TYPE_CUSTOM:
1425
+ state.brief_path_pending_text = True
1426
+ return None
1427
+ if value == BRIEF_CARRY_ABORT:
1428
+ state.aborted = True
1429
+ return t["echo_variants"]["abort"]
1430
+ raise WizardError(
1431
+ f"expected '{BRIEF_CARRY_SWITCH_ENTRY}', {PICK_TYPE_CUSTOM!r}, "
1432
+ f"or '{BRIEF_CARRY_ABORT}', got: {value!r}"
1433
+ )
1434
+
1435
+
1360
1436
  def _build_base_ref_pick(state: WizardState) -> Prompt:
1361
1437
  t = _p(state.workspace_root, "base_ref_pick")
1362
1438
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -1602,6 +1678,105 @@ def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1602
1678
  return f"stage: {answer}"
1603
1679
 
1604
1680
 
1681
+ def _handoff_msgs(state: WizardState) -> dict:
1682
+ """handoff_stage_pick 의 JSON 텍스트 묶음 (label 미사용 조회용)."""
1683
+ return _p(state.workspace_root, "handoff_stage_pick", blocked="")
1684
+
1685
+
1686
+ def _resolve_handoff_plan(state: WizardState) -> Path:
1687
+ """release-handoff 의 approved plan 을 질문 없이 자동 해소한다.
1688
+
1689
+ plan 미존재/미승인은 사용자가 고칠 대상이 아니라 라이프사이클 선행 단계
1690
+ 누락이므로 picker 대신 안내 메시지로 즉시 실패한다."""
1691
+ if state.approved_plan_path:
1692
+ return Path(state.approved_plan_path)
1693
+ t = _handoff_msgs(state)
1694
+ latest = _latest_implementation_planning_report(state)
1695
+ if latest is None:
1696
+ raise WizardError(t["errors"]["no_plan"])
1697
+ p, fully_approved = _classify_approved_plan(
1698
+ str(latest), Path(state.project_root))
1699
+ if not fully_approved:
1700
+ raise WizardError(t["errors"]["plan_not_approved"].format(plan=p))
1701
+ state.approved_plan_path = str(p)
1702
+ return p
1703
+
1704
+
1705
+ def _handoff_eligibility(state: WizardState) -> list:
1706
+ """stage 별 PR 자격 — okstra_ctl.handoff 의 SSOT 판정을 그대로 재사용한다."""
1707
+ from okstra_ctl.handoff import compute_eligibility
1708
+ from okstra_ctl.run import _parse_stage_map_into_ctx
1709
+ from okstra_ctl.consumers import read_consumers
1710
+ plan = _resolve_handoff_plan(state)
1711
+ stage_map = _parse_stage_map_into_ctx(str(plan))
1712
+ if not stage_map:
1713
+ raise WizardError(
1714
+ _handoff_msgs(state)["errors"]["no_stage_map"].format(plan=plan))
1715
+ rows = read_consumers(plan.resolve().parents[1])
1716
+ return compute_eligibility(stage_map, rows)
1717
+
1718
+
1719
+ def _latest_whole_task_fv_accepted(state: WizardState) -> str:
1720
+ """accepted whole-task final-verification 보고서 경로 — handoff 모듈 SSOT 위임."""
1721
+ from okstra_ctl.handoff import latest_whole_task_fv_accepted
1722
+ return latest_whole_task_fv_accepted(
1723
+ state.project_root, state.project_id, state.task_group, state.task_id)
1724
+
1725
+
1726
+ def _build_handoff_stage_pick(state: WizardState) -> Prompt:
1727
+ elig = _handoff_eligibility(state)
1728
+ eligible = [e for e in elig if e["eligible"]]
1729
+ blocked = [e for e in elig if not e["eligible"]]
1730
+ whole_task_report = _latest_whole_task_fv_accepted(state)
1731
+ msgs = _handoff_msgs(state)
1732
+ blocked_summary = ("; ".join(
1733
+ f"stage {e['stage']} ({', '.join(e['reasons'])})" for e in blocked)
1734
+ or msgs["labels"]["blocked_none"])
1735
+ if not eligible and not whole_task_report:
1736
+ raise WizardError(
1737
+ msgs["errors"]["nothing_eligible"].format(blocked=blocked_summary))
1738
+ t = _p(state.workspace_root, "handoff_stage_pick", blocked=blocked_summary)
1739
+ options: list[Option] = []
1740
+ if whole_task_report:
1741
+ options.append(_opt(WHOLE_TASK_STAGE, t["labels"]["whole_task"]))
1742
+ stage_label = t["labels"]["stage"]
1743
+ for e in eligible:
1744
+ deps = ", ".join(str(d) for d in e["depends_on"]) or "-"
1745
+ options.append(_opt(str(e["stage"]),
1746
+ stage_label.format(stage=e["stage"], deps=deps)))
1747
+ return Prompt(
1748
+ step=S_HANDOFF_STAGE_PICK, kind="pick", multi=True,
1749
+ label=t["label"], options=options,
1750
+ echo_template=t["echo_template"],
1751
+ )
1752
+
1753
+
1754
+ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
1755
+ t = _handoff_msgs(state)
1756
+ picks = [v.strip() for v in (value or "").split(",") if v.strip()]
1757
+ if not picks:
1758
+ raise WizardError(t["errors"]["none_selected"])
1759
+ if WHOLE_TASK_STAGE in picks:
1760
+ if len(picks) > 1:
1761
+ raise WizardError(t["errors"]["whole_task_exclusive"])
1762
+ if not _latest_whole_task_fv_accepted(state):
1763
+ raise WizardError(t["errors"]["whole_task_missing"])
1764
+ state.handoff_mode = "whole-task"
1765
+ state.handoff_stages = ""
1766
+ return t["echo_variants"]["whole_task"]
1767
+ eligible = {str(e["stage"]) for e in _handoff_eligibility(state)
1768
+ if e["eligible"]}
1769
+ bad = [p for p in picks if p not in eligible]
1770
+ if bad:
1771
+ raise WizardError(t["errors"]["not_eligible"].format(
1772
+ bad=", ".join(bad), eligible=", ".join(sorted(eligible))))
1773
+ nums = sorted({int(p) for p in picks})
1774
+ state.handoff_mode = "stage-group"
1775
+ state.handoff_stages = ",".join(str(n) for n in nums)
1776
+ return t["echo_variants"]["stage_group"].format(
1777
+ stages=state.handoff_stages)
1778
+
1779
+
1605
1780
  def _suggest_latest_final_report(state: WizardState) -> str:
1606
1781
  """task 의 모든 phase runs 디렉토리에서 가장 최근 final-report-*.md 의 relpath 를 반환.
1607
1782
 
@@ -2250,6 +2425,8 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
2250
2425
 
2251
2426
 
2252
2427
  def _build_branch_confirm(state: WizardState) -> Prompt:
2428
+ if state.task_type == "implementation":
2429
+ return _build_branch_confirm_impl_stage(state)
2253
2430
  decision = preview_worktree_decision(
2254
2431
  project_root=Path(state.project_root),
2255
2432
  project_id=state.project_id,
@@ -2270,6 +2447,7 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
2270
2447
  branch=decision.branch or "(none)",
2271
2448
  base_ref=decision.base_ref or "(HEAD)",
2272
2449
  path=decision.path,
2450
+ task_key=f"{state.task_group}/{state.task_id}",
2273
2451
  )
2274
2452
  # Pass the computed label as `summary` so _p's placeholder interpolation works.
2275
2453
  t = _p(state.workspace_root, "branch_confirm", summary=label)
@@ -2282,6 +2460,43 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
2282
2460
  options=options, echo_template=t["echo_template"])
2283
2461
 
2284
2462
 
2463
+ def _build_branch_confirm_impl_stage(state: WizardState) -> Prompt:
2464
+ """implementation 은 stage 격리로 동작하므로 task-key 디렉터리가 아니라
2465
+ 이번 run 이 실제로 사용할 stage worktree 관점으로 미리보기를 보여준다."""
2466
+ raw = _load_wizard_root(state.workspace_root)["steps"]["branch_confirm"]
2467
+ task_key = f"{state.task_group}/{state.task_id}"
2468
+ stage = (state.selected_stage or "auto").strip()
2469
+ if stage == "auto":
2470
+ parent_dir = compute_worktree_path(
2471
+ project_id=state.project_id, task_group_segment=state.task_group,
2472
+ task_id_segment=state.task_id,
2473
+ )
2474
+ label = raw["labels"]["impl_stage_auto"].format(
2475
+ task_key=task_key, path=str(parent_dir),
2476
+ )
2477
+ decision_status = "new"
2478
+ else:
2479
+ decision = preview_stage_worktree_decision(
2480
+ project_id=state.project_id, task_group_segment=state.task_group,
2481
+ task_id_segment=state.task_id, work_category="",
2482
+ stage_number=int(stage),
2483
+ )
2484
+ key = "impl_stage_reuse" if decision.status == "reused" else "impl_stage_new"
2485
+ label = raw["labels"][key].format(
2486
+ stage=stage, path=decision.path, branch=decision.branch,
2487
+ task_key=task_key,
2488
+ )
2489
+ decision_status = decision.status
2490
+ t = _p(state.workspace_root, "branch_confirm", summary=label)
2491
+ opts = t["options"]
2492
+ options = [_opt("proceed", opts["proceed"])]
2493
+ if decision_status == "new" and _base_ref_required(state):
2494
+ options.append(_opt("edit", opts["edit"]))
2495
+ options.append(_opt("abort", opts["abort"]))
2496
+ return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
2497
+ options=options, echo_template=t["echo_template"])
2498
+
2499
+
2285
2500
  def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
2286
2501
  if value == "edit":
2287
2502
  _reset_from(state, S_BASE_REF_PICK)
@@ -2364,16 +2579,40 @@ STEPS: list[Step] = [
2364
2579
  and s.task_group_pending_text),
2365
2580
  build=_build_task_group_text, submit=_submit_task_group_text,
2366
2581
  owns=("task_group", "task_group_pending_text")),
2582
+ # 신규 task 흐름 순서: task-group → task-type → (entry 면 brief) → task-id.
2583
+ # brief 는 entry phase 전용 입력이므로 task-type 이 정해진 뒤에만 물을 수 있다.
2584
+ Step(S_TASK_TYPE,
2585
+ applies=lambda s: (s.is_new_task is not None
2586
+ and (s.is_new_task is False or bool(s.task_group))
2587
+ and S_TASK_TYPE not in s.answered),
2588
+ build=_build_task_type, submit=_submit_task_type,
2589
+ owns=("task_type", "profile_workers", "profile_optional_workers",
2590
+ "reuse_worktree")),
2591
+ Step(S_TASK_TYPE_TEXT,
2592
+ applies=lambda s: (S_TASK_TYPE in s.answered
2593
+ and not s.task_type
2594
+ and S_TASK_TYPE_TEXT not in s.answered),
2595
+ build=_build_task_type_text, submit=_submit_task_type_text,
2596
+ owns=("task_type", "profile_workers", "profile_optional_workers",
2597
+ "reuse_worktree")),
2598
+ Step(S_BRIEF_KEEP,
2599
+ applies=lambda s: (not s.is_new_task
2600
+ and s.task_type in _BRIEF_ENTRY_TASK_TYPES
2601
+ and bool(s.existing_brief_path)
2602
+ and s.keep_existing_brief is None
2603
+ and S_TASK_TYPE in s.answered),
2604
+ build=_build_brief_keep, submit=_submit_brief_keep,
2605
+ owns=("keep_existing_brief",)),
2367
2606
  Step(S_BRIEF_PATH_PICK,
2368
2607
  applies=lambda s: (
2369
2608
  not s.brief_path
2370
2609
  and not s.brief_path_pending_text
2371
2610
  and S_BRIEF_PATH_PICK not in s.answered
2611
+ and S_TASK_TYPE in s.answered
2612
+ and s.task_type in _BRIEF_ENTRY_TASK_TYPES
2372
2613
  and (
2373
- (s.is_new_task is True and bool(s.task_group))
2614
+ s.is_new_task is True
2374
2615
  or (s.is_new_task is False
2375
- and S_TASK_TYPE in s.answered
2376
- and bool(s.task_type)
2377
2616
  and (s.keep_existing_brief is False
2378
2617
  or not s.existing_brief_path))
2379
2618
  )
@@ -2385,49 +2624,39 @@ STEPS: list[Step] = [
2385
2624
  and S_BRIEF_PATH not in s.answered,
2386
2625
  build=_build_brief_path, submit=_submit_brief_path,
2387
2626
  owns=("brief_path", "task_group_suggestion", "task_id_suggestion")),
2627
+ # downstream task-type 인데 carry-in 할 brief 가 없을 때의 fallback.
2628
+ # release-handoff 는 brief 자체가 없는 phase 라 대상에서 빠진다.
2629
+ Step(S_BRIEF_CARRY,
2630
+ applies=lambda s: (S_TASK_TYPE in s.answered
2631
+ and bool(s.task_type)
2632
+ and s.task_type not in _BRIEF_ENTRY_TASK_TYPES
2633
+ and s.task_type != "release-handoff"
2634
+ and not s.brief_path
2635
+ and not s.brief_path_pending_text),
2636
+ build=_build_brief_carry, submit=_submit_brief_carry,
2637
+ owns=("brief_path_pending_text",)),
2388
2638
  Step(S_TASK_ID,
2389
2639
  applies=lambda s: (bool(s.is_new_task)
2390
- and bool(s.brief_path)
2391
2640
  and bool(s.task_group)
2641
+ and _brief_resolved(s)
2392
2642
  and not s.task_id
2393
2643
  and not s.task_id_pending_text),
2394
2644
  build=_build_task_id, submit=_submit_task_id,
2395
2645
  owns=("task_id", "task_id_pending_text")),
2396
2646
  Step(S_TASK_ID_TEXT,
2397
2647
  applies=lambda s: (bool(s.is_new_task)
2398
- and bool(s.brief_path)
2399
2648
  and bool(s.task_group)
2649
+ and _brief_resolved(s)
2400
2650
  and not s.task_id
2401
2651
  and s.task_id_pending_text),
2402
2652
  build=_build_task_id_text, submit=_submit_task_id_text,
2403
2653
  owns=("task_id", "task_id_pending_text")),
2404
- Step(S_TASK_TYPE,
2405
- applies=lambda s: (s.is_new_task is not None
2406
- and (s.is_new_task is False or bool(s.task_id))
2407
- and S_TASK_TYPE not in s.answered),
2408
- build=_build_task_type, submit=_submit_task_type,
2409
- owns=("task_type", "profile_workers", "profile_optional_workers",
2410
- "reuse_worktree")),
2411
- Step(S_TASK_TYPE_TEXT,
2412
- applies=lambda s: (S_TASK_TYPE in s.answered
2413
- and not s.task_type
2414
- and S_TASK_TYPE_TEXT not in s.answered),
2415
- build=_build_task_type_text, submit=_submit_task_type_text,
2416
- owns=("task_type", "profile_workers", "profile_optional_workers",
2417
- "reuse_worktree")),
2418
- Step(S_BRIEF_KEEP,
2419
- applies=lambda s: (not s.is_new_task
2420
- and bool(s.existing_brief_path)
2421
- and s.keep_existing_brief is None
2422
- and S_TASK_TYPE in s.answered
2423
- and bool(s.task_type)),
2424
- build=_build_brief_keep, submit=_submit_brief_keep,
2425
- owns=("keep_existing_brief",)),
2426
2654
  Step(S_BASE_REF_PICK,
2427
2655
  applies=lambda s: (S_TASK_TYPE in s.answered
2428
2656
  and _base_ref_required(s)
2429
2657
  and S_BASE_REF_PICK not in s.answered
2430
- and bool(s.brief_path)),
2658
+ and _brief_resolved(s)
2659
+ and (not s.is_new_task or bool(s.task_id))),
2431
2660
  build=_build_base_ref_pick, submit=_submit_base_ref_pick,
2432
2661
  owns=("base_ref", "base_ref_pending_text")),
2433
2662
  Step(S_BASE_REF_TEXT,
@@ -2468,6 +2697,13 @@ STEPS: list[Step] = [
2468
2697
  and S_STAGE_PICK not in s.answered),
2469
2698
  build=_build_stage_pick, submit=_submit_stage_pick,
2470
2699
  owns=("selected_stage",)),
2700
+ Step(S_HANDOFF_STAGE_PICK,
2701
+ applies=lambda s: (s.task_type == "release-handoff"
2702
+ and bool(s.task_group)
2703
+ and bool(s.task_id)
2704
+ and S_HANDOFF_STAGE_PICK not in s.answered),
2705
+ build=_build_handoff_stage_pick, submit=_submit_handoff_stage_pick,
2706
+ owns=("handoff_mode", "handoff_stages", "approved_plan_path")),
2471
2707
  Step(S_EXECUTOR,
2472
2708
  applies=lambda s: (s.task_type == "implementation"
2473
2709
  and bool(s.approved_plan_path)
@@ -2644,7 +2880,8 @@ def _identity_ready(s: WizardState) -> bool:
2644
2880
  """All identity questions (task pick → executor for impl) answered."""
2645
2881
  if not s.task_type:
2646
2882
  return False
2647
- if not s.brief_path:
2883
+ # release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
2884
+ if not s.brief_path and s.task_type != "release-handoff":
2648
2885
  return False
2649
2886
  if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
2650
2887
  return False
@@ -2656,6 +2893,9 @@ def _identity_ready(s: WizardState) -> bool:
2656
2893
  if s.task_type == "implementation":
2657
2894
  if not s.executor:
2658
2895
  return False
2896
+ if (s.task_type == "release-handoff"
2897
+ and S_HANDOFF_STAGE_PICK not in s.answered):
2898
+ return False
2659
2899
  return True
2660
2900
 
2661
2901
 
@@ -2708,6 +2948,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
2708
2948
  "base_ref_pending_text": False, "approved_plan_path": "",
2709
2949
  "approved_plan_pending_text": False,
2710
2950
  "selected_stage": "auto",
2951
+ "handoff_mode": "", "handoff_stages": "",
2711
2952
  "executor": "", "critic": "", "critic_pending_text": False,
2712
2953
  "reuse_previous": None,
2713
2954
  "use_defaults": None, "workers_override": "",
@@ -2873,6 +3114,7 @@ def render_args(state: WizardState) -> dict[str, str]:
2873
3114
  "critic": state.critic,
2874
3115
  "approved-plan": state.approved_plan_path,
2875
3116
  "stage": stage,
3117
+ "stages": state.handoff_stages,
2876
3118
  "base-ref": base_ref,
2877
3119
  "workers": workers,
2878
3120
  "directive": state.directive,
@@ -2896,8 +3138,13 @@ def confirmation_block(state: WizardState) -> str:
2896
3138
  lines.append(f" brief : {state.brief_path or '(none)'}")
2897
3139
  if state.task_type == "final-verification":
2898
3140
  lines.append(" base-ref : (selected stage worktree)")
3141
+ elif state.task_type == "implementation" and state.reuse_worktree:
3142
+ lines.append(_msg(state.workspace_root, "confirmation",
3143
+ "base_ref_stage_isolated"))
2899
3144
  elif state.reuse_worktree:
2900
- lines.append(" base-ref : (reusing existing worktree)")
3145
+ lines.append(_msg(state.workspace_root, "confirmation",
3146
+ "base_ref_reuse_task_dir",
3147
+ task_key=f"{state.task_group}/{state.task_id}"))
2901
3148
  else:
2902
3149
  lines.append(f" base-ref : {state.base_ref}")
2903
3150
  if state.task_type == "implementation":
@@ -2934,6 +3181,16 @@ def confirmation_block(state: WizardState) -> str:
2934
3181
  lines.append(f" stage : {stage}")
2935
3182
  if state.clarification_response_path:
2936
3183
  lines.append(f" clarification : {state.clarification_response_path}")
3184
+ if state.task_type == "release-handoff" and state.handoff_mode:
3185
+ scope = (
3186
+ _msg(state.workspace_root, "confirmation",
3187
+ "handoff_scope_whole_task")
3188
+ if state.handoff_mode == "whole-task"
3189
+ else _msg(state.workspace_root, "confirmation",
3190
+ "handoff_scope_stage_group",
3191
+ stages=state.handoff_stages)
3192
+ )
3193
+ lines.append(f" handoff scope : {scope}")
2937
3194
  if state.task_type == "release-handoff" and state.pr_template_path:
2938
3195
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
2939
3196
  return "\n".join(lines)
@@ -195,6 +195,41 @@ def preview_worktree_decision(
195
195
  )
196
196
 
197
197
 
198
+ def preview_stage_worktree_decision(
199
+ *,
200
+ project_id: str,
201
+ task_group_segment: str,
202
+ task_id_segment: str,
203
+ work_category: str,
204
+ stage_number: int,
205
+ ) -> "WorktreeDecision":
206
+ """Side-effect-free: what provision_stage_worktree WOULD do (reuse vs new).
207
+
208
+ Mirrors provision_stage_worktree's registry-reuse branch exactly. A "new"
209
+ decision carries no base_ref — the stage base commit is resolved later by
210
+ the prepare flow from the stage's dependency anchors.
211
+ """
212
+ safe_project = _safe_segment(project_id)
213
+ safe_group = _safe_segment(task_group_segment)
214
+ safe_task = _safe_segment(task_id_segment)
215
+ existing = worktree_registry.lookup(
216
+ safe_project, safe_group, safe_task, stage_number=stage_number)
217
+ if existing is not None and existing.status == "active":
218
+ return WorktreeDecision(
219
+ status="reused", path=existing.worktree_path,
220
+ branch=existing.branch, base_ref=existing.base_ref,
221
+ )
222
+ return WorktreeDecision(
223
+ status="new",
224
+ path=str(compute_worktree_path(
225
+ project_id=safe_project, task_group_segment=safe_group,
226
+ task_id_segment=safe_task, stage_number=stage_number)),
227
+ branch=compute_branch_name(
228
+ work_category=work_category, task_id_segment=safe_task,
229
+ stage_number=stage_number),
230
+ )
231
+
232
+
198
233
  def _safe_segment(value: str) -> str:
199
234
  """Sanitise a single path/branch segment.
200
235
 
@@ -47,7 +47,7 @@ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag
47
47
  - `kind: "done"` → input collection finished; move to Step 5.
48
48
  - `kind: "aborted"` → the user picked 중단; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
49
49
 
50
- The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed. Its options always include `중단` (abort); `base-ref 다시 고르기` (edit) appears only when a new worktree would be created.
50
+ The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed. Its options always include `중단` (abort); `base-ref 다시 고르기` (edit) appears only when a new branch would be created from the user-chosen base-ref. For `implementation` runs the wizard previews the **stage worktree** this run will actually use (not the task-key directory); render its label verbatim.
51
51
 
52
52
  Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry as its own selectable `AskUserQuestion` choice, including entries that carry a `(default)` / `(recommended)` suffix. Do NOT collapse a multi-option pick into a "recommended + 직접 입력 / Other" shortlist: the wizard's `options[]` array IS the complete, authoritative choice set. Example: the `executor` step always emits `claude` / `codex` / `gemini` — show all three, never just `claude`. The run-prompt recommendation rule (1–2 추천 + 직접 입력) applies ONLY to prompts this skill authors itself (e.g. the conformance-waiver picker), never to wizard-provided `options[]`. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
53
53
 
@@ -121,9 +121,10 @@ That is the entire interactive flow. The wizard handles:
121
121
 
122
122
  - new-vs-existing task split (남은 작업 — `workStatus != done` — 최신순 3개 추천 + 직접 입력), task-group / task-id slug validation (각각 최근 후보 3개 추천 + 직접 입력),
123
123
  - task-type pick (추천 3개 — `nextRecommendedPhase` recommended / 현재 phase 재실행 / 라이프사이클 다음 단계 — + 직접 입력; 직접 입력은 후속 `text` 단계에서 전체 task-type 화이트리스트로 검증),
124
- - brief path after task-group selection (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing tasks),
124
+ - brief path **entry task-type(requirements-discovery / error-analysis / improvement-discovery)에서만 질문** (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing entry tasks). downstream task-type 은 manifest 의 brief 를 자동 carry-in 하고, 등록 brief 가 없으면 `brief_carry` 3-옵션(entry 전환 추천 / 직접 입력 / 중단)이 뜬다. `release-handoff` 는 brief 단계가 아예 없다 — prepare 가 검증 보고서 인용 input 문서를 생성한다,
125
125
  - base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
126
126
  - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick,
127
+ - `release-handoff`-only sub-flow: approved plan 자동 해소 후 `handoff_stage_pick` 멀티선택 — eligible stage 묶음(stage-group) 또는 전체 task(accepted whole-task 검증 보고서 존재 시) 선택; 결과는 render-args 의 `stages` 키(csv, whole-task 면 빈 값)로 나간다,
127
128
  - `Use defaults / Customize` branch with profile-aware worker/model questions,
128
129
  - `release-handoff` PR template override + persist scope,
129
130
  - final `Proceed / Edit` confirmation; on `Edit` the wizard asks which step to rewind to and clears every later answer.
@@ -166,6 +167,7 @@ okstra render-bundle \
166
167
  --critic "<args.critic>" \
167
168
  --approved-plan "<args.approved-plan>" \
168
169
  --stage "<args.stage>" \
170
+ --stages "<args.stages>" \
169
171
  --base-ref "<args.base-ref>" \
170
172
  --workers "<args.workers>" \
171
173
  --directive "<args.directive>" \
@@ -25,11 +25,15 @@ taskType: "{{FM_TASK_TYPE}}"
25
25
 
26
26
  ## Source Verification Report
27
27
 
28
- - Path (project-relative) to the `final-verification` final-report whose verdict authorises this handoff:
29
- - Verbatim quoted `Verdict Token` row from that report's `## 7. Final Verdict` table (MUST have value `accepted`):
30
- - Run timestamp of that final-verification run:
28
+ - Mode: `{{HANDOFF_MODE}}`
29
+ - Stages: `{{HANDOFF_STAGES}}`
30
+ - Reports (one row per cited `final-verification` final-report; every Verdict Token MUST be `accepted`):
31
31
 
32
- > If this section is empty or cites a `Verdict Token` value other than `accepted`, the lead MUST end the run immediately and route back to `final-verification`. Release-handoff never operates on `conditional-accept` or `blocked` outcomes.
32
+ | Stage | Report path (project-relative) | Verdict Token |
33
+ |---|---|---|
34
+ {{HANDOFF_SOURCE_REPORTS}}
35
+
36
+ > This section is generated by `okstra render-bundle` from the consumers ledger (`verified` rows) / the latest accepted whole-task report — the lead re-confirms each cited report's `Verdict Token` and MUST end the run immediately (route back to `final-verification`) if any token is not `accepted`. Release-handoff never operates on `conditional-accept` or `blocked` outcomes.
33
37
 
34
38
  ## Working-Tree Snapshot (filled at run start)
35
39
 
@@ -83,5 +87,5 @@ taskType: "{{FM_TASK_TYPE}}"
83
87
 
84
88
  ## Conversion Note
85
89
 
86
- - This input can be used as the release-handoff brief before creating `okstra-task-brief.md`.
87
- - Reuse the same `Task Group` and `Task ID` as the originating implementation / final-verification runs so the handoff stays attached to the same task history.
90
+ - This document is generated by `okstra render-bundle --task-type release-handoff` and serves as the run's input in place of a task brief (briefs belong to entry phases only).
91
+ - It reuses the same `Task Group` and `Task ID` as the originating implementation / final-verification runs so the handoff stays attached to the same task history.
@@ -18,7 +18,15 @@ Usage:
18
18
  [--gemini-model <m>] [--report-writer-model <m>] \\
19
19
  [--related-tasks <list>] [--base-ref <ref>] \\
20
20
  [--clarification-response <path>] [--work-category <cat>] \\
21
- [--pr-template-path <path>] # release-handoff only
21
+ [--stage <auto|N>] [--stages <csv>] [--pr-template-path <path>]
22
+
23
+ --stage implementation / final-verification only
24
+ --stages release-handoff only: PR stage bundle csv (empty = whole-task)
25
+ --pr-template-path release-handoff only
26
+
27
+ release-handoff takes NO --task-brief (briefs belong to entry phases) —
28
+ prepare generates the run's input document from the cited verification
29
+ reports instead.
22
30
 
23
31
  All flags pass through unchanged to \`python3 -m okstra_ctl.run\`. The
24
32
  shim auto-supplies \`--workspace-root\` (from \`okstra paths --field workspace\`)