okstra 0.70.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.
@@ -373,7 +373,9 @@ okstra phase 는 PRD / issue file 을 직접 쓰지 않습니다. 동등한 결
373
373
  - **whole-task (기본)**: task 전체를 1개 PR 로 내보냅니다. 기존 동작입니다.
374
374
  - **stage-group**: 단독-stage final-verification 에서 `accepted` 를 받은 stage 들 중 일부를 골라, 수집(collector) 브랜치로 묶어 1개 PR 로 내보냅니다. task 전체가 끝나기를 기다리지 않고 검증 완료된 stage 묶음 단위로 PR 을 낼 수 있습니다.
375
375
 
376
- stage-group상호작용 순서: **G1 base 선택 G2 stage multi-select assemble(수집 브랜치 생성 + 선택 stage 머지) 충돌 프로브 PR 초안 push/PR**.
376
+ 진입은 brief 없이 **task-id 기반**입니다 — brief 는 entry phase 입력물이고, release-handoff prepare approved plan 의 Stage Map + `consumers.jsonl` 로 자격을 판정한 뒤 검증 보고서를 인용하는 input 문서(`<task_root>/release-handoff-input.md`)를 자동 생성합니다. stage 선택은 okstra-run wizard 의 `handoff_stage_pick` 멀티선택(eligible stage 묶음 / accepted whole-task 보고서가 있으면 전체 task) 또는 CLI `--stages <csv>` 들어오고, run-context 의 `HANDOFF_MODE` / `HANDOFF_STAGES` 로 lead 에 노출됩니다.
377
+
378
+ stage-group 의 상호작용 순서: **G1 base 선택 → G2 stage 확인(선택은 prepare 전에 끝남 — 재질문 없음) → assemble(수집 브랜치 생성 + 선택 stage 머지) → 충돌 프로브 → PR 초안 → push/PR**.
377
379
 
378
380
  - 자격 판정의 SSOT 는 `consumers.jsonl` 의 두 행입니다 — `verified`(어떤 stage 가 단독-stage final-verification 에서 accepted 됨), `pr`(어떤 stage 들이 어느 PR 로 나갔는지). `verified` 인데 아직 `pr` 에 안 들어간 stage 가 자격 후보입니다.
379
381
  - worktree registry 는 stage-group 점유를 `<task-key>#group-<id>` 키로 예약하고, 수집 브랜치 이름은 `<work-category-prefix>-<task-id-segment>-g2-3` (예: 선택한 stage 가 2·3 이면 `-g2-3`) 형태입니다.
@@ -680,6 +682,8 @@ canonical metadata는 항상 `task-manifest.json`을 기준으로 확인합니
680
682
 
681
683
  `okstra`는 brief-first 구조입니다. brief 는 외부 입력과 okstra 보강을 보존하는 정본 source material 이며, 워커가 필요로 할 추가 자료(보고서, 코드 스니펫, 로그 등)는 brief 내부의 `Evidence and Source Materials` 섹션에 inline 또는 path 로 모두 포함시킵니다.
682
684
 
685
+ brief 의 **입력 시점은 entry phase 전용**입니다 — 사용자가 brief 경로를 직접 대는 것은 `requirements-discovery` / `error-analysis` / `improvement-discovery` 뿐이고, downstream phase(implementation-planning / implementation / final-verification)는 task manifest 의 `taskBriefPath` 를 자동 carry-in 합니다 (okstra-run wizard 가 묻지 않음; 미등록 시 entry 전환을 추천하는 fallback picker). `release-handoff` 는 brief 자체가 없으며 prepare 가 검증 보고서 인용 input 문서를 생성합니다.
686
+
683
687
  brief 는 **translation layer** 입니다 — 외부 입력 (이슈 트래커 ticket, 요구사항 문서, 사용자 메시지) 을 okstra-readable 형식으로 옮기되, 원문은 verbatim 으로 보존하고 okstra 가 추가한 부분은 labelled augmentation 으로 명확히 구분합니다. `okstra-brief` skill 의 산출이 source SSOT 이고, `prepare_task_bundle()` 은 분석 phase 마다 여기서 필요한 frontmatter, task-specific brief 섹션, reference expectations, carry-in clarification, directive 를 추출해 `instruction-set/analysis-packet.md` 를 만듭니다. 분석 워커의 1차 입력은 이 compact packet 이며, 원본 brief 와 profile/material 파일은 근거 확인이나 누락 보완이 필요할 때만 여는 fallback evidence 입니다.
684
688
 
685
689
  brief에는 보통 아래를 포함합니다.
package/docs/kr/cli.md CHANGED
@@ -132,6 +132,12 @@ interactive terminal에서 실행하면 다음 규칙이 추가로 적용됩니
132
132
  분석의 기준이 되는 task brief 문서 경로입니다.
133
133
  상대 경로는 대상 프로젝트 루트를 기준으로 해석됩니다.
134
134
 
135
+ `release-handoff` 에는 적용되지 않습니다 — brief 는 entry phase
136
+ (requirements-discovery / error-analysis / improvement-discovery)의 입력물이고,
137
+ release-handoff 는 prepare 가 검증 보고서를 인용하는 input 문서
138
+ (`<task_root>/release-handoff-input.md`)를 자동 생성해 brief 자리를 채웁니다.
139
+ release-handoff 에 비어 있지 않은 `--task-brief` 를 주면 즉시 거부됩니다.
140
+
135
141
  예:
136
142
 
137
143
  - `.project-docs/linear/feature/8858/okstra-task-brief.md`
@@ -593,9 +599,9 @@ chmod +x ~/.local/bin/okstra-ctl
593
599
  | `okstra task-show <task-key> [--project-root <path>]` | task-manifest.json 의 workflow / phase / status 요약 |
594
600
  | `okstra worktree-lookup <task-key>` | `worktree_registry.lookup` 결과 (예약된 path / branch / base ref / 현재 상태) |
595
601
  | `okstra plan-validate <plan-path>` | `_validate_approved_plan` — frontmatter `approved` 인식 결과와 Blocks=approval 미해결 행 진단 |
596
- | `okstra render-bundle <args…> [--stage <auto\|N>]` | `prepare_task_bundle(render_only=True)` 의 thin shim — `python3 -m okstra_ctl.run --render-only` 와 동일 시그니처. `--stage` 는 `implementation` / `final-verification` 전용: 실행(검증)할 Stage Map 항목 지정. `implementation` 은 `auto` (기본값) = 의존성이 만족된 가장 빠른 미완료 stage, `<N>` = 강제 지정. `final-verification` 은 `<N>` = 해당 stage 단독 검증(산출물이 `runs/final-verification/stage-<N>/` 에 격리되고 팀 이름에 `-fv-s<N>` 접미사), 빈 값 = 전체-task 검증(평면 구조 유지) |
602
+ | `okstra render-bundle <args…> [--stage <auto\|N>] [--stages <csv>]` | `prepare_task_bundle(render_only=True)` 의 thin shim — `python3 -m okstra_ctl.run --render-only` 와 동일 시그니처. `--stage` 는 `implementation` / `final-verification` 전용: 실행(검증)할 Stage Map 항목 지정. `implementation` 은 `auto` (기본값) = 의존성이 만족된 가장 빠른 미완료 stage, `<N>` = 강제 지정. `final-verification` 은 `<N>` = 해당 stage 단독 검증(산출물이 `runs/final-verification/stage-<N>/` 에 격리되고 팀 이름에 `-fv-s<N>` 접미사), 빈 값 = 전체-task 검증(평면 구조 유지). `--stages <csv>` 는 `release-handoff` 전용(별개 채널): PR 로 묶을 stage 번호들(stage-group 모드), 빈 값 = whole-task 모드. prepare 가 eligibility(`done`+`verified` accepted+미-`pr`)를 강제하고 검증 보고서 인용 input 문서를 자동 생성한다 |
597
603
  | `okstra render-views <final-report.md>` | Phase 7 step 1.5 — 토큰 치환된 final-report MD 한 본을 입력으로 sibling `*.slim.md` (AI 입력용) + `*.html` (사람용 self-contained) 두 view 를 결정론적으로 생성. 원본 MD 는 수정하지 않음. Node 위임 wrapper는 `scripts/okstra-render-report-views.py` 를 호출. `validators/validate-report-views.py` 가 substring 보존 / form-control 위치 / Response ID parity 를 검사 |
598
- | `okstra wizard <init\|step\|render-args\|confirmation> --state-file <path>` | okstra-run 인터랙티브 입력 상태머신 (`okstra_ctl.wizard`). `init` 으로 state file 을 시드한 뒤 skill 이 `step --answer <val>` 을 반복 호출하면 다음 `Prompt` JSON 을 받음. `--answer` 는 **필수**. 응답을 주지 않고 다음 prompt 만 미리 보고 싶다면 `--no-submit` 으로 peek. `render-args` 는 최종 `render-bundle` 인자 맵, `confirmation` 은 사용자 echo 블록을 반환. `implementation` task type 에서는 `approved_plan_pick` 직후 `stage_pick` 단계가 추가되어 실행할 stage 를 선택하고, `executor_pick` 으로 넘어갑니다 |
604
+ | `okstra wizard <init\|step\|render-args\|confirmation> --state-file <path>` | okstra-run 인터랙티브 입력 상태머신 (`okstra_ctl.wizard`). `init` 으로 state file 을 시드한 뒤 skill 이 `step --answer <val>` 을 반복 호출하면 다음 `Prompt` JSON 을 받음. `--answer` 는 **필수**. 응답을 주지 않고 다음 prompt 만 미리 보고 싶다면 `--no-submit` 으로 peek. `render-args` 는 최종 `render-bundle` 인자 맵, `confirmation` 은 사용자 echo 블록을 반환. `implementation` task type 에서는 `approved_plan_pick` 직후 `stage_pick` 단계가 추가되어 실행할 stage 를 선택하고, `executor_pick` 으로 넘어갑니다. brief 단계는 entry task-type(requirements-discovery / error-analysis / improvement-discovery)에서만 나오며, downstream 은 manifest 의 brief 를 자동 carry-in 하고(미등록 시 `brief_carry` 3-옵션 fallback), `release-handoff` 는 brief 없이 `handoff_stage_pick` 멀티선택(eligible stage 묶음 / 전체 task)으로 진입합니다 |
599
605
  | `okstra token-usage ...` | 설치된 `okstra-token-usage.py` 를 감싸 run token usage 수집/치환을 수행. 세션 jsonl 은 기본적으로 `$OKSTRA_HOME/cache/token-usage/` 의 byte cursor 캐시로 증분 스캔하며, `--no-cache` 로 캐시를 우회해 전체 재스캔을 강제할 수 있음(정확성 폴백) |
600
606
 
601
607
  > 모든 subcommand 는 `bin/okstra` 가 spawn 하는 python 헬퍼 (`src/_python-helper.mjs`) 가 `PYTHONPATH` 와 `~/.okstra/lib/python` 을 wire 합니다. 직접 `python3 -m okstra_ctl.*` 으로 호출하면 `PYTHONPATH` 를 사용자가 직접 셋업해야 합니다.
@@ -0,0 +1,158 @@
1
+ # brief entry-phase 한정 + release-handoff task-id 기반 stage 진입 — 설계
2
+
3
+ - 날짜: 2026-06-11
4
+ - 상태: 구현 완료 (사용자 승인 후 구현 — §5.3 의 manifest 갱신 항목만 구현 시 수정)
5
+ - 선행 설계: [2026-06-10-stage-group-handoff-design.md](2026-06-10-stage-group-handoff-design.md) (stage-group 백엔드), [2026-06-06-stage-worktree-isolation-design.md](2026-06-06-stage-worktree-isolation-design.md)
6
+
7
+ ## 1. 배경 / 문제
8
+
9
+ brief 는 비개발자·타 팀·아이디어·에러 신고 초안을 담는 **entry-phase 입력물**인데, 현재 구조는 모든 phase 에서 brief 를 의식처럼 요구한다. 사용자가 실제 run 에서 확인한 문제 세 가지:
10
+
11
+ 1. **위저드가 전 task-type 에 brief 를 묻는다.** brief 단계에 task-type 필터가 없고([wizard.py:2367-2382](../../../scripts/okstra_ctl/wizard.py:2367)), 기존 task 면 `brief_keep` 으로 유지/변경을 또 묻는다([wizard.py:2418-2425](../../../scripts/okstra_ctl/wizard.py:2418)). downstream phase 의 brief 는 task manifest 의 `taskBriefPath` 로 이미 자동 해소 가능한데도([wizard.py:830-837](../../../scripts/okstra_ctl/wizard.py:830)) 사용자 입력을 요구한다.
12
+ 2. **release-handoff 의 실질 입력은 final-verification final-report 인데 위저드는 brief 경로를 요구한다.** stage-group 모드 진입 조건이 "brief 가 단독-stage 검증 보고서 N 개를 인용"이라는 수동 의식이고([release-handoff.md:15](../../../prompts/profiles/release-handoff.md:15)), 그 brief 를 만들어 주는 자동화는 없다 — [release-handoff-input.template.md](../../../templates/reports/release-handoff-input.template.md) 는 렌더러가 없는 죽은 템플릿이며 단수 인용 슬롯뿐이다. 모드(whole-task/stage-group)를 사용자에게 보여주는 지점도 위저드·brief·게이트 어디에도 없다.
13
+ 3. **stage 자격 판정 인프라는 이미 있다.** `okstra_ctl.handoff.compute_eligibility` 가 approved plan 의 Stage Map + `consumers.jsonl`(done + `verified` accepted + 미-`pr`)로 stage 별 PR 자격을 판정한다([handoff.py:36-39](../../../scripts/okstra_ctl/handoff.py:36)). task-id 만 있으면 계산되는 정보를 brief 인용으로 중복 선언하게 하는 것은 단일 참조점 위반이다.
14
+
15
+ ## 2. 결정 요약 (사용자 Q&A)
16
+
17
+ | 질문 | 결정 |
18
+ |---|---|
19
+ | brief 는 어느 phase 입력인가 | entry phase(requirements-discovery / error-analysis / improvement-discovery) 전용. downstream 은 task-id 로 자동 carry-in |
20
+ | release-handoff 진입 입력 | brief 경로가 아니라 task-id. 검증 완료(eligible) stage 를 멀티선택하거나 전체 선택 |
21
+ | stage 선택 근거 | implementation-planning final-report 의 Stage Map + consumers 상태 (`handoff eligible` 재사용) |
22
+ | PR 생성 후 stage worktree 정리 | **범위 제외** (사용자 결정 — PR 리뷰 수정이 stage 브랜치를 재사용하는 흐름과 충돌 위험) |
23
+
24
+ ## 3. 목표 / 비목표
25
+
26
+ 목표:
27
+
28
+ - 위저드 brief 단계(`brief_path_pick`/`brief_path`/`brief_keep`)를 entry phase 에만 적용하고, downstream phase 는 manifest `taskBriefPath` 로 자동 해소한다.
29
+ - release-handoff 위저드 진입을 brief picker 대신 **eligible stage picker** 로 교체한다. 모드는 선택 결과로 자동 결정된다.
30
+ - prepare 가 release-handoff 의 입력 문서(검증 보고서 인용 래퍼)를 [release-handoff-input.template.md](../../../templates/reports/release-handoff-input.template.md) 로 **자동 생성**한다 — 죽은 템플릿의 부활, 수동 brief 작성 제거.
31
+ - `--stage`(impl/fv 의 Stage Map 실행/검증 선택)와 release-handoff 의 stage 묶음 선택을 **별개 채널**로 유지하되, 후자를 위저드 → render-bundle 로 전달하는 `--stages` flag 를 신설한다.
32
+
33
+ 비목표:
34
+
35
+ - PR 생성 후 worktree/branch/registry 정리 (사용자 결정으로 제외).
36
+ - `handoff eligible / assemble / record-*` 백엔드 로직 변경 — 그대로 재사용한다.
37
+ - 다른 phase 의 죽은 input 템플릿(`error-analysis-input` 등) 정비 — 후속 과제(§9).
38
+ - okstra-brief 스킬 변경 — entry phase 용도 그대로.
39
+ - prepare 의 `--task-brief` 필수 계약 변경 — 위저드/prepare 가 채워 넣는 방식만 바뀐다.
40
+
41
+ ## 4. 설계 A — brief 를 entry phase 전용으로
42
+
43
+ ### 4.1 task-type 분류 (SSOT 상수)
44
+
45
+ `wizard.py` 에 상수 신설 (기존 `_STAGE_SCOPED_TASK_TYPES` [wizard.py:89](../../../scripts/okstra_ctl/wizard.py:89) 와 나란히):
46
+
47
+ ```python
48
+ _BRIEF_ENTRY_TASK_TYPES = ("requirements-discovery", "error-analysis",
49
+ "improvement-discovery")
50
+ ```
51
+
52
+ downstream = 그 외 전부(implementation-planning, implementation, final-verification, release-handoff).
53
+
54
+ ### 4.2 위저드 단계 변경
55
+
56
+ - `S_BRIEF_PATH_PICK` / `S_BRIEF_PATH` / `S_BRIEF_KEEP` 의 `applies` 에 `s.task_type in _BRIEF_ENTRY_TASK_TYPES` 게이트 추가.
57
+ - 주의: 현재 신규 task 흐름은 brief 선택이 task-type 선택보다 **앞선다**([wizard.py:2373](../../../scripts/okstra_ctl/wizard.py:2373) `is_new_task is True and bool(task_group)`). 게이트를 넣으려면 신규 task 의 단계 순서를 task-type 먼저로 재배열해야 한다(§8 테스트로 고정). 기존 task 흐름은 이미 task-type 이후에 brief 를 묻는다.
58
+ - downstream + 기존 task: `_existing_task_brief()` 결과를 `state.brief_path` 에 **자동 주입**하고 brief 관련 단계를 모두 건너뛴다. 사용자에게 echo 한 줄로만 알린다 (`brief (carry-in): <path>`).
59
+ - downstream + brief 미등록 (신규 task 로 downstream type 을 고르거나 manifest 에 `taskBriefPath` 없음): 3-옵션 picker (run-prompt 추천 규칙 준수):
60
+ 1. (추천) entry phase 로 task-type 변경 — picker 가 entry 3종을 보여주고 task-type 단계로 되돌린다.
61
+ 2. brief 경로 직접 입력 — 탈출구. 손으로 쓴 brief 로 downstream 부터 시작하는 기존 워크플로 보존.
62
+ 3. 중단.
63
+ - release-handoff 는 §5 의 자동 생성 입력이 brief 역할을 대체하므로 이 picker 대상에서도 제외된다.
64
+
65
+ ### 4.3 계약 불변 사항
66
+
67
+ - prepare 의 `--task-brief` 필수 검증([run.py:2209-2212](../../../scripts/okstra_ctl/run.py:2209))과 downstream profile 들의 brief 인용(예: Requirement Coverage 가 brief heading 인용 — [implementation-planning.md:86](../../../prompts/profiles/implementation-planning.md:86))은 그대로다. brief 는 여전히 전 phase 의 요구사항 anchor 다 — 바뀌는 것은 "누가 경로를 대는가"(사용자 → 위저드 자동) 뿐이다.
68
+
69
+ ## 5. 설계 B — release-handoff: task-id 진입 + eligible stage picker
70
+
71
+ ### 5.1 위저드 흐름 (release-handoff 한정)
72
+
73
+ ```
74
+ task 선택(기존 task 전제) → task-type=release-handoff
75
+ → approved-plan 자동 해소 (fv 와 동일: _latest_implementation_planning_report
76
+ + frontmatter approved: true 확인; 실패 시 implementation-planning 선행 안내)
77
+ → S_HANDOFF_STAGE_PICK (신규):
78
+ compute_eligibility 직접 호출 (python import — shell-out 금지, 단일 참조점)
79
+ 옵션:
80
+ - "전체 task (whole-task 검증 기반)" — whole-task fv 보고서(accepted,
81
+ verificationScope=whole-task)가 존재할 때만 노출 → whole-task 모드
82
+ - eligible stage 멀티선택 (전체 eligible 선택 = "다 같이") → stage-group 모드
83
+ - blocked stage 는 reasons 와 함께 비선택 표시
84
+ → (이후 기존 단계: pr-template, 모델/directive 등 — 변경 없음)
85
+ ```
86
+
87
+ - brief 단계 없음, base-ref 단계 없음(현행 유지 — PR base 는 run 중 G1/Q2 가 묻는다).
88
+ - 모드 질문은 별도 단계가 아니라 **이 picker 의 선택지로 흡수**된다.
89
+
90
+ ### 5.2 인자 전달 — `--stages` 신설
91
+
92
+ - `render_args()` 가 release-handoff 일 때 신규 키 `stages` 를 채운다: whole-task = `""`, stage-group = `"2,3"` (csv).
93
+ - `render-bundle` / `okstra.sh` / `okstra_ctl.run` argparse 에 `--stages` 추가. **release-handoff 외 task-type 에서 비어 있지 않은 `--stages` 는 PrepareError** (어제 고친 `--stage` 빈 값 규칙과 동일하게, 빈 문자열은 미지정으로 통과 — [run.py:1124](../../../scripts/okstra_ctl/run.py:1124) 패턴).
94
+ - `--stage` 와 `--stages` 는 다른 채널이다: 전자 = impl/fv 의 Stage Map 실행/검증 선택, 후자 = release-handoff 의 PR 묶음 선택. 도움말에 명시한다.
95
+
96
+ ### 5.3 prepare — 입력 문서 자동 생성
97
+
98
+ release-handoff prepare 시:
99
+
100
+ 1. `compute_eligibility` 로 선택 stage 들의 자격을 **재검증** (위저드를 거치지 않은 CLI 경로도 같은 강제 지점을 통과).
101
+ 2. 각 선택 stage 의 단독 검증 보고서 경로·verdict 를 `consumers.jsonl` 의 `verified` 행(last-wins, [consumers.py:107-111](../../../scripts/okstra_ctl/consumers.py:107))에서 수집. whole-task 면 최신 whole-task fv final-report 에서 수집.
102
+ 3. [release-handoff-input.template.md](../../../templates/reports/release-handoff-input.template.md) 를 렌더링해 `<task_root>/release-handoff-input.md` 로 저장(매 run 덮어씀) → 이 파일이 그 run 의 brief 자리(`inp.brief_path`)를 채운다. manifest `taskBriefPath` 는 **갱신하지 않는다** — entry brief 포인터를 덮으면 이후 다른 phase 의 carry-in 이 handoff input 을 brief 로 오인하기 때문(구현 시 확정한 spec 수정).
103
+ 4. run-context 에 `HANDOFF_MODE` (`whole-task` | `stage-group`) 와 `HANDOFF_STAGES` 를 기록.
104
+
105
+ ### 5.4 템플릿 개정
106
+
107
+ `## Source Verification Report` 를 단수/복수 겸용으로 교체:
108
+
109
+ ```markdown
110
+ ## Source Verification Report
111
+
112
+ - Mode: `whole-task` | `stage-group`
113
+ - Reports (one row per cited final-verification report):
114
+
115
+ | Stage | Report path | Verdict Token (verbatim) | Run timestamp |
116
+ |---|---|---|---|
117
+ ```
118
+
119
+ whole-task 모드는 Stage 열을 `(all)` 한 행으로 채운다.
120
+
121
+ ### 5.5 profile 개정 ([release-handoff.md](../../../prompts/profiles/release-handoff.md))
122
+
123
+ - 진입 게이트(:13-15): "brief 가 인용해야 한다" → "prepare 가 생성한 input 문서 + run-context 의 `HANDOFF_MODE`/`HANDOFF_STAGES` 를 읽고, 인용된 각 Verdict Token 이 `accepted` 인지 확인한다" 로 교체.
124
+ - G2(:27): 위저드/CLI 에서 stage 가 이미 선택돼 있으므로 **재선택이 아니라 확인 표시**로 축소 — 선택 목록을 보여주고 진행. `okstra handoff eligible` 직접 호출 분기는 stage 선택 없이 진입한 비정상 경로의 방어로만 남긴다.
125
+ - assemble(:28) 이후 흐름(G1 base → assemble → Q2b → Q3, record-pr)은 불변.
126
+
127
+ ## 6. 설계 C — final-verification
128
+
129
+ stage picker(done 마킹 + 전체 task 검증)는 최근 작업으로 이미 사용자 모델과 일치한다. 이 설계에서는 §4 의 brief 자동 carry-in 만 적용된다.
130
+
131
+ ## 7. 영향 파일
132
+
133
+ | 파일 | 변경 |
134
+ |---|---|
135
+ | [scripts/okstra_ctl/wizard.py](../../../scripts/okstra_ctl/wizard.py) | `_BRIEF_ENTRY_TASK_TYPES`, brief 단계 게이트·자동 주입·미등록 picker, 신규 task 단계 순서 재배열, `S_HANDOFF_STAGE_PICK`, `render_args` `stages` 키 |
136
+ | [scripts/okstra_ctl/run.py](../../../scripts/okstra_ctl/run.py) | `--stages` argparse + 검증 + prepare 의 input 자동 생성·`HANDOFF_MODE`/`HANDOFF_STAGES` |
137
+ | [scripts/okstra_ctl/handoff.py](../../../scripts/okstra_ctl/handoff.py) | (변경 없음 — import 재사용) |
138
+ | [templates/reports/release-handoff-input.template.md](../../../templates/reports/release-handoff-input.template.md) | Source Verification Report 복수 양식 |
139
+ | [prompts/profiles/release-handoff.md](../../../prompts/profiles/release-handoff.md) | 진입 게이트·G2 개정 |
140
+ | [prompts/wizard/prompts.ko.json](../../../prompts/wizard/prompts.ko.json) | 신규/변경 prompt 문구 |
141
+ | [skills/okstra-run/SKILL.md](../../../skills/okstra-run/SKILL.md) | render-bundle 호출부 `--stages`, brief 단계 서술 갱신 |
142
+ | [scripts/okstra.sh](../../../scripts/okstra.sh) | `--stages` 전달 |
143
+ | [docs/kr/cli.md](../../../docs/kr/cli.md), [docs/kr/architecture.md](../../../docs/kr/architecture.md), [docs/task-process/release-handoff.md](../../../docs/task-process/release-handoff.md) | 계약 서술 갱신 |
144
+
145
+ ## 8. 테스트
146
+
147
+ - 위저드 단계 적용 매트릭스: entry 3종 = brief 단계 노출, downstream 4종 = 미노출 + 자동 주입 echo. 신규 task 단계 순서(task-type 이 brief 보다 먼저) 회귀.
148
+ - downstream + brief 미등록 → 3-옵션 picker 노출, 각 분기 동작.
149
+ - `S_HANDOFF_STAGE_PICK`: eligible/blocked 구성, whole-task 옵션 노출 조건, 멀티선택 → `render_args["stages"]` csv.
150
+ - `--stages` 게이트: release-handoff 외 task-type 의 비어 있지 않은 값 거부, 빈 값 통과 (기존 [tests/test_prepare_stage_flag_gate.py](../../../tests/test_prepare_stage_flag_gate.py) 패턴).
151
+ - prepare 입력 자동 생성: verified 행 인용 정확성(단위) + subprocess `--render-only` e2e 로 생성 파일·manifest 갱신 확인.
152
+ - profile 계약: validators 의 release-handoff 관련 체크가 있다면 갱신분 통과.
153
+
154
+ ## 9. 미해결 / 후속
155
+
156
+ - 다른 phase 의 죽은 input 템플릿([render.py:113-118](../../../scripts/okstra_ctl/render.py:113) 태그만 존재) 정리 또는 부활 — 별도 과제.
157
+ - PR 생성 후 stage worktree/branch/registry 정리 — 이번 범위에서 명시적으로 제외(사용자 결정). 재논의 시 PR 리뷰 수정 흐름([okstra-run SKILL.md:228](../../../skills/okstra-run/SKILL.md:228))과의 충돌을 먼저 풀어야 한다.
158
+ - okstra-brief 체인 다이어그램([SKILL.md:44-57](../../../skills/okstra-brief/SKILL.md:44))의 release-handoff 종단 표기 정합화.
@@ -51,8 +51,9 @@ sequenceDiagram
51
51
  participant WT as worktree registry
52
52
  participant FS as task artifacts
53
53
 
54
- W->>P: task-type=release-handoff, brief, optional pr-template-path
55
- P->>P: validate profile and brief
54
+ W->>P: task-type=release-handoff, approved-plan, stages csv, optional pr-template-path
55
+ P->>P: enforce stage eligibility (consumers verified/pr rows)
56
+ P->>FS: generate release-handoff-input.md (cited verification reports)
56
57
  P->>P: force workers=[]
57
58
  P->>T: resolve PR template
58
59
  P->>WT: reuse or provision task worktree
@@ -67,7 +68,7 @@ profile에 `Required workers:` block이 없고 `run.py`도 `release-handoff`에
67
68
 
68
69
  ```mermaid
69
70
  flowchart TD
70
- Brief[task brief] --> Source{Source Verification Report present?}
71
+ Brief[release-handoff-input.md<br/>generated by prepare] --> Source{Source Verification Report present?}
71
72
  Source -->|no| Block[blocked<br/>route final-verification]
72
73
  Source -->|yes| Verdict{Verdict Token == accepted?}
73
74
  Verdict -->|no| Block
@@ -82,8 +83,8 @@ flowchart TD
82
83
 
83
84
  lead는 사용자에게 push/PR 여부를 묻기 전에 다음을 확인한다.
84
85
 
85
- - task brief가 `## Source Verification Report`를 가리킨다.
86
- - report의 `## 7. Final Verdict`에 `Verdict Token = accepted`가 정확히 있다.
86
+ - prepare생성한 input 문서(`release-handoff-input.md`)의 `## Source Verification Report` 가 모드(`HANDOFF_MODE`)와 인용 보고서 표를 담고 있다. brief 는 entry phase 의 입력물이라 release-handoff 에는 없다 — 사용자의 stage 선택은 wizard `handoff_stage_pick` 또는 CLI `--stages` 로 prepare 전에 끝난다.
87
+ - 인용된 report 의 `## 7. Final Verdict`에 `Verdict Token = accepted`가 정확히 있다.
87
88
  - working tree가 clean이다.
88
89
  - 현재 branch가 `main`, `master`, `prod`, `preprod`, `staging`, `dev` 같은 base branch가 아니다.
89
90
  - `<base>..HEAD` commit range가 비어 있지 않다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.70.0",
3
- "builtAt": "2026-06-10T17:00:34.215Z",
2
+ "package": "0.71.0",
3
+ "builtAt": "2026-06-10T18:46:31.471Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -24,7 +24,10 @@ collect_required_arguments() {
24
24
  prompt_for_required_argument TASK_GROUP "Task Group"
25
25
  prompt_for_required_argument TASK_ID "Task ID"
26
26
  prompt_for_required_argument TASK_TYPE "Task Type"
27
- prompt_for_required_argument BRIEF_PATH "Task Brief Path"
27
+ if [[ "$TASK_TYPE" != "release-handoff" ]]; then
28
+ # release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성.
29
+ prompt_for_required_argument BRIEF_PATH "Task Brief Path"
30
+ fi
28
31
  }
29
32
 
30
33
  require_option_value() {
@@ -103,6 +106,10 @@ while [[ $# -gt 0 ]]; do
103
106
  STAGE="$(require_option_value --stage "${2-}")"
104
107
  shift 2
105
108
  ;;
109
+ --stages)
110
+ STAGES="$(require_option_value --stages "${2-}")"
111
+ shift 2
112
+ ;;
106
113
  --qa-waiver)
107
114
  QA_WAIVER="$(require_option_value --qa-waiver "${2-}")"
108
115
  shift 2
@@ -215,14 +215,16 @@ PY
215
215
  esac
216
216
  done <<<"$autofill_output"
217
217
 
218
- if [[ -z "$BRIEF_PATH" && -n "$manifest_brief" ]]; then
219
- BRIEF_PATH="$manifest_brief"
220
- printf 'autofill: brief-path from task-manifest.json: %s\n' "$BRIEF_PATH" >&2
221
- fi
222
218
  if [[ -z "$TASK_TYPE" && -n "$manifest_type" ]]; then
223
219
  TASK_TYPE="$manifest_type"
224
220
  printf 'autofill: task-type from manifest workflow.nextRecommendedPhase: %s\n' "$TASK_TYPE" >&2
225
221
  fi
222
+ # release-handoff 는 brief 입력이 없다 (prepare 가 검증 보고서 인용 input 을
223
+ # 생성) — task-type autofill 이후에 판정해야 하므로 순서가 위와 바뀌면 안 된다.
224
+ if [[ -z "$BRIEF_PATH" && -n "$manifest_brief" && "$TASK_TYPE" != "release-handoff" ]]; then
225
+ BRIEF_PATH="$manifest_brief"
226
+ printf 'autofill: brief-path from task-manifest.json: %s\n' "$BRIEF_PATH" >&2
227
+ fi
226
228
  }
227
229
 
228
230
  missing_required_arguments_summary() {
@@ -232,7 +234,8 @@ missing_required_arguments_summary() {
232
234
  [[ -z "$TASK_GROUP" ]] && missing+=("<task-group>")
233
235
  [[ -z "$TASK_ID" ]] && missing+=("<task-id>")
234
236
  [[ -z "$TASK_TYPE" ]] && missing+=("<task-type>")
235
- [[ -z "$BRIEF_PATH" ]] && missing+=("<brief-path>")
237
+ # release-handoff brief 입력이 없다 — prepare 가 input 문서를 생성한다.
238
+ [[ -z "$BRIEF_PATH" && "$TASK_TYPE" != "release-handoff" ]] && missing+=("<brief-path>")
236
239
 
237
240
  if (( ${#missing[@]} == 0 )); then
238
241
  printf '%s' ""
@@ -124,6 +124,7 @@ PY_ARGS=(
124
124
  [[ -n "${WORK_CATEGORY-}" ]] && PY_ARGS+=(--work-category "$WORK_CATEGORY")
125
125
  [[ -n "${BASE_REF-}" ]] && PY_ARGS+=(--base-ref "$BASE_REF")
126
126
  [[ -n "${STAGE-}" ]] && PY_ARGS+=(--stage "$STAGE")
127
+ [[ -n "${STAGES-}" ]] && PY_ARGS+=(--stages "$STAGES")
127
128
  [[ -n "${QA_WAIVER-}" ]] && PY_ARGS+=(--qa-waiver "$QA_WAIVER")
128
129
  [[ "$RENDER_ONLY" == "true" ]] && PY_ARGS+=(--render-only)
129
130
  [[ "$PLAN_VERIFICATION_ENABLED" == "false" ]] && PY_ARGS+=(--no-plan-verification)
@@ -11,8 +11,9 @@
11
11
  - The shared "authority & permissions assumption" rule from the common contract still applies: assume the user holds every permission needed; do not block on hypothetical approvals.
12
12
  - The shared "MCP read-only" rule still applies if the brief lists MCP servers, though most release-handoff runs do not use MCP.
13
13
  - Pre-handoff entry gate (mandatory — refuse to start if any item fails):
14
- - **whole-task mode**: the task brief MUST cite the originating `final-verification` final-report path under `## Source Verification Report`; the lead confirms its `Verdict Token` is exactly `accepted` and its `verificationScope` is `whole-task`.
15
- - **stage-group mode**: the brief cites N single-stage `final-verification` reports (one per candidate stage); the lead confirms each `Verdict Token` is `accepted`. Eligibility is re-enforced by `okstra handoff` the lead never hand-computes it.
14
+ - the run's input document (`release-handoff-input.md`, generated by prepare in place of a task brief briefs belong to entry phases only) carries a `## Source Verification Report` section with `Mode`, `Stages`, and one table row per cited `final-verification` final-report. The run context exposes the same selection as `HANDOFF_MODE` (`whole-task` | `stage-group`) and `HANDOFF_STAGES` (csv, empty for whole-task).
15
+ - **whole-task mode** (`HANDOFF_MODE=whole-task`): the lead opens the cited report and confirms its `Verdict Token` is exactly `accepted` and its `verificationScope` is `whole-task`.
16
+ - **stage-group mode** (`HANDOFF_MODE=stage-group`): the lead opens each cited single-stage report and confirms every `Verdict Token` is `accepted`. Eligibility was already enforced at prepare time (`okstra_ctl.handoff.compute_eligibility`) and is re-enforced by `okstra handoff assemble` — the lead never hand-computes it.
16
17
  - if the verdict is `conditional-accept`, `blocked`, or any other token (including ambiguous phrasing like "looks good"), the run MUST end immediately with status `blocked` and a routing recommendation back to `error-analysis` or `implementation-planning`. Do NOT prompt the user; Do NOT run any git command.
17
18
  - the lead MUST capture `git status --short` and confirm the working tree is clean. Dirty state aborts the run; release-handoff packages the commits produced by `implementation`, it does not stage or commit changes.
18
19
  - the lead MUST capture `git rev-parse --abbrev-ref HEAD` and record it as the **feature branch**. If the current branch is itself `main`, `master`, `prod`, `preprod`, `staging`, or `dev`, the run MUST end immediately — release-handoff never operates on a base branch.
@@ -23,11 +24,10 @@
23
24
  - `push + PR` — push the feature branch, then open or reuse a pull request.
24
25
  - `skip` — record the verified state and end the run without any git command.
25
26
  If the user picks `skip`, route directly to the final-report self-review pass.
26
- - **stage-group mode order**: G1 base branch first (same options as Q2 — the dependency-closure check needs `origin/<base>`), then G2 stage selection, then assemble, then Q2b/Q3 as usual with the collector branch as the PR head.
27
- 1g. **G2 — stage selection**: run `okstra handoff eligible --plan-run-root <plan-run-root> --approved-plan <approved plan path>` and present the returned stages (eligible ones selectable, blocked ones listed with their `reasons`) as a multi-select. At least one stage must be selected.
27
+ - **stage-group mode order**: G1 base branch first (same options as Q2 — the dependency-closure check needs `origin/<base>`), then G2 stage confirmation, then assemble, then Q2b/Q3 as usual with the collector branch as the PR head.
28
+ 1g. **G2 — stage confirmation**: the stage selection already happened before the run (wizard `handoff_stage_pick`, or the CLI `--stages` flag) and is fixed in `HANDOFF_STAGES`. Display it as a one-line confirmation (`PR 대상 stage: <csv> — 진행합니다`) and proceed; do NOT re-ask the multi-select. Only if `HANDOFF_MODE` is `stage-group` but `HANDOFF_STAGES` is empty (defensive, should not happen) run `okstra handoff eligible --plan-run-root <plan-run-root> --approved-plan <approved plan path>` and ask the user to pick from the eligible stages.
28
29
  2g. **assemble**: run `okstra handoff assemble --plan-run-root <...> --approved-plan <...> --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --work-category <c> --stages <csv> --base <chosen-base>`. Exit 2 means a stage-vs-stage merge conflict: show the `conflicts` paths and stop (route: reshape the group or resolve manually). Exit 1 means an eligibility/closure violation: show the error verbatim and re-ask G2. On success the returned `branch` is the PR head branch for every subsequent step.
29
30
  2. **PR base branch** (only when the user picked `push + PR`) — present four options and capture exactly one:
30
- - `staging`
31
31
  - `preprod`
32
32
  - `main`
33
33
  - `직접 입력` (free-form branch name; lead validates the name exists on origin via `git ls-remote --heads origin <name>` and re-asks on failure)
@@ -123,6 +123,42 @@
123
123
  "label": "task brief markdown 의 경로를 알려주세요 (project root 기준 상대경로 또는 절대경로)",
124
124
  "echo_template": "brief: {value}"
125
125
  },
126
+ "handoff_stage_pick": {
127
+ "label": "PR 로 내보낼 범위를 선택하세요 (복수 선택 가능 — 검증 accepted + 미-PR stage 만 표시). 제외된 stage: {blocked}",
128
+ "echo_template": "handoff-scope: {value}",
129
+ "labels": {
130
+ "whole_task": "전체 task — whole-task 검증 기반 단일 PR (단독 선택)",
131
+ "stage": "stage {stage} (depends-on: {deps})",
132
+ "blocked_none": "없음"
133
+ },
134
+ "echo_variants": {
135
+ "whole_task": "handoff scope: 전체 task (whole-task 검증 기반)",
136
+ "stage_group": "handoff scope: stage-group ({stages})"
137
+ },
138
+ "errors": {
139
+ "no_plan": "release-handoff 는 이 task 의 implementation-planning final-report 가 필요합니다 — implementation-planning → implementation → final-verification 을 먼저 진행하세요.",
140
+ "plan_not_approved": "최신 plan 이 approved 상태가 아닙니다: {plan} — implementation 이 완료된 task 에서만 release-handoff 를 시작할 수 있습니다.",
141
+ "no_stage_map": "approved plan 에서 Stage Map 을 읽지 못했습니다: {plan}",
142
+ "nothing_eligible": "PR 로 내보낼 수 있는 범위가 없습니다 — 제외 사유: {blocked}. 단독-stage final-verification 의 accepted 기록(okstra handoff record-verified) 또는 whole-task 검증을 먼저 완료하세요.",
143
+ "none_selected": "최소 1개의 범위를 선택하세요",
144
+ "whole_task_exclusive": "'전체 task' 는 단독 선택만 가능합니다 — stage 묶음을 원하면 stage 번호들만 선택하세요",
145
+ "whole_task_missing": "accepted whole-task final-verification 보고서가 없습니다",
146
+ "not_eligible": "eligible 하지 않은 stage: {bad} (선택 가능: {eligible})"
147
+ }
148
+ },
149
+ "brief_carry": {
150
+ "label": "이 task 에 carry-in 할 brief 가 없습니다. {task_type} 는 entry phase 의 brief 를 이어받는 단계입니다 — 어떻게 진행할까요?",
151
+ "echo_template": "brief-carry: {value}",
152
+ "options": {
153
+ "__switch_entry__": "entry phase 로 전환 (추천) — requirements-discovery / error-analysis / improvement-discovery 부터 시작",
154
+ "__free_input__": "brief 경로 직접 입력",
155
+ "__abort__": "중단"
156
+ },
157
+ "echo_variants": {
158
+ "switch_entry": "brief-carry: entry phase 로 전환 — task-type 을 다시 선택합니다",
159
+ "abort": "brief-carry: 중단"
160
+ }
161
+ },
126
162
  "base_ref_pick": {
127
163
  "label": "이 task worktree 의 base branch?",
128
164
  "echo_template": "base-ref: {value}",
@@ -371,6 +407,8 @@
371
407
  "confirmation": {
372
408
  "header": "선택 확인:",
373
409
  "workers_implementation_default": " workers : (프로필 기본 — executor + verifier 2 + report-writer)",
374
- "stage_whole_task": "전체 task"
410
+ "stage_whole_task": "전체 task",
411
+ "handoff_scope_whole_task": "전체 task (whole-task 검증 기반)",
412
+ "handoff_scope_stage_group": "stage-group ({stages})"
375
413
  }
376
414
  }
@@ -62,6 +62,35 @@ def compute_eligibility(stage_map: List[Dict[str, Any]],
62
62
  return out
63
63
 
64
64
 
65
+ def latest_whole_task_fv_accepted(project_root, project_id: str,
66
+ task_group: str, task_id: str) -> str:
67
+ """accepted whole-task final-verification 보고서 경로. 없으면 ''.
68
+
69
+ 판정의 SSOT 는 final-report 의 data.json 이다 (record_verified 와 동일
70
+ 필드: header.taskType / verificationScope / finalVerdict.verdictToken).
71
+ whole-task run 산출물만 평면 reports/ 에 남으므로 stage-* 는 걸리지 않는다."""
72
+ from okstra_project.state import find_task_root
73
+ root = find_task_root(Path(project_root),
74
+ f"{project_id}:{task_group}:{task_id}")
75
+ if root is None:
76
+ return ""
77
+ reports_dir = root / "runs" / "final-verification" / "reports"
78
+ for dj in sorted(reports_dir.glob("final-report-*.data.json"),
79
+ reverse=True):
80
+ try:
81
+ data = json.loads(dj.read_text(encoding="utf-8"))
82
+ except (OSError, json.JSONDecodeError):
83
+ continue
84
+ token = ((data.get("finalVerdict") or {}).get("verdictToken")
85
+ or "").strip().lower()
86
+ if ((data.get("header") or {}).get("taskType") == "final-verification"
87
+ and data.get("verificationScope") == "whole-task"
88
+ and token == "accepted"):
89
+ md = dj.with_name(dj.name[:-len(".data.json")] + ".md")
90
+ return str(md if md.is_file() else dj)
91
+ return ""
92
+
93
+
65
94
  def check_dependency_closure(
66
95
  selected: List[int],
67
96
  stage_map: List[Dict[str, Any]],
@@ -291,6 +291,10 @@ class PrepareInputs:
291
291
  # prepare-time 에 task-level conformance 매니페스트 entry.waiver 를 채운다.
292
292
  qa_waiver: str = ""
293
293
  stage: str = "auto"
294
+ # release-handoff 전용: PR 로 내보낼 stage 묶음 (csv, 예: "2,3"). 빈 값 =
295
+ # whole-task 모드. `--stage`(impl/fv 의 Stage Map 실행/검증 선택)와는
296
+ # 별개 채널이다.
297
+ stages: str = ""
294
298
  clarification_response_path: str = "" # absolute or empty
295
299
  # release-handoff 전용: PR 본문 템플릿 1회성 override. 빈 문자열이면
296
300
  # project.json → global config → 스킬 디폴트 순으로 해석된다.
@@ -1064,7 +1068,17 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1064
1068
  를 검증하고, implementation 일 때 stage map 을 파싱해 돌려준다 (그 외엔 빈 리스트)."""
1065
1069
  if not project_root.is_dir():
1066
1070
  raise PrepareError(f"project root not found: {project_root}")
1067
- if not inp.brief_path.is_file():
1071
+ if inp.stages and inp.task_type != "release-handoff":
1072
+ raise PrepareError(
1073
+ "--stages is only meaningful with --task-type release-handoff; "
1074
+ f"got {inp.task_type}"
1075
+ )
1076
+ if inp.task_type == "release-handoff":
1077
+ # brief 는 entry phase 의 입력물 — release-handoff 는 검증 보고서를
1078
+ # 인용하는 input 문서를 prepare 가 직접 생성해 brief 자리에 채운다
1079
+ # (_materialize_release_handoff_input, 이 검증 직후 실행).
1080
+ pass
1081
+ elif not inp.brief_path.is_file():
1068
1082
  raise PrepareError(f"task brief not found: {inp.brief_path}")
1069
1083
  ctx_stage_map: list = []
1070
1084
  # implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
@@ -1121,7 +1135,9 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1121
1135
  "--implementation-option is only meaningful with --task-type "
1122
1136
  "implementation and --approved-plan <path>"
1123
1137
  )
1124
- if inp.stage != "auto":
1138
+ # wizard 는 모든 flag 를 빈 값 포함 항상 전달하므로(`--stage ""`),
1139
+ # 빈 문자열은 "stage 미지정" 으로 받아들인다.
1140
+ if inp.stage not in ("", "auto"):
1125
1141
  raise PrepareError(
1126
1142
  "--stage is only meaningful with --task-type implementation or "
1127
1143
  f"final-verification; got {inp.task_type}"
@@ -1133,6 +1149,113 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1133
1149
  return ctx_stage_map
1134
1150
 
1135
1151
 
1152
+ def _collect_handoff_source_report_rows(
1153
+ rows: list, nums: list,
1154
+ ) -> list:
1155
+ """선택 stage 들의 마지막 verified 행에서 인용 표 행을 만든다 (last-wins)."""
1156
+ last_verified: dict = {}
1157
+ for r in rows:
1158
+ if r.get("status") == "verified" and isinstance(r.get("stage"), int):
1159
+ last_verified[r["stage"]] = r
1160
+ return [
1161
+ f"| {n} | {last_verified[n].get('report_path', '')} "
1162
+ f"| `{last_verified[n].get('verdict', '')}` |"
1163
+ for n in nums
1164
+ ]
1165
+
1166
+
1167
+ def _materialize_release_handoff_input(
1168
+ workspace_root: Path, project_root: Path, inp: "PrepareInputs",
1169
+ ) -> dict:
1170
+ """release-handoff 의 입력 문서를 검증 보고서 인용으로 자동 생성한다.
1171
+
1172
+ brief 는 entry phase 의 입력물이라 release-handoff 에는 없다 — 이 run 의
1173
+ `--task-brief` 자리는 여기서 생성된 input 문서가 채운다. stage 자격은
1174
+ okstra_ctl.handoff 의 SSOT 판정을 prepare 에서도 그대로 강제한다.
1175
+ 반환: ctx 에 올릴 {"HANDOFF_MODE": ..., "HANDOFF_STAGES": ...}."""
1176
+ from .consumers import read_consumers
1177
+ from .handoff import (HandoffError, _require_eligible,
1178
+ latest_whole_task_fv_accepted)
1179
+ from .paths import task_dir
1180
+ from .render import render_template_with_ctx
1181
+ from .run_context import _now_task_date
1182
+
1183
+ # Path("") 는 "." 로 정규화된다 — 빈 brief 센티널은 둘 다로 들어올 수 있다.
1184
+ if str(inp.brief_path).strip() not in ("", "."):
1185
+ raise PrepareError(
1186
+ "--task-brief is not accepted for --task-type release-handoff — "
1187
+ "the input document is generated from the cited final-verification "
1188
+ "reports (pick stages via --stages, or leave it empty for "
1189
+ "whole-task)"
1190
+ )
1191
+ if not inp.approved_plan_path:
1192
+ raise PrepareError(
1193
+ "release-handoff requires --approved-plan "
1194
+ "<implementation-planning final-report> — the Stage Map and the "
1195
+ "consumers ledger drive stage eligibility"
1196
+ )
1197
+ plan = Path(inp.approved_plan_path)
1198
+ if not plan.is_file():
1199
+ raise PrepareError(f"approved plan not found: {plan}")
1200
+ stage_map = _parse_stage_map_into_ctx(str(plan))
1201
+ if not stage_map:
1202
+ raise PrepareError(f"approved plan has no parsable Stage Map: {plan}")
1203
+ rows = read_consumers(plan.resolve().parents[1])
1204
+
1205
+ if inp.stages:
1206
+ try:
1207
+ nums = sorted({int(x) for x in inp.stages.split(",") if x.strip()})
1208
+ except ValueError:
1209
+ raise PrepareError(
1210
+ f"--stages must be a comma-separated int list, got {inp.stages!r}"
1211
+ )
1212
+ if not nums:
1213
+ raise PrepareError("--stages must select at least one stage")
1214
+ try:
1215
+ _require_eligible(stage_map, rows, nums)
1216
+ except HandoffError as exc:
1217
+ raise PrepareError(str(exc)) from exc
1218
+ mode = "stage-group"
1219
+ stages_csv = ",".join(str(n) for n in nums)
1220
+ report_rows = _collect_handoff_source_report_rows(rows, nums)
1221
+ else:
1222
+ report = latest_whole_task_fv_accepted(
1223
+ project_root, inp.project_id, inp.task_group, inp.task_id)
1224
+ if not report:
1225
+ raise PrepareError(
1226
+ "whole-task release-handoff requires an accepted whole-task "
1227
+ "final-verification report — run final-verification first, "
1228
+ "or pass --stages <csv> for a stage-group PR"
1229
+ )
1230
+ mode = "whole-task"
1231
+ stages_csv = ""
1232
+ report_relative = relative_to_project_root(Path(report), project_root)
1233
+ report_rows = [f"| (all) | {report_relative or report} | `accepted` |"]
1234
+
1235
+ template = (workspace_root / "templates" / "reports"
1236
+ / "release-handoff-input.template.md")
1237
+ if not template.is_file():
1238
+ raise PrepareError(
1239
+ f"release-handoff input template missing: {template}.{_INSTALL_HINT}"
1240
+ )
1241
+ out = (task_dir(project_root, inp.task_group, inp.task_id)
1242
+ / "release-handoff-input.md")
1243
+ out.parent.mkdir(parents=True, exist_ok=True)
1244
+ render_template_with_ctx(str(template), str(out), {
1245
+ "TASK_KEY": f"{inp.project_id}:{inp.task_group}:{inp.task_id}",
1246
+ "TASK_ID": inp.task_id,
1247
+ "TASK_GROUP": inp.task_group,
1248
+ "PROJECT_ID": inp.project_id,
1249
+ "TASK_TYPE": inp.task_type,
1250
+ "TASK_DATE": _now_task_date(),
1251
+ "HANDOFF_MODE": mode,
1252
+ "HANDOFF_STAGES": stages_csv or "(all)",
1253
+ "HANDOFF_SOURCE_REPORTS": "\n".join(report_rows),
1254
+ })
1255
+ inp.brief_path = out
1256
+ return {"HANDOFF_MODE": mode, "HANDOFF_STAGES": stages_csv}
1257
+
1258
+
1136
1259
  def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1137
1260
  """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다."""
1138
1261
  if not inp.qa_waiver:
@@ -1822,6 +1945,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1822
1945
  final_report_template = assets.final_report_template
1823
1946
  ctx_stage_map = _validate_prepare_inputs(project_root, inp)
1824
1947
 
1948
+ # release-handoff: 검증 보고서 인용 input 문서를 생성해 brief 자리에 채운다.
1949
+ # 이후의 모든 brief 소비 경로(material/instruction-set 복사)는 그대로 동작한다.
1950
+ handoff_tokens = {"HANDOFF_MODE": "", "HANDOFF_STAGES": ""}
1951
+ if inp.task_type == "release-handoff":
1952
+ handoff_tokens = _materialize_release_handoff_input(
1953
+ workspace_root, project_root, inp)
1954
+
1825
1955
  verify_installation(workspace_root)
1826
1956
  _register_and_check_project(project_root, inp)
1827
1957
 
@@ -1989,6 +2119,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1989
2119
  "RECOMMENDED_ANALYSERS": selected_reviewers,
1990
2120
  "PR_TEMPLATE_PATH": pr_template_path_str,
1991
2121
  "PR_TEMPLATE_SOURCE": pr_template_source,
2122
+ **handoff_tokens,
1992
2123
  "CLAUDE_SESSION_ID": claude_session_id,
1993
2124
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
1994
2125
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
@@ -2113,7 +2244,14 @@ def main(argv: list[str]) -> int:
2113
2244
  p.add_argument("--task-group", required=True)
2114
2245
  p.add_argument("--task-id", required=True)
2115
2246
  p.add_argument("--task-type", required=True)
2116
- p.add_argument("--task-brief", required=True, dest="task_brief")
2247
+ p.add_argument(
2248
+ "--task-brief", default="", dest="task_brief",
2249
+ help=(
2250
+ "Required for every task-type except release-handoff (whose input "
2251
+ "document is generated by prepare from the cited "
2252
+ "final-verification reports)."
2253
+ ),
2254
+ )
2117
2255
  p.add_argument("--directive", default="")
2118
2256
  p.add_argument("--workers", default="", dest="workers_override")
2119
2257
  p.add_argument("--lead-model", default="")
@@ -2135,6 +2273,15 @@ def main(argv: list[str]) -> int:
2135
2273
  "consumers.jsonl status:done. Numeric '<N>' = force that stage."
2136
2274
  ),
2137
2275
  )
2276
+ p.add_argument(
2277
+ "--stages", default="", dest="stages",
2278
+ help=(
2279
+ "release-handoff only. Comma-separated stage numbers to bundle "
2280
+ "into one PR (stage-group mode); empty = whole-task mode. "
2281
+ "Distinct from --stage (implementation/final-verification "
2282
+ "Stage Map selection)."
2283
+ ),
2284
+ )
2138
2285
  p.add_argument(
2139
2286
  "--approve",
2140
2287
  action="store_true",
@@ -2206,10 +2353,22 @@ def main(argv: list[str]) -> int:
2206
2353
  args = p.parse_args(argv)
2207
2354
 
2208
2355
  project_root = Path(args.project_root).expanduser().resolve()
2209
- brief_abs = resolve_user_file(args.task_brief, project_root)
2210
- if brief_abs is None:
2211
- print(f"task brief not found: {args.task_brief}", file=__import__("sys").stderr)
2212
- return 1
2356
+ if args.task_type == "release-handoff":
2357
+ if args.task_brief:
2358
+ print(
2359
+ "--task-brief is not accepted for --task-type release-handoff "
2360
+ "— the input document is generated from the cited "
2361
+ "final-verification reports",
2362
+ file=__import__("sys").stderr,
2363
+ )
2364
+ return 1
2365
+ brief_abs = Path("") # prepare 가 input 문서를 생성해 채운다
2366
+ else:
2367
+ brief_abs = resolve_user_file(args.task_brief, project_root)
2368
+ if brief_abs is None:
2369
+ print(f"task brief not found: {args.task_brief}",
2370
+ file=__import__("sys").stderr)
2371
+ return 1
2213
2372
  clarification_abs = ""
2214
2373
  if args.clarification_response_path:
2215
2374
  cr = resolve_user_file(args.clarification_response_path, project_root)
@@ -2244,6 +2403,7 @@ def main(argv: list[str]) -> int:
2244
2403
  approved_plan_path=args.approved_plan_path,
2245
2404
  qa_waiver=args.qa_waiver,
2246
2405
  stage=args.stage,
2406
+ stages=args.stages,
2247
2407
  clarification_response_path=clarification_abs,
2248
2408
  pr_template_path=args.pr_template_path,
2249
2409
  render_only=args.render_only,
@@ -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
 
@@ -1215,6 +1234,23 @@ def _build_task_type(state: WizardState) -> Prompt:
1215
1234
  echo_template=t["echo_template"])
1216
1235
 
1217
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
+
1218
1254
  def _apply_task_type(state: WizardState, value: str) -> str:
1219
1255
  if value not in TASK_TYPE_VALUES:
1220
1256
  raise WizardError(
@@ -1222,6 +1258,11 @@ def _apply_task_type(state: WizardState, value: str) -> str:
1222
1258
  f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
1223
1259
  )
1224
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)
1225
1266
  state.profile_workers = _load_profile_workers(
1226
1267
  Path(state.workspace_root), value
1227
1268
  )
@@ -1231,6 +1272,8 @@ def _apply_task_type(state: WizardState, value: str) -> str:
1231
1272
  # Reuse-worktree is decided once identity is final. Recompute here so
1232
1273
  # subsequent base-ref step knows whether to apply.
1233
1274
  state.reuse_worktree = _resolve_reuse_worktree(state)
1275
+ if carried:
1276
+ return f"task-type: {value}\nbrief (carry-in): {carried}"
1234
1277
  return f"task-type: {value}"
1235
1278
 
1236
1279
 
@@ -1357,6 +1400,37 @@ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
1357
1400
  return f"brief: {p}"
1358
1401
 
1359
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
+
1360
1434
  def _build_base_ref_pick(state: WizardState) -> Prompt:
1361
1435
  t = _p(state.workspace_root, "base_ref_pick")
1362
1436
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -1602,6 +1676,105 @@ def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1602
1676
  return f"stage: {answer}"
1603
1677
 
1604
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
+
1605
1778
  def _suggest_latest_final_report(state: WizardState) -> str:
1606
1779
  """task 의 모든 phase runs 디렉토리에서 가장 최근 final-report-*.md 의 relpath 를 반환.
1607
1780
 
@@ -2364,16 +2537,40 @@ STEPS: list[Step] = [
2364
2537
  and s.task_group_pending_text),
2365
2538
  build=_build_task_group_text, submit=_submit_task_group_text,
2366
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",)),
2367
2564
  Step(S_BRIEF_PATH_PICK,
2368
2565
  applies=lambda s: (
2369
2566
  not s.brief_path
2370
2567
  and not s.brief_path_pending_text
2371
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
2372
2571
  and (
2373
- (s.is_new_task is True and bool(s.task_group))
2572
+ s.is_new_task is True
2374
2573
  or (s.is_new_task is False
2375
- and S_TASK_TYPE in s.answered
2376
- and bool(s.task_type)
2377
2574
  and (s.keep_existing_brief is False
2378
2575
  or not s.existing_brief_path))
2379
2576
  )
@@ -2385,49 +2582,39 @@ STEPS: list[Step] = [
2385
2582
  and S_BRIEF_PATH not in s.answered,
2386
2583
  build=_build_brief_path, submit=_submit_brief_path,
2387
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",)),
2388
2596
  Step(S_TASK_ID,
2389
2597
  applies=lambda s: (bool(s.is_new_task)
2390
- and bool(s.brief_path)
2391
2598
  and bool(s.task_group)
2599
+ and _brief_resolved(s)
2392
2600
  and not s.task_id
2393
2601
  and not s.task_id_pending_text),
2394
2602
  build=_build_task_id, submit=_submit_task_id,
2395
2603
  owns=("task_id", "task_id_pending_text")),
2396
2604
  Step(S_TASK_ID_TEXT,
2397
2605
  applies=lambda s: (bool(s.is_new_task)
2398
- and bool(s.brief_path)
2399
2606
  and bool(s.task_group)
2607
+ and _brief_resolved(s)
2400
2608
  and not s.task_id
2401
2609
  and s.task_id_pending_text),
2402
2610
  build=_build_task_id_text, submit=_submit_task_id_text,
2403
2611
  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
2612
  Step(S_BASE_REF_PICK,
2427
2613
  applies=lambda s: (S_TASK_TYPE in s.answered
2428
2614
  and _base_ref_required(s)
2429
2615
  and S_BASE_REF_PICK not in s.answered
2430
- and bool(s.brief_path)),
2616
+ and _brief_resolved(s)
2617
+ and (not s.is_new_task or bool(s.task_id))),
2431
2618
  build=_build_base_ref_pick, submit=_submit_base_ref_pick,
2432
2619
  owns=("base_ref", "base_ref_pending_text")),
2433
2620
  Step(S_BASE_REF_TEXT,
@@ -2468,6 +2655,13 @@ STEPS: list[Step] = [
2468
2655
  and S_STAGE_PICK not in s.answered),
2469
2656
  build=_build_stage_pick, submit=_submit_stage_pick,
2470
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")),
2471
2665
  Step(S_EXECUTOR,
2472
2666
  applies=lambda s: (s.task_type == "implementation"
2473
2667
  and bool(s.approved_plan_path)
@@ -2644,7 +2838,8 @@ def _identity_ready(s: WizardState) -> bool:
2644
2838
  """All identity questions (task pick → executor for impl) answered."""
2645
2839
  if not s.task_type:
2646
2840
  return False
2647
- if not s.brief_path:
2841
+ # release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
2842
+ if not s.brief_path and s.task_type != "release-handoff":
2648
2843
  return False
2649
2844
  if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
2650
2845
  return False
@@ -2656,6 +2851,9 @@ def _identity_ready(s: WizardState) -> bool:
2656
2851
  if s.task_type == "implementation":
2657
2852
  if not s.executor:
2658
2853
  return False
2854
+ if (s.task_type == "release-handoff"
2855
+ and S_HANDOFF_STAGE_PICK not in s.answered):
2856
+ return False
2659
2857
  return True
2660
2858
 
2661
2859
 
@@ -2708,6 +2906,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
2708
2906
  "base_ref_pending_text": False, "approved_plan_path": "",
2709
2907
  "approved_plan_pending_text": False,
2710
2908
  "selected_stage": "auto",
2909
+ "handoff_mode": "", "handoff_stages": "",
2711
2910
  "executor": "", "critic": "", "critic_pending_text": False,
2712
2911
  "reuse_previous": None,
2713
2912
  "use_defaults": None, "workers_override": "",
@@ -2873,6 +3072,7 @@ def render_args(state: WizardState) -> dict[str, str]:
2873
3072
  "critic": state.critic,
2874
3073
  "approved-plan": state.approved_plan_path,
2875
3074
  "stage": stage,
3075
+ "stages": state.handoff_stages,
2876
3076
  "base-ref": base_ref,
2877
3077
  "workers": workers,
2878
3078
  "directive": state.directive,
@@ -2934,6 +3134,16 @@ def confirmation_block(state: WizardState) -> str:
2934
3134
  lines.append(f" stage : {stage}")
2935
3135
  if state.clarification_response_path:
2936
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}")
2937
3147
  if state.task_type == "release-handoff" and state.pr_template_path:
2938
3148
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
2939
3149
  return "\n".join(lines)
@@ -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\`)