okstra 0.69.0 → 0.71.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.
@@ -88,6 +88,13 @@ EXECUTORS = ["claude", "codex", "gemini"]
88
88
  # (or the whole task via `auto`). Both gate the approved-plan + stage steps.
89
89
  _STAGE_SCOPED_TASK_TYPES = ("implementation", "final-verification")
90
90
 
91
+ # brief 는 entry phase 의 입력물(비개발자/타 팀의 아이디어·에러 초안)이다.
92
+ # downstream phase 는 task manifest 의 taskBriefPath 를 자동 carry-in 하며
93
+ # 위저드에서 brief 를 묻지 않는다 (release-handoff 는 prepare 가 검증 보고서
94
+ # 인용 input 문서를 자동 생성하므로 brief 자체가 없다).
95
+ _BRIEF_ENTRY_TASK_TYPES = ("requirements-discovery", "improvement-discovery",
96
+ "error-analysis")
97
+
91
98
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
92
99
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
93
100
 
@@ -230,12 +237,14 @@ S_TASK_TYPE_TEXT = "task_type_text"
230
237
  S_BRIEF_KEEP = "brief_keep"
231
238
  S_BRIEF_PATH_PICK = "brief_path_pick"
232
239
  S_BRIEF_PATH = "brief_path"
240
+ S_BRIEF_CARRY = "brief_carry"
233
241
  S_BASE_REF_PICK = "base_ref_pick"
234
242
  S_BASE_REF_TEXT = "base_ref_text"
235
243
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
236
244
  S_APPROVED_PLAN = "approved_plan"
237
245
  S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
238
246
  S_STAGE_PICK = "stage_pick"
247
+ S_HANDOFF_STAGE_PICK = "handoff_stage_pick"
239
248
  S_EXECUTOR = "executor"
240
249
  S_CRITIC_PICK = "critic_pick"
241
250
  S_CRITIC_TEXT = "critic_text"
@@ -334,6 +343,11 @@ class WizardState:
334
343
  critic: str = ""
335
344
  critic_pending_text: bool = False
336
345
 
346
+ # release-handoff: PR 로 내보낼 범위. mode 는 stages 선택의 파생값이다 —
347
+ # whole-task = 빈 stages(whole-task 검증 기반 단일 PR), stage-group = csv.
348
+ handoff_mode: str = "" # "" | "whole-task" | "stage-group"
349
+ handoff_stages: str = "" # csv ("2,3"), whole-task 면 ""
350
+
337
351
  # resume: 직전 run-inputs 재사용 여부 (None=미응답, True=재사용, False=재입력)
338
352
  reuse_previous: Optional[bool] = None
339
353
 
@@ -767,6 +781,11 @@ def _base_ref_required(state: WizardState) -> bool:
767
781
  return state.task_type != "final-verification" and state.reuse_worktree is False
768
782
 
769
783
 
784
+ def _brief_resolved(state: WizardState) -> bool:
785
+ """brief 입력이 끝났는가. release-handoff 는 brief 가 없는 phase 라 항상 True."""
786
+ return bool(state.brief_path) or state.task_type == "release-handoff"
787
+
788
+
770
789
  def _base_ref_ready(state: WizardState) -> bool:
771
790
  return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
772
791
 
@@ -779,6 +798,54 @@ def _stage_auto_allowed(state: WizardState) -> bool:
779
798
  return state.task_type == "implementation"
780
799
 
781
800
 
801
+ def _parse_stage_objects(state: WizardState) -> list:
802
+ """승인 plan 의 Stage Map stage 객체 목록. validator 의 _parse_stage_map 재사용.
803
+ `_build_stage_pick` 과 `_whole_task_allowed` 가 공유한다."""
804
+ import importlib.util as _ilu
805
+ import sys as _sys
806
+ plan_text = Path(state.approved_plan_path).read_text(encoding="utf-8")
807
+ validator_path = (Path(state.workspace_root) / "validators"
808
+ / "validate-implementation-plan-stages.py")
809
+ spec = _ilu.spec_from_file_location("_ip_stage_v_wizard", str(validator_path))
810
+ if spec is None or spec.loader is None:
811
+ raise WizardError(f"cannot load stage validator at {validator_path}")
812
+ mod = _ilu.module_from_spec(spec)
813
+ _sys.modules["_ip_stage_v_wizard"] = mod
814
+ try:
815
+ spec.loader.exec_module(mod)
816
+ stages, _errs = mod._parse_stage_map(plan_text)
817
+ finally:
818
+ _sys.modules.pop("_ip_stage_v_wizard", None)
819
+ return stages
820
+
821
+
822
+ def _done_stage_numbers(state: WizardState) -> set:
823
+ """approved plan 을 소비한 implementation run 들의 consumers.jsonl 에서
824
+ done 처리된 stage 번호 집합. git 호출 없음 — 파일 읽기만(prepare 와 동일 SSOT)."""
825
+ if not state.approved_plan_path:
826
+ return set()
827
+ from .consumers import (read_consumers, backfill_done_from_carry,
828
+ latest_done_by_stage)
829
+ plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
830
+ backfill_done_from_carry(plan_run_root)
831
+ rows = read_consumers(plan_run_root)
832
+ return set(latest_done_by_stage(rows).keys())
833
+
834
+
835
+ def _whole_task_allowed(state: WizardState) -> bool:
836
+ """final-verification 이고 Stage Map 의 모든 stage 가 done 일 때만 True.
837
+ 위저드는 done 만 본다 — 머지/clean/active 는 prepare 게이트가 강제한다."""
838
+ if state.task_type != "final-verification":
839
+ return False
840
+ if not state.approved_plan_path:
841
+ return False
842
+ stages = _parse_stage_objects(state)
843
+ if not stages:
844
+ return False
845
+ done = _done_stage_numbers(state)
846
+ return all(s.stage_number in done for s in stages)
847
+
848
+
782
849
  def _existing_task_brief(project_root: Path, task_key: str) -> str:
783
850
  """Read taskBriefPath from manifest for an existing task. Empty if none."""
784
851
  root = find_task_root(project_root, task_key)
@@ -1167,6 +1234,23 @@ def _build_task_type(state: WizardState) -> Prompt:
1167
1234
  echo_template=t["echo_template"])
1168
1235
 
1169
1236
 
1237
+ def _carry_in_existing_brief(state: WizardState) -> str:
1238
+ """downstream task-type 이 등록된 brief 를 자동 carry-in 한다. 주입한 경로(또는 '')."""
1239
+ if state.task_type in _BRIEF_ENTRY_TASK_TYPES:
1240
+ return ""
1241
+ if state.task_type == "release-handoff":
1242
+ return "" # prepare 가 검증 보고서 인용 input 문서를 자동 생성한다
1243
+ if state.brief_path or not state.existing_brief_path:
1244
+ return ""
1245
+ p = Path(state.existing_brief_path)
1246
+ if not p.is_absolute():
1247
+ p = Path(state.project_root) / p
1248
+ if not p.is_file():
1249
+ return "" # manifest 경로가 깨졌으면 brief_carry 단계가 처리한다
1250
+ state.brief_path = state.existing_brief_path
1251
+ return state.brief_path
1252
+
1253
+
1170
1254
  def _apply_task_type(state: WizardState, value: str) -> str:
1171
1255
  if value not in TASK_TYPE_VALUES:
1172
1256
  raise WizardError(
@@ -1174,6 +1258,11 @@ def _apply_task_type(state: WizardState, value: str) -> str:
1174
1258
  f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
1175
1259
  )
1176
1260
  state.task_type = value
1261
+ # brief_carry 의 "entry phase 로 전환" 이 task-type 을 리셋한 뒤 submit() 이
1262
+ # brief_carry 를 answered 로 되돌려 놓는다 — task-type 을 다시 고르는 시점에
1263
+ # 퍼지해야 downstream 재선택 시 carry 단계가 다시 나온다.
1264
+ state.answered = [a for a in state.answered if a != S_BRIEF_CARRY]
1265
+ carried = _carry_in_existing_brief(state)
1177
1266
  state.profile_workers = _load_profile_workers(
1178
1267
  Path(state.workspace_root), value
1179
1268
  )
@@ -1183,6 +1272,8 @@ def _apply_task_type(state: WizardState, value: str) -> str:
1183
1272
  # Reuse-worktree is decided once identity is final. Recompute here so
1184
1273
  # subsequent base-ref step knows whether to apply.
1185
1274
  state.reuse_worktree = _resolve_reuse_worktree(state)
1275
+ if carried:
1276
+ return f"task-type: {value}\nbrief (carry-in): {carried}"
1186
1277
  return f"task-type: {value}"
1187
1278
 
1188
1279
 
@@ -1309,6 +1400,37 @@ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
1309
1400
  return f"brief: {p}"
1310
1401
 
1311
1402
 
1403
+ BRIEF_CARRY_SWITCH_ENTRY = "__switch_entry__"
1404
+ BRIEF_CARRY_ABORT = "__abort__"
1405
+
1406
+
1407
+ def _build_brief_carry(state: WizardState) -> Prompt:
1408
+ t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
1409
+ return Prompt(
1410
+ step=S_BRIEF_CARRY, kind="pick",
1411
+ label=t["label"],
1412
+ options=[_opt(k, v) for k, v in t["options"].items()],
1413
+ echo_template=t["echo_template"],
1414
+ )
1415
+
1416
+
1417
+ def _submit_brief_carry(state: WizardState, value: str) -> Optional[str]:
1418
+ t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
1419
+ if value == BRIEF_CARRY_SWITCH_ENTRY:
1420
+ _reset_from(state, S_TASK_TYPE)
1421
+ return t["echo_variants"]["switch_entry"]
1422
+ if value == PICK_TYPE_CUSTOM:
1423
+ state.brief_path_pending_text = True
1424
+ return None
1425
+ if value == BRIEF_CARRY_ABORT:
1426
+ state.aborted = True
1427
+ return t["echo_variants"]["abort"]
1428
+ raise WizardError(
1429
+ f"expected '{BRIEF_CARRY_SWITCH_ENTRY}', {PICK_TYPE_CUSTOM!r}, "
1430
+ f"or '{BRIEF_CARRY_ABORT}', got: {value!r}"
1431
+ )
1432
+
1433
+
1312
1434
  def _build_base_ref_pick(state: WizardState) -> Prompt:
1313
1435
  t = _p(state.workspace_root, "base_ref_pick")
1314
1436
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -1358,6 +1480,7 @@ _REUSE_LAST_TOKEN = "__reuse_last__"
1358
1480
  _SIBLINGS_TOKEN = "__siblings__"
1359
1481
  _LATEST_REPORT_TOKEN = "__latest_report__"
1360
1482
  _PROJECT_DEFAULT_TOKEN = "__project_default__"
1483
+ WHOLE_TASK_STAGE = "__whole_task__"
1361
1484
 
1362
1485
 
1363
1486
  def _list_implementation_planning_reports(
@@ -1493,37 +1616,31 @@ def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str
1493
1616
 
1494
1617
  def _build_stage_pick(state: WizardState) -> Prompt:
1495
1618
  """Parse the Stage Map from the approved plan and build the stage picker."""
1496
- import importlib.util as _ilu
1497
- import sys as _sys
1498
1619
  t = _p(state.workspace_root, "stage_pick")
1499
- plan_text = Path(state.approved_plan_path).read_text(encoding="utf-8")
1500
- validator_path = (
1501
- Path(state.workspace_root) / "validators"
1502
- / "validate-implementation-plan-stages.py"
1503
- )
1504
- spec = _ilu.spec_from_file_location("_ip_stage_v_wizard", str(validator_path))
1505
- if spec is None or spec.loader is None:
1506
- raise WizardError(f"cannot load stage validator at {validator_path}")
1507
- mod = _ilu.module_from_spec(spec)
1508
- _sys.modules["_ip_stage_v_wizard"] = mod
1509
- try:
1510
- spec.loader.exec_module(mod)
1511
- stages, _errs = mod._parse_stage_map(plan_text)
1512
- finally:
1513
- _sys.modules.pop("_ip_stage_v_wizard", None)
1620
+ stages = _parse_stage_objects(state)
1621
+ is_fv = state.task_type == "final-verification"
1514
1622
  label = (
1515
1623
  t.get("label_final_verification", t["label"])
1516
- if state.task_type == "final-verification"
1517
- else t["label"]
1624
+ if is_fv else t["label"]
1518
1625
  )
1626
+ done = _done_stage_numbers(state) if is_fv else set()
1519
1627
  options = []
1520
1628
  if _stage_auto_allowed(state):
1521
1629
  options.append(_opt("auto", t["options"]["auto"]))
1630
+ if _whole_task_allowed(state):
1631
+ options.append(_opt(WHOLE_TASK_STAGE, t["options"]["whole_task"]))
1522
1632
  for s in stages:
1523
1633
  depends = ",".join(map(str, s.depends_on)) or "(none)"
1634
+ suffix = ""
1635
+ if is_fv:
1636
+ mark = (t["options"]["done_mark"]
1637
+ if s.stage_number in done
1638
+ else t["options"]["undone_mark"])
1639
+ suffix = f" {mark}"
1524
1640
  options.append(_opt(
1525
1641
  str(s.stage_number),
1526
- f"{s.stage_number}: {s.title} [depends-on: {depends} | steps: {s.step_count}]",
1642
+ f"{s.stage_number}: {s.title} "
1643
+ f"[depends-on: {depends} | steps: {s.step_count}]{suffix}",
1527
1644
  ))
1528
1645
  return Prompt(
1529
1646
  step=S_STAGE_PICK, kind="pick",
@@ -1541,17 +1658,123 @@ def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1541
1658
  raise WizardError(
1542
1659
  "final-verification requires an explicit stage number"
1543
1660
  )
1661
+ elif answer == WHOLE_TASK_STAGE:
1662
+ if not _whole_task_allowed(state):
1663
+ raise WizardError(
1664
+ "whole-task verification requires final-verification "
1665
+ "with all stages done"
1666
+ )
1544
1667
  else:
1545
1668
  try:
1546
1669
  int(answer)
1547
1670
  except ValueError:
1548
1671
  raise WizardError(
1549
- f"answer must be 'auto' or a stage number, got {answer!r}"
1672
+ f"answer must be 'auto', whole-task, or a stage number, "
1673
+ f"got {answer!r}"
1550
1674
  )
1551
1675
  state.selected_stage = answer
1552
1676
  return f"stage: {answer}"
1553
1677
 
1554
1678
 
1679
+ def _handoff_msgs(state: WizardState) -> dict:
1680
+ """handoff_stage_pick 의 JSON 텍스트 묶음 (label 미사용 조회용)."""
1681
+ return _p(state.workspace_root, "handoff_stage_pick", blocked="")
1682
+
1683
+
1684
+ def _resolve_handoff_plan(state: WizardState) -> Path:
1685
+ """release-handoff 의 approved plan 을 질문 없이 자동 해소한다.
1686
+
1687
+ plan 미존재/미승인은 사용자가 고칠 대상이 아니라 라이프사이클 선행 단계
1688
+ 누락이므로 picker 대신 안내 메시지로 즉시 실패한다."""
1689
+ if state.approved_plan_path:
1690
+ return Path(state.approved_plan_path)
1691
+ t = _handoff_msgs(state)
1692
+ latest = _latest_implementation_planning_report(state)
1693
+ if latest is None:
1694
+ raise WizardError(t["errors"]["no_plan"])
1695
+ p, fully_approved = _classify_approved_plan(
1696
+ str(latest), Path(state.project_root))
1697
+ if not fully_approved:
1698
+ raise WizardError(t["errors"]["plan_not_approved"].format(plan=p))
1699
+ state.approved_plan_path = str(p)
1700
+ return p
1701
+
1702
+
1703
+ def _handoff_eligibility(state: WizardState) -> list:
1704
+ """stage 별 PR 자격 — okstra_ctl.handoff 의 SSOT 판정을 그대로 재사용한다."""
1705
+ from okstra_ctl.handoff import compute_eligibility
1706
+ from okstra_ctl.run import _parse_stage_map_into_ctx
1707
+ from okstra_ctl.consumers import read_consumers
1708
+ plan = _resolve_handoff_plan(state)
1709
+ stage_map = _parse_stage_map_into_ctx(str(plan))
1710
+ if not stage_map:
1711
+ raise WizardError(
1712
+ _handoff_msgs(state)["errors"]["no_stage_map"].format(plan=plan))
1713
+ rows = read_consumers(plan.resolve().parents[1])
1714
+ return compute_eligibility(stage_map, rows)
1715
+
1716
+
1717
+ def _latest_whole_task_fv_accepted(state: WizardState) -> str:
1718
+ """accepted whole-task final-verification 보고서 경로 — handoff 모듈 SSOT 위임."""
1719
+ from okstra_ctl.handoff import latest_whole_task_fv_accepted
1720
+ return latest_whole_task_fv_accepted(
1721
+ state.project_root, state.project_id, state.task_group, state.task_id)
1722
+
1723
+
1724
+ def _build_handoff_stage_pick(state: WizardState) -> Prompt:
1725
+ elig = _handoff_eligibility(state)
1726
+ eligible = [e for e in elig if e["eligible"]]
1727
+ blocked = [e for e in elig if not e["eligible"]]
1728
+ whole_task_report = _latest_whole_task_fv_accepted(state)
1729
+ msgs = _handoff_msgs(state)
1730
+ blocked_summary = ("; ".join(
1731
+ f"stage {e['stage']} ({', '.join(e['reasons'])})" for e in blocked)
1732
+ or msgs["labels"]["blocked_none"])
1733
+ if not eligible and not whole_task_report:
1734
+ raise WizardError(
1735
+ msgs["errors"]["nothing_eligible"].format(blocked=blocked_summary))
1736
+ t = _p(state.workspace_root, "handoff_stage_pick", blocked=blocked_summary)
1737
+ options: list[Option] = []
1738
+ if whole_task_report:
1739
+ options.append(_opt(WHOLE_TASK_STAGE, t["labels"]["whole_task"]))
1740
+ stage_label = t["labels"]["stage"]
1741
+ for e in eligible:
1742
+ deps = ", ".join(str(d) for d in e["depends_on"]) or "-"
1743
+ options.append(_opt(str(e["stage"]),
1744
+ stage_label.format(stage=e["stage"], deps=deps)))
1745
+ return Prompt(
1746
+ step=S_HANDOFF_STAGE_PICK, kind="pick", multi=True,
1747
+ label=t["label"], options=options,
1748
+ echo_template=t["echo_template"],
1749
+ )
1750
+
1751
+
1752
+ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
1753
+ t = _handoff_msgs(state)
1754
+ picks = [v.strip() for v in (value or "").split(",") if v.strip()]
1755
+ if not picks:
1756
+ raise WizardError(t["errors"]["none_selected"])
1757
+ if WHOLE_TASK_STAGE in picks:
1758
+ if len(picks) > 1:
1759
+ raise WizardError(t["errors"]["whole_task_exclusive"])
1760
+ if not _latest_whole_task_fv_accepted(state):
1761
+ raise WizardError(t["errors"]["whole_task_missing"])
1762
+ state.handoff_mode = "whole-task"
1763
+ state.handoff_stages = ""
1764
+ return t["echo_variants"]["whole_task"]
1765
+ eligible = {str(e["stage"]) for e in _handoff_eligibility(state)
1766
+ if e["eligible"]}
1767
+ bad = [p for p in picks if p not in eligible]
1768
+ if bad:
1769
+ raise WizardError(t["errors"]["not_eligible"].format(
1770
+ bad=", ".join(bad), eligible=", ".join(sorted(eligible))))
1771
+ nums = sorted({int(p) for p in picks})
1772
+ state.handoff_mode = "stage-group"
1773
+ state.handoff_stages = ",".join(str(n) for n in nums)
1774
+ return t["echo_variants"]["stage_group"].format(
1775
+ stages=state.handoff_stages)
1776
+
1777
+
1555
1778
  def _suggest_latest_final_report(state: WizardState) -> str:
1556
1779
  """task 의 모든 phase runs 디렉토리에서 가장 최근 final-report-*.md 의 relpath 를 반환.
1557
1780
 
@@ -2314,16 +2537,40 @@ STEPS: list[Step] = [
2314
2537
  and s.task_group_pending_text),
2315
2538
  build=_build_task_group_text, submit=_submit_task_group_text,
2316
2539
  owns=("task_group", "task_group_pending_text")),
2540
+ # 신규 task 흐름 순서: task-group → task-type → (entry 면 brief) → task-id.
2541
+ # brief 는 entry phase 전용 입력이므로 task-type 이 정해진 뒤에만 물을 수 있다.
2542
+ Step(S_TASK_TYPE,
2543
+ applies=lambda s: (s.is_new_task is not None
2544
+ and (s.is_new_task is False or bool(s.task_group))
2545
+ and S_TASK_TYPE not in s.answered),
2546
+ build=_build_task_type, submit=_submit_task_type,
2547
+ owns=("task_type", "profile_workers", "profile_optional_workers",
2548
+ "reuse_worktree")),
2549
+ Step(S_TASK_TYPE_TEXT,
2550
+ applies=lambda s: (S_TASK_TYPE in s.answered
2551
+ and not s.task_type
2552
+ and S_TASK_TYPE_TEXT not in s.answered),
2553
+ build=_build_task_type_text, submit=_submit_task_type_text,
2554
+ owns=("task_type", "profile_workers", "profile_optional_workers",
2555
+ "reuse_worktree")),
2556
+ Step(S_BRIEF_KEEP,
2557
+ applies=lambda s: (not s.is_new_task
2558
+ and s.task_type in _BRIEF_ENTRY_TASK_TYPES
2559
+ and bool(s.existing_brief_path)
2560
+ and s.keep_existing_brief is None
2561
+ and S_TASK_TYPE in s.answered),
2562
+ build=_build_brief_keep, submit=_submit_brief_keep,
2563
+ owns=("keep_existing_brief",)),
2317
2564
  Step(S_BRIEF_PATH_PICK,
2318
2565
  applies=lambda s: (
2319
2566
  not s.brief_path
2320
2567
  and not s.brief_path_pending_text
2321
2568
  and S_BRIEF_PATH_PICK not in s.answered
2569
+ and S_TASK_TYPE in s.answered
2570
+ and s.task_type in _BRIEF_ENTRY_TASK_TYPES
2322
2571
  and (
2323
- (s.is_new_task is True and bool(s.task_group))
2572
+ s.is_new_task is True
2324
2573
  or (s.is_new_task is False
2325
- and S_TASK_TYPE in s.answered
2326
- and bool(s.task_type)
2327
2574
  and (s.keep_existing_brief is False
2328
2575
  or not s.existing_brief_path))
2329
2576
  )
@@ -2335,49 +2582,39 @@ STEPS: list[Step] = [
2335
2582
  and S_BRIEF_PATH not in s.answered,
2336
2583
  build=_build_brief_path, submit=_submit_brief_path,
2337
2584
  owns=("brief_path", "task_group_suggestion", "task_id_suggestion")),
2585
+ # downstream task-type 인데 carry-in 할 brief 가 없을 때의 fallback.
2586
+ # release-handoff 는 brief 자체가 없는 phase 라 대상에서 빠진다.
2587
+ Step(S_BRIEF_CARRY,
2588
+ applies=lambda s: (S_TASK_TYPE in s.answered
2589
+ and bool(s.task_type)
2590
+ and s.task_type not in _BRIEF_ENTRY_TASK_TYPES
2591
+ and s.task_type != "release-handoff"
2592
+ and not s.brief_path
2593
+ and not s.brief_path_pending_text),
2594
+ build=_build_brief_carry, submit=_submit_brief_carry,
2595
+ owns=("brief_path_pending_text",)),
2338
2596
  Step(S_TASK_ID,
2339
2597
  applies=lambda s: (bool(s.is_new_task)
2340
- and bool(s.brief_path)
2341
2598
  and bool(s.task_group)
2599
+ and _brief_resolved(s)
2342
2600
  and not s.task_id
2343
2601
  and not s.task_id_pending_text),
2344
2602
  build=_build_task_id, submit=_submit_task_id,
2345
2603
  owns=("task_id", "task_id_pending_text")),
2346
2604
  Step(S_TASK_ID_TEXT,
2347
2605
  applies=lambda s: (bool(s.is_new_task)
2348
- and bool(s.brief_path)
2349
2606
  and bool(s.task_group)
2607
+ and _brief_resolved(s)
2350
2608
  and not s.task_id
2351
2609
  and s.task_id_pending_text),
2352
2610
  build=_build_task_id_text, submit=_submit_task_id_text,
2353
2611
  owns=("task_id", "task_id_pending_text")),
2354
- Step(S_TASK_TYPE,
2355
- applies=lambda s: (s.is_new_task is not None
2356
- and (s.is_new_task is False or bool(s.task_id))
2357
- and S_TASK_TYPE not in s.answered),
2358
- build=_build_task_type, submit=_submit_task_type,
2359
- owns=("task_type", "profile_workers", "profile_optional_workers",
2360
- "reuse_worktree")),
2361
- Step(S_TASK_TYPE_TEXT,
2362
- applies=lambda s: (S_TASK_TYPE in s.answered
2363
- and not s.task_type
2364
- and S_TASK_TYPE_TEXT not in s.answered),
2365
- build=_build_task_type_text, submit=_submit_task_type_text,
2366
- owns=("task_type", "profile_workers", "profile_optional_workers",
2367
- "reuse_worktree")),
2368
- Step(S_BRIEF_KEEP,
2369
- applies=lambda s: (not s.is_new_task
2370
- and bool(s.existing_brief_path)
2371
- and s.keep_existing_brief is None
2372
- and S_TASK_TYPE in s.answered
2373
- and bool(s.task_type)),
2374
- build=_build_brief_keep, submit=_submit_brief_keep,
2375
- owns=("keep_existing_brief",)),
2376
2612
  Step(S_BASE_REF_PICK,
2377
2613
  applies=lambda s: (S_TASK_TYPE in s.answered
2378
2614
  and _base_ref_required(s)
2379
2615
  and S_BASE_REF_PICK not in s.answered
2380
- and bool(s.brief_path)),
2616
+ and _brief_resolved(s)
2617
+ and (not s.is_new_task or bool(s.task_id))),
2381
2618
  build=_build_base_ref_pick, submit=_submit_base_ref_pick,
2382
2619
  owns=("base_ref", "base_ref_pending_text")),
2383
2620
  Step(S_BASE_REF_TEXT,
@@ -2418,6 +2655,13 @@ STEPS: list[Step] = [
2418
2655
  and S_STAGE_PICK not in s.answered),
2419
2656
  build=_build_stage_pick, submit=_submit_stage_pick,
2420
2657
  owns=("selected_stage",)),
2658
+ Step(S_HANDOFF_STAGE_PICK,
2659
+ applies=lambda s: (s.task_type == "release-handoff"
2660
+ and bool(s.task_group)
2661
+ and bool(s.task_id)
2662
+ and S_HANDOFF_STAGE_PICK not in s.answered),
2663
+ build=_build_handoff_stage_pick, submit=_submit_handoff_stage_pick,
2664
+ owns=("handoff_mode", "handoff_stages", "approved_plan_path")),
2421
2665
  Step(S_EXECUTOR,
2422
2666
  applies=lambda s: (s.task_type == "implementation"
2423
2667
  and bool(s.approved_plan_path)
@@ -2594,7 +2838,8 @@ def _identity_ready(s: WizardState) -> bool:
2594
2838
  """All identity questions (task pick → executor for impl) answered."""
2595
2839
  if not s.task_type:
2596
2840
  return False
2597
- if not s.brief_path:
2841
+ # release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
2842
+ if not s.brief_path and s.task_type != "release-handoff":
2598
2843
  return False
2599
2844
  if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
2600
2845
  return False
@@ -2606,6 +2851,9 @@ def _identity_ready(s: WizardState) -> bool:
2606
2851
  if s.task_type == "implementation":
2607
2852
  if not s.executor:
2608
2853
  return False
2854
+ if (s.task_type == "release-handoff"
2855
+ and S_HANDOFF_STAGE_PICK not in s.answered):
2856
+ return False
2609
2857
  return True
2610
2858
 
2611
2859
 
@@ -2658,6 +2906,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
2658
2906
  "base_ref_pending_text": False, "approved_plan_path": "",
2659
2907
  "approved_plan_pending_text": False,
2660
2908
  "selected_stage": "auto",
2909
+ "handoff_mode": "", "handoff_stages": "",
2661
2910
  "executor": "", "critic": "", "critic_pending_text": False,
2662
2911
  "reuse_previous": None,
2663
2912
  "use_defaults": None, "workers_override": "",
@@ -2797,11 +3046,14 @@ def render_args(state: WizardState) -> dict[str, str]:
2797
3046
  if state.task_type == "implementation":
2798
3047
  stage = state.selected_stage or "auto"
2799
3048
  elif state.task_type == "final-verification":
2800
- if not state.selected_stage or state.selected_stage == "auto":
3049
+ if state.selected_stage == WHOLE_TASK_STAGE:
3050
+ stage = "" # prepare 가 빈 stage 를 whole-task 로 해석
3051
+ elif not state.selected_stage or state.selected_stage == "auto":
2801
3052
  raise WizardError(
2802
3053
  "final-verification requires an explicit stage number"
2803
3054
  )
2804
- stage = state.selected_stage
3055
+ else:
3056
+ stage = state.selected_stage
2805
3057
  else:
2806
3058
  stage = ""
2807
3059
  pr_template = (
@@ -2820,6 +3072,7 @@ def render_args(state: WizardState) -> dict[str, str]:
2820
3072
  "critic": state.critic,
2821
3073
  "approved-plan": state.approved_plan_path,
2822
3074
  "stage": stage,
3075
+ "stages": state.handoff_stages,
2823
3076
  "base-ref": base_ref,
2824
3077
  "workers": workers,
2825
3078
  "directive": state.directive,
@@ -2872,12 +3125,25 @@ def confirmation_block(state: WizardState) -> str:
2872
3125
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
2873
3126
  lines.append(f" approved-plan : {state.approved_plan_path}")
2874
3127
  stage = (
2875
- state.selected_stage
2876
- or ("auto" if state.task_type == "implementation" else "(not selected)")
3128
+ _msg(state.workspace_root, "confirmation", "stage_whole_task")
3129
+ if state.selected_stage == WHOLE_TASK_STAGE
3130
+ else (state.selected_stage
3131
+ or ("auto" if state.task_type == "implementation"
3132
+ else "(not selected)"))
2877
3133
  )
2878
3134
  lines.append(f" stage : {stage}")
2879
3135
  if state.clarification_response_path:
2880
3136
  lines.append(f" clarification : {state.clarification_response_path}")
3137
+ if state.task_type == "release-handoff" and state.handoff_mode:
3138
+ scope = (
3139
+ _msg(state.workspace_root, "confirmation",
3140
+ "handoff_scope_whole_task")
3141
+ if state.handoff_mode == "whole-task"
3142
+ else _msg(state.workspace_root, "confirmation",
3143
+ "handoff_scope_stage_group",
3144
+ stages=state.handoff_stages)
3145
+ )
3146
+ lines.append(f" handoff scope : {scope}")
2881
3147
  if state.task_type == "release-handoff" and state.pr_template_path:
2882
3148
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
2883
3149
  return "\n".join(lines)
@@ -572,7 +572,7 @@ Promoted blockers enter `## 5.8 Acceptance Blockers`; since `accepted` requires
572
572
 
573
573
  ### State
574
574
 
575
- Critic output lives at `runs/final-verification/worker-results/<provider>-critic-final-verification-<seq>.md`. The convergence state `config.critic` summary (see §"Coverage critic pass") records `mode: "acceptance-devils-advocate"`, `candidatesProposed`, `confirmedBlockers`, `downgradedToResidual` (optional v1.2 fields; readers treat absence as null).
575
+ Critic output lives in the run's `worker-results/` directory (`runs/final-verification/worker-results/` for whole-task verification, `runs/final-verification/stage-<N>/worker-results/` for single-stage), filename `<provider>-critic-final-verification-<seq>.md`. The convergence state `config.critic` summary (see §"Coverage critic pass") records `mode: "acceptance-devils-advocate"`, `candidatesProposed`, `confirmedBlockers`, `downgradedToResidual` (optional v1.2 fields; readers treat absence as null).
576
576
 
577
577
  ## Output
578
578
 
@@ -299,7 +299,7 @@ B. **`task-manifest.json` (direct):** if catalog missing, slugify task-group / t
299
299
 
300
300
  C. **`timeline.json` (specific run):** for a specific date or run, read `.okstra/tasks/<group-segment>/<id-segment>/history/timeline.json`, filter `runs[]` by `runTimestamp` / `status` / `taskType`, use `runs[].reportPath`.
301
301
 
302
- D. **Specific task-type (fallback):** `latestReportPath` is task-type-agnostic. For a specific task-type's latest report, look under `.okstra/tasks/<group-segment>/<id-segment>/runs/<task-type-segment>/reports/`, filename pattern `final-report-<task-type-segment>-<NNN>.md` per `scripts/okstra_ctl/sequence.py:31`. Highest seq is latest. Cross-verify with `timeline.json`'s `runs[].taskType` filter.
302
+ D. **Specific task-type (fallback):** `latestReportPath` is task-type-agnostic. For a specific task-type's latest report, look under `.okstra/tasks/<group-segment>/<id-segment>/runs/<task-type-segment>/reports/`, filename pattern `final-report-<task-type-segment>-<NNN>.md` per `scripts/okstra_ctl/sequence.py:31`. Highest seq is latest. Stage-isolated runs (`implementation`, single-stage `final-verification`) keep their reports one level deeper at `runs/<task-type-segment>/stage-<N>/reports/` — scan those subdirectories too; seq is independent per stage. Cross-verify with `timeline.json`'s `runs[].taskType` filter.
303
303
 
304
304
  ### report.2 — Confirm existence
305
305
 
@@ -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\`)