okstra 0.70.0 → 0.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
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.1",
3
+ "builtAt": "2026-06-11T03:33:31.499Z",
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)
@@ -50,6 +50,6 @@ are collected and convergence finished. Phase 1-5 do not need it.
50
50
 
51
51
  - Parse the executor's `### Stage Carry Evidence` JSON block. If absent or unparsable, end with status `contract-violated` and route to a follow-up `error-analysis`.
52
52
  - For this run's single stage: write its JSON verbatim to `runs/<impl-task-key>/carry/stage-<N>.json`. Refuse to overwrite an existing file (one stage = one sidecar; re-runs are out of scope for this version).
53
- - For this run's single stage: append a `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` with `completed_at`, `carry_path`, `report_path` (this run's final-report path relative to the run root), and the SHA of HEAD. Use the okstra runtime's `consumers_mutex` helper (NOT a raw filesystem write) to honour the lock. `report_path` lets `final-verification` cite each stage's originating report when assembling its Source Implementation Report list.
53
+ - For this run's single stage: append a `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` with `completed_at`, `carry_path`, `report_path` (this run's final-report path relative to the run root), and the SHA of HEAD. Append it with `okstra_ctl.consumers.append_consumer` (NOT a raw filesystem write) it honours the consumers lock AND releases this stage's worktree-registry occupancy, so later runs stop seeing a finished stage as a concurrent run. `report_path` lets `final-verification` cite each stage's originating report when assembling its Source Implementation Report list.
54
54
  - The verifier round, Phase 5.5 convergence, and this Phase 6 report run **once per run** over this stage's diff — NOT per step.
55
55
  - Quote this stage's new contents (the sidecar JSON in full and the new consumers row by itself) in the final report's `Stage sidecar evidence` deliverable section.
@@ -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,8 +123,44 @@
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
- "label": "이 task worktree 의 base branch?",
163
+ "label": "이 task 의 base branch?",
128
164
  "echo_template": "base-ref: {value}",
129
165
  "options": {
130
166
  "_RECOMMENDED_SUFFIX": " (recommended)",
@@ -134,10 +170,13 @@
134
170
  "branch_confirm": {
135
171
  "label": "{summary}",
136
172
  "labels": {
137
- "new": "새 브랜치 `{branch}` 를 base-ref `{base_ref}` 에서 worktree(`{path}`)에 생성합니다 — 진행할까요?",
138
- "reuse": "현재 worktree(`{path}`, 브랜치 `{branch}`) 재사용합니다 — 진행할까요?",
173
+ "new": "새 브랜치 `{branch}` 를 base-ref `{base_ref}` 에서 분기해 `{task_key}` 디렉터리(`{path}`)에 체크아웃합니다 — 진행할까요?",
174
+ "reuse": "기존 `{task_key}` 디렉터리(`{path}`, 브랜치 `{branch}`)에서 이어서 진행합니다 — 진행할까요?",
139
175
  "in_worktree": "현재 worktree(`{path}`)에서 그대로 진행합니다(이미 non-main worktree) — 진행할까요?",
140
- "not_git": "git 저장소가 아니므로 `{path}` 에서 직접 진행합니다 — 진행할까요?"
176
+ "not_git": "git 저장소가 아니므로 `{path}` 에서 직접 진행합니다 — 진행할까요?",
177
+ "impl_stage_new": "implementation 은 stage 격리로 동작합니다 — stage {stage} worktree 를 `{path}` 에 브랜치 `{branch}` 로 새로 만들고, base 커밋은 의존 stage 의 done 커밋 기준으로 run 준비 시점에 해소됩니다 — 진행할까요?",
178
+ "impl_stage_reuse": "기존 stage {stage} worktree(`{path}`, 브랜치 `{branch}`)에서 이어서 진행합니다 — 진행할까요?",
179
+ "impl_stage_auto": "implementation 은 stage 격리로 동작합니다 — stage 번호는 run 준비 시점에 자동 선택되고, `{task_key}` 디렉터리(`{path}`) 아래 `stage-<N>/` worktree 가 새로 만들어지거나 재사용됩니다 — 진행할까요?"
141
180
  },
142
181
  "options": { "proceed": "진행", "edit": "base-ref 다시 고르기", "abort": "중단" },
143
182
  "echo_template": "branch-confirm: {value}"
@@ -371,6 +410,10 @@
371
410
  "confirmation": {
372
411
  "header": "선택 확인:",
373
412
  "workers_implementation_default": " workers : (프로필 기본 — executor + verifier 2 + report-writer)",
374
- "stage_whole_task": "전체 task"
413
+ "base_ref_stage_isolated": " base-ref : (stage 격리 — 의존 stage 기준으로 run 준비 시점에 자동 해소)",
414
+ "base_ref_reuse_task_dir": " base-ref : (기존 `{task_key}` 디렉터리 재사용 — 최초 base 유지)",
415
+ "stage_whole_task": "전체 task",
416
+ "handoff_scope_whole_task": "전체 task (whole-task 검증 기반)",
417
+ "handoff_scope_stage_group": "stage-group ({stages})"
375
418
  }
376
419
  }
@@ -50,22 +50,52 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
50
50
  if status not in ("started", "done"):
51
51
  raise ValueError(f"status must be 'started' or 'done', got: {status!r}")
52
52
  with consumers_mutex(plan_run_root):
53
- existing = read_consumers(plan_run_root)
54
- for row in existing:
55
- if (row.get("impl_task_key") == impl_task_key
56
- and row.get("stage") == stage
57
- and row.get("status") == status):
58
- if not force_reappend:
59
- return # idempotent
60
- if row.get("head_commit") == fields.get("head_commit"):
61
- return # 동일 보정의 중복 재-append 방지
62
- record: Dict[str, Any] = {
63
- "impl_task_key": impl_task_key,
64
- "stage": stage,
65
- "status": status,
66
- **fields,
67
- }
68
- _append_row(plan_run_root, record)
53
+ if not _equivalent_row_exists(plan_run_root, impl_task_key, stage,
54
+ status, force_reappend,
55
+ fields.get("head_commit")):
56
+ record: Dict[str, Any] = {
57
+ "impl_task_key": impl_task_key,
58
+ "stage": stage,
59
+ "status": status,
60
+ **fields,
61
+ }
62
+ _append_row(plan_run_root, record)
63
+ # done 은 점유 해제 이벤트이기도 하다 — 중복 append(no-op)에서도 풀어야
64
+ # release 없이 done 만 기록된 과거 run 의 잔존 점유가 다음 호출에서 치유된다.
65
+ if status == "done":
66
+ _release_stage_reservation(impl_task_key, stage)
67
+
68
+
69
+ def _equivalent_row_exists(plan_run_root: Path, impl_task_key: str, stage: int,
70
+ status: str, force_reappend: bool,
71
+ head_commit: Any) -> bool:
72
+ for row in read_consumers(plan_run_root):
73
+ if (row.get("impl_task_key") == impl_task_key
74
+ and row.get("stage") == stage
75
+ and row.get("status") == status):
76
+ if not force_reappend:
77
+ return True
78
+ if row.get("head_commit") == head_commit:
79
+ return True # 동일 보정의 중복 재-append 방지
80
+ return False
81
+
82
+
83
+ def _release_stage_reservation(impl_task_key: str, stage: Any) -> None:
84
+ """done 이 기록된 stage 의 worktree-registry 점유(stage-key)를 해제한다.
85
+
86
+ release 는 점유 표시만 푼다 — worktree 디렉토리·브랜치는 보존된다.
87
+ registry 좌표는 TASK_KEY(`project:group:task`) 각 segment 의
88
+ safe-segment 와 같다(stage 예약이 그렇게 만들어진다). 형식이 다르면
89
+ 점유 주체가 아니므로 건너뛴다."""
90
+ parts = impl_task_key.split(":")
91
+ if len(parts) != 3 or not isinstance(stage, int):
92
+ return
93
+ from .ids import _safe_fs_segment
94
+ from . import worktree_registry
95
+ worktree_registry.release(
96
+ _safe_fs_segment(parts[0]), _safe_fs_segment(parts[1]),
97
+ _safe_fs_segment(parts[2]), stage_number=stage,
98
+ )
69
99
 
70
100
 
71
101
  def _append_row(plan_run_root: Path, record: Dict[str, Any]) -> None:
@@ -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]],