okstra 0.66.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/okstra +7 -0
  2. package/docs/kr/architecture.md +17 -1
  3. package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
  4. package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
  5. package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
  6. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
  7. package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
  8. package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
  9. package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
  10. package/package.json +1 -1
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/agents/SKILL.md +5 -4
  13. package/runtime/prompts/profiles/_common-contract.md +6 -6
  14. package/runtime/prompts/profiles/final-verification.md +3 -2
  15. package/runtime/prompts/profiles/release-handoff.md +12 -5
  16. package/runtime/prompts/wizard/prompts.ko.json +14 -4
  17. package/runtime/python/okstra_ctl/consumers.py +72 -5
  18. package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
  19. package/runtime/python/okstra_ctl/handoff.py +348 -0
  20. package/runtime/python/okstra_ctl/render.py +44 -2
  21. package/runtime/python/okstra_ctl/run.py +88 -27
  22. package/runtime/python/okstra_ctl/wizard.py +141 -36
  23. package/runtime/python/okstra_ctl/worktree.py +10 -0
  24. package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
  25. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  26. package/runtime/skills/okstra-convergence/SKILL.md +1 -1
  27. package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
  28. package/runtime/skills/okstra-run/SKILL.md +45 -5
  29. package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
  30. package/runtime/validators/validate-run.py +49 -9
  31. package/src/git-reconcile.mjs +31 -0
  32. package/src/handoff.mjs +30 -0
@@ -0,0 +1,1572 @@
1
+ # release-handoff stage-group 모드 구현 계획
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 한 task 안에서 단독-stage 검증 `accepted` 를 받은 stage 들을 골라 수집 브랜치로 머지하고 하나의 PR 로 내보내는 release-handoff stage-group 모드를 추가한다.
6
+
7
+ **Architecture:** 자격 판정·수집 머지·기록은 새 Python 모듈 `scripts/okstra_ctl/handoff.py` 한 곳에서 강제한다(`PrepareError` 패턴의 fail-fast). consumers.jsonl 에 `verified`/`pr` 행을 신설해 자격의 SSOT 로 삼고, worktree registry 에 그룹 키(`<task-key>#group-<id>`)를 추가한다. lead 는 `okstra handoff <sub>` Node 셔틀로 헬퍼만 호출한다. 스펙: [2026-06-10-stage-group-handoff-design.md](../specs/2026-06-10-stage-group-handoff-design.md).
8
+
9
+ **Tech Stack:** Python 3 (stdlib only — 기존 okstra_ctl 관례), Node ESM 셔틀(src/*.mjs), pytest, bash e2e.
10
+
11
+ ---
12
+
13
+ ## 파일 구조
14
+
15
+ | 파일 | 책임 |
16
+ |---|---|
17
+ | Modify [scripts/okstra_ctl/consumers.py](../../../scripts/okstra_ctl/consumers.py) | `verified`/`pr` 행 append + 리더 (`verified_accepted_stages`, `pr_covered_stages`) |
18
+ | Modify [scripts/okstra_ctl/worktree_registry.py](../../../scripts/okstra_ctl/worktree_registry.py) | 그룹 키 `#group-<id>` — `task_key`/`lookup`/`reserve`/`release` 확장, `WorktreeEntry.stages` |
19
+ | Modify [scripts/okstra_ctl/worktree.py](../../../scripts/okstra_ctl/worktree.py) | `compute_worktree_path`/`compute_branch_name` 에 `group_id` 변형 |
20
+ | Create `scripts/okstra_ctl/handoff.py` | 자격 판정(pure) + assemble(git) + record-verified/record-pr + argparse main |
21
+ | Create `src/handoff.mjs`, Modify [bin/okstra](../../../bin/okstra) | `okstra handoff` Node 셔틀 + 디스패치/도움말 등록 |
22
+ | Modify [validators/validate-run.py](../../../validators/validate-run.py) | single-stage → `release-handoff(stage-group)` 라우팅 허용 |
23
+ | Modify [prompts/profiles/release-handoff.md](../../../prompts/profiles/release-handoff.md), [prompts/profiles/final-verification.md](../../../prompts/profiles/final-verification.md) | stage-group 모드 계약 / 라우팅·record-verified 의무 |
24
+ | Modify 문서 3종 | isolation spec 비목표 주석, `docs/kr/architecture.md`, `CHANGES.md` |
25
+ | Create `tests/test_consumers_verified_pr_rows.py`, `tests/test_worktree_registry_group_key.py`, `tests/test_handoff_eligibility.py`, `tests/test_handoff_assemble.py`, `tests/test_handoff_records.py` | 단위 테스트 |
26
+ | Create `tests-e2e/scenario-13-stage-group-handoff.sh` | end-to-end 시나리오 |
27
+
28
+ 참고 사실(이미 확인됨):
29
+ - consumers 행 식별자는 `(impl_task_key, stage, status)`, 허용 status 는 `started|done` ([consumers.py:50](../../../scripts/okstra_ctl/consumers.py:50)).
30
+ - registry `reserve` 는 flock 안에서 task-key 중복·branch 소유 충돌을 RuntimeError 로 막는다 ([worktree_registry.py:132](../../../scripts/okstra_ctl/worktree_registry.py:132)). `release` 는 키 3-인자 고정 ([worktree_registry.py:283](../../../scripts/okstra_ctl/worktree_registry.py:283)).
31
+ - stage map 은 `_parse_stage_map_into_ctx(plan_path)` 가 `{"stage_number": int, "depends_on": [int], ...}` 리스트로 반환 ([run.py:611](../../../scripts/okstra_ctl/run.py:611)).
32
+ - branch/path 계산의 단일 참조점은 [worktree.py:489](../../../scripts/okstra_ctl/worktree.py:489) `compute_worktree_path` / [worktree.py:513](../../../scripts/okstra_ctl/worktree.py:513) `compute_branch_name`.
33
+ - Node 셔틀 패턴은 [src/git-reconcile.mjs](../../../src/git-reconcile.mjs) (`runPythonModule`), 디스패치 테이블은 [bin/okstra:24](../../../bin/okstra:24).
34
+ - 모든 pytest 는 conftest 가 `OKSTRA_HOME` 을 임시 디렉터리로 격리한다 ([tests/conftest.py:6](../../../tests/conftest.py:6)) — registry/worktree 가 사용자 홈을 오염시키지 않음.
35
+ - 검증 라우팅 enforcement 는 [validate-run.py:1441](../../../validators/validate-run.py:1441) (`single-stage` → release-handoff 금지).
36
+
37
+ 커밋 메시지 규칙: Conventional Commits, 구체적 scope, **Claude trailer 금지** (`Co-Authored-By` / `🤖 Generated` 라인 넣지 말 것).
38
+
39
+ ---
40
+
41
+ ### Task 1: consumers — `verified` / `pr` 행
42
+
43
+ **Files:**
44
+ - Modify: `scripts/okstra_ctl/consumers.py`
45
+ - Test: `tests/test_consumers_verified_pr_rows.py`
46
+
47
+ - [ ] **Step 1: 실패하는 테스트 작성**
48
+
49
+ `tests/test_consumers_verified_pr_rows.py` 생성:
50
+
51
+ ```python
52
+ """consumers.jsonl 의 verified / pr 행 계약.
53
+
54
+ verified: 단독-stage final-verification accepted 기록. (key, stage, 'verified',
55
+ report_path) 가 같으면 멱등, report_path 가 다르면 재검증으로 보고 append (last-wins).
56
+ pr: handoff 가 PR 생성/재사용 시 기록. 같은 branch 의 pr 행이 있으면 멱등.
57
+ """
58
+ from okstra_ctl import consumers
59
+
60
+ KEY = "proj/group/task"
61
+
62
+
63
+ def test_append_verified_roundtrip(tmp_path):
64
+ consumers.append_verified(
65
+ tmp_path, impl_task_key=KEY, stage=2,
66
+ verdict="accepted", report_path="reports/fv-2.md")
67
+ rows = consumers.read_consumers(tmp_path)
68
+ assert rows == [{
69
+ "impl_task_key": KEY, "stage": 2, "status": "verified",
70
+ "verdict": "accepted", "report_path": "reports/fv-2.md",
71
+ }]
72
+
73
+
74
+ def test_append_verified_idempotent_same_report(tmp_path):
75
+ for _ in range(2):
76
+ consumers.append_verified(
77
+ tmp_path, impl_task_key=KEY, stage=2,
78
+ verdict="accepted", report_path="reports/fv-2.md")
79
+ assert len(consumers.read_consumers(tmp_path)) == 1
80
+
81
+
82
+ def test_append_verified_reverification_appends(tmp_path):
83
+ consumers.append_verified(tmp_path, impl_task_key=KEY, stage=2,
84
+ verdict="blocked", report_path="reports/fv-2a.md")
85
+ consumers.append_verified(tmp_path, impl_task_key=KEY, stage=2,
86
+ verdict="accepted", report_path="reports/fv-2b.md")
87
+ assert len(consumers.read_consumers(tmp_path)) == 2
88
+
89
+
90
+ def test_verified_accepted_stages_last_wins(tmp_path):
91
+ consumers.append_verified(tmp_path, impl_task_key=KEY, stage=2,
92
+ verdict="accepted", report_path="a.md")
93
+ consumers.append_verified(tmp_path, impl_task_key=KEY, stage=3,
94
+ verdict="blocked", report_path="b.md")
95
+ # stage 2 가 이후 재검증에서 blocked 로 뒤집힘 → 제외돼야 함
96
+ consumers.append_verified(tmp_path, impl_task_key=KEY, stage=2,
97
+ verdict="blocked", report_path="c.md")
98
+ rows = consumers.read_consumers(tmp_path)
99
+ assert consumers.verified_accepted_stages(rows) == set()
100
+
101
+
102
+ def test_append_pr_and_covered_stages(tmp_path):
103
+ consumers.append_pr(tmp_path, impl_task_key=KEY, stages=[3, 2],
104
+ branch="feat-task-g2-3", url="https://example.com/pr/1")
105
+ rows = consumers.read_consumers(tmp_path)
106
+ assert rows[0]["stages"] == [2, 3] # 정렬 저장
107
+ assert consumers.pr_covered_stages(rows) == {2, 3}
108
+
109
+
110
+ def test_append_pr_idempotent_same_branch(tmp_path):
111
+ for _ in range(2):
112
+ consumers.append_pr(tmp_path, impl_task_key=KEY, stages=[2],
113
+ branch="feat-task-g2", url="https://example.com/pr/1")
114
+ assert len(consumers.read_consumers(tmp_path)) == 1
115
+
116
+
117
+ def test_append_consumer_still_rejects_unknown_status(tmp_path):
118
+ import pytest
119
+ with pytest.raises(ValueError):
120
+ consumers.append_consumer(tmp_path, impl_task_key=KEY, stage=1,
121
+ status="verified")
122
+ ```
123
+
124
+ - [ ] **Step 2: 실패 확인**
125
+
126
+ Run: `python3 -m pytest tests/test_consumers_verified_pr_rows.py -v`
127
+ Expected: FAIL — `AttributeError: module 'okstra_ctl.consumers' has no attribute 'append_verified'`
128
+
129
+ - [ ] **Step 3: 구현**
130
+
131
+ `scripts/okstra_ctl/consumers.py` 의 `append_consumer` 바로 아래에 추가 (공용 write 경로를 `_append_row` 로 추출해 `append_consumer` 마지막 두 줄도 이를 쓰도록 변경):
132
+
133
+ ```python
134
+ def _append_row(plan_run_root: Path, record: Dict[str, Any]) -> None:
135
+ with _path(plan_run_root).open("a", encoding="utf-8") as f:
136
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
137
+
138
+
139
+ def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
140
+ verdict: str, report_path: str) -> None:
141
+ """단독-stage final-verification 결과 기록. 같은 report_path 재기록은 멱등,
142
+ 다른 report_path 는 재검증으로 append 한다 (읽기는 last-wins)."""
143
+ with consumers_mutex(plan_run_root):
144
+ for row in read_consumers(plan_run_root):
145
+ if (row.get("impl_task_key") == impl_task_key
146
+ and row.get("stage") == stage
147
+ and row.get("status") == "verified"
148
+ and row.get("report_path") == report_path):
149
+ return
150
+ _append_row(plan_run_root, {
151
+ "impl_task_key": impl_task_key, "stage": stage,
152
+ "status": "verified", "verdict": verdict,
153
+ "report_path": report_path,
154
+ })
155
+
156
+
157
+ def append_pr(plan_run_root: Path, *, impl_task_key: str, stages: List[int],
158
+ branch: str, url: str) -> None:
159
+ """handoff 의 PR 생성/재사용 기록. 같은 branch 의 pr 행이 이미 있으면 멱등."""
160
+ with consumers_mutex(plan_run_root):
161
+ for row in read_consumers(plan_run_root):
162
+ if row.get("status") == "pr" and row.get("branch") == branch:
163
+ return
164
+ _append_row(plan_run_root, {
165
+ "impl_task_key": impl_task_key, "stages": sorted(stages),
166
+ "status": "pr", "branch": branch, "url": url,
167
+ })
168
+
169
+
170
+ def verified_accepted_stages(rows: List[Dict[str, Any]]) -> set:
171
+ """stage → 마지막 verified 행의 verdict 가 accepted 인 stage 집합 (last-wins)."""
172
+ last: Dict[int, str] = {}
173
+ for r in rows:
174
+ if r.get("status") == "verified" and isinstance(r.get("stage"), int):
175
+ last[r["stage"]] = (r.get("verdict") or "").strip().lower()
176
+ return {n for n, v in last.items() if v == "accepted"}
177
+
178
+
179
+ def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
180
+ out: set = set()
181
+ for r in rows:
182
+ if r.get("status") == "pr":
183
+ out.update(n for n in (r.get("stages") or []) if isinstance(n, int))
184
+ return out
185
+ ```
186
+
187
+ `append_consumer` 의 기존 파일-쓰기 두 줄(`with _path(...).open("a", ...)` 블록)은 `_append_row(plan_run_root, record)` 호출로 교체한다. `append_consumer` 의 `status not in ("started", "done")` 검증은 그대로 둔다 (started/done 전용 — verified/pr 는 전용 함수만 사용).
188
+
189
+ - [ ] **Step 4: 통과 확인**
190
+
191
+ Run: `python3 -m pytest tests/test_consumers_verified_pr_rows.py tests/test_consumers_jsonl.py tests/test_consumers_carry_backfill.py tests/test_consumers_report_path.py -v`
192
+ Expected: 전부 PASS (기존 consumers 테스트 회귀 없음)
193
+
194
+ - [ ] **Step 5: 커밋**
195
+
196
+ ```bash
197
+ git add scripts/okstra_ctl/consumers.py tests/test_consumers_verified_pr_rows.py
198
+ git commit -m "feat(okstra-ctl): consumers 에 verified/pr 행 추가 — stage-group handoff 자격 SSOT"
199
+ ```
200
+
201
+ ---
202
+
203
+ ### Task 2: worktree_registry — 그룹 키
204
+
205
+ **Files:**
206
+ - Modify: `scripts/okstra_ctl/worktree_registry.py`
207
+ - Test: `tests/test_worktree_registry_group_key.py`
208
+
209
+ - [ ] **Step 1: 실패하는 테스트 작성**
210
+
211
+ `tests/test_worktree_registry_group_key.py` 생성:
212
+
213
+ ```python
214
+ """registry 의 #group-<id> 키 — stage-key 와 동형의 예약/해제, 상호배타 검증."""
215
+ import pytest
216
+
217
+ from okstra_ctl import worktree_registry as reg
218
+
219
+ ARGS = dict(project_id="proj", task_group="grp", task_id="task")
220
+
221
+
222
+ def test_task_key_group_suffix():
223
+ assert reg.task_key(*ARGS.values(), group_id="g2-3") == "proj/grp/task#group-g2-3"
224
+
225
+
226
+ def test_task_key_stage_and_group_mutually_exclusive():
227
+ with pytest.raises(ValueError):
228
+ reg.task_key(*ARGS.values(), stage_number=2, group_id="g2-3")
229
+
230
+
231
+ def test_reserve_lookup_release_group(tmp_path):
232
+ entry = reg.reserve(
233
+ **ARGS, worktree_path=str(tmp_path / "wt"), branch="feat-task-g2-3",
234
+ base_ref="abc123", phase="release-handoff",
235
+ group_id="g2-3", stages=[2, 3])
236
+ assert entry.task_key == "proj/grp/task#group-g2-3"
237
+ assert entry.stages == [2, 3]
238
+
239
+ found = reg.lookup(**ARGS, group_id="g2-3")
240
+ assert found is not None and found.branch == "feat-task-g2-3"
241
+
242
+ # 같은 그룹 키 재예약은 거부
243
+ with pytest.raises(RuntimeError):
244
+ reg.reserve(**ARGS, worktree_path=str(tmp_path / "wt2"),
245
+ branch="feat-task-g2-3b", base_ref="abc123",
246
+ group_id="g2-3", stages=[2, 3])
247
+
248
+ released = reg.release(**ARGS, group_id="g2-3")
249
+ assert released is not None and released.status == "released"
250
+ # 해제 후 branch 슬롯이 풀려 재예약 가능
251
+ reg.reserve(**ARGS, worktree_path=str(tmp_path / "wt3"),
252
+ branch="feat-task-g2-3", base_ref="abc123",
253
+ group_id="g2-3", stages=[2, 3])
254
+
255
+
256
+ def test_group_key_does_not_collide_with_stage_key(tmp_path):
257
+ reg.reserve(**ARGS, worktree_path=str(tmp_path / "s2"), branch="feat-task-s2",
258
+ base_ref="abc", stage_number=2)
259
+ entry = reg.reserve(**ARGS, worktree_path=str(tmp_path / "g"), branch="feat-task-g2",
260
+ base_ref="abc", group_id="g2", stages=[2])
261
+ assert entry.task_key.endswith("#group-g2")
262
+ assert reg.lookup(**ARGS, stage_number=2).task_key.endswith("#stage-2")
263
+ ```
264
+
265
+ - [ ] **Step 2: 실패 확인**
266
+
267
+ Run: `python3 -m pytest tests/test_worktree_registry_group_key.py -v`
268
+ Expected: FAIL — `TypeError: task_key() got an unexpected keyword argument 'group_id'`
269
+
270
+ - [ ] **Step 3: 구현**
271
+
272
+ `scripts/okstra_ctl/worktree_registry.py` 수정 4곳:
273
+
274
+ (a) `task_key` ([worktree_registry.py:46](../../../scripts/okstra_ctl/worktree_registry.py:46)) 교체:
275
+
276
+ ```python
277
+ def task_key(
278
+ project_id: str, task_group: str, task_id: str,
279
+ stage_number: Optional[int] = None,
280
+ group_id: Optional[str] = None,
281
+ ) -> str:
282
+ """Canonical task-key. stage_number → `#stage-<N>` (per-stage worktree),
283
+ group_id → `#group-<id>` (stage-group collector worktree). 둘은 상호배타."""
284
+ if stage_number is not None and group_id is not None:
285
+ raise ValueError("stage_number and group_id are mutually exclusive")
286
+ base = f"{project_id}/{task_group}/{task_id}"
287
+ if stage_number is not None:
288
+ return f"{base}#stage-{stage_number}"
289
+ if group_id is not None:
290
+ return f"{base}#group-{group_id}"
291
+ return base
292
+ ```
293
+
294
+ (b) `WorktreeEntry` ([worktree_registry.py:59](../../../scripts/okstra_ctl/worktree_registry.py:59)) 에 필드 추가 (기존 행에는 없는 키이므로 default 필수):
295
+
296
+ ```python
297
+ stages: Optional[list] = None
298
+ ```
299
+
300
+ (c) `lookup` / `reserve` 시그니처에 `group_id: Optional[str] = None` 추가, `reserve` 에는 `stages: Optional[list] = None` 도 추가. 두 함수의 `task_key(...)` 호출에 `group_id=group_id` 전달. `reserve` 의 `row` dict 에 `"stages": stages,` 한 줄 추가 ([worktree_registry.py:166](../../../scripts/okstra_ctl/worktree_registry.py:166) row 구성부).
301
+
302
+ (d) `release` ([worktree_registry.py:283](../../../scripts/okstra_ctl/worktree_registry.py:283)) 시그니처를 `release(project_id, task_group, task_id, stage_number=None, group_id=None)` 로 확장하고 `key = task_key(project_id, task_group, task_id, stage_number, group_id)` 로 변경.
303
+
304
+ - [ ] **Step 4: 통과 확인**
305
+
306
+ Run: `python3 -m pytest tests/test_worktree_registry_group_key.py tests/test_get_stage_row.py -v && python3 -m pytest tests/ -q -k "registry or worktree" `
307
+ Expected: 전부 PASS
308
+
309
+ - [ ] **Step 5: 커밋**
310
+
311
+ ```bash
312
+ git add scripts/okstra_ctl/worktree_registry.py tests/test_worktree_registry_group_key.py
313
+ git commit -m "feat(okstra-ctl): worktree registry 에 stage-group 그룹 키 예약 추가"
314
+ ```
315
+
316
+ ---
317
+
318
+ ### Task 3: worktree.py — 그룹 경로/브랜치 계산
319
+
320
+ **Files:**
321
+ - Modify: `scripts/okstra_ctl/worktree.py:489-524`
322
+ - Test: `tests/test_handoff_eligibility.py` (이 task 에서는 compute 함수 테스트만 먼저 담는다)
323
+
324
+ - [ ] **Step 1: 실패하는 테스트 작성**
325
+
326
+ `tests/test_handoff_eligibility.py` 생성 (Task 4 가 같은 파일에 자격 테스트를 추가한다):
327
+
328
+ ```python
329
+ """stage-group handoff — 경로/브랜치 계산과 자격 판정(pure)."""
330
+ import pytest
331
+
332
+ from okstra_ctl.worktree import compute_branch_name, compute_worktree_path
333
+
334
+
335
+ def test_compute_branch_name_group():
336
+ assert compute_branch_name(
337
+ work_category="feature-development", task_id_segment="dev-9184",
338
+ group_id="g2-3",
339
+ ).endswith("-dev-9184-g2-3")
340
+
341
+
342
+ def test_compute_worktree_path_group():
343
+ p = compute_worktree_path(
344
+ project_id="proj", task_group_segment="grp", task_id_segment="task",
345
+ group_id="g2-3",
346
+ )
347
+ assert p.parts[-1] == "group-g2-3"
348
+ assert p.parts[-2] == "task"
349
+
350
+
351
+ def test_compute_stage_and_group_mutually_exclusive():
352
+ with pytest.raises(ValueError):
353
+ compute_branch_name(work_category="feature-development",
354
+ task_id_segment="t", stage_number=2, group_id="g2")
355
+ with pytest.raises(ValueError):
356
+ compute_worktree_path(project_id="p", task_group_segment="g",
357
+ task_id_segment="t", stage_number=2, group_id="g2")
358
+ ```
359
+
360
+ - [ ] **Step 2: 실패 확인**
361
+
362
+ Run: `python3 -m pytest tests/test_handoff_eligibility.py -v`
363
+ Expected: FAIL — `TypeError: ... unexpected keyword argument 'group_id'`
364
+
365
+ - [ ] **Step 3: 구현**
366
+
367
+ [worktree.py:489](../../../scripts/okstra_ctl/worktree.py:489) `compute_worktree_path` 와 [worktree.py:513](../../../scripts/okstra_ctl/worktree.py:513) `compute_branch_name` 에 `group_id: Optional[str] = None` 파라미터 추가. 양쪽 모두 함수 첫머리에:
368
+
369
+ ```python
370
+ if stage_number is not None and group_id is not None:
371
+ raise ValueError("stage_number and group_id are mutually exclusive")
372
+ ```
373
+
374
+ `compute_worktree_path` 의 마지막 경로 결합부: `stage_number` 분기와 대칭으로 `group_id` 가 있으면 `/ f"group-{group_id}"` 세그먼트를 덧붙인다. `compute_branch_name` 은 `stage_number` 분기와 대칭으로:
375
+
376
+ ```python
377
+ if group_id is not None:
378
+ name = f"{name}-{group_id}"
379
+ ```
380
+
381
+ (`group_id` 가 이미 `g2-3` 형태이므로 접두 `g` 를 다시 붙이지 않는다 — 브랜치는 `feat-dev-9184-g2-3`.)
382
+
383
+ - [ ] **Step 4: 통과 확인**
384
+
385
+ Run: `python3 -m pytest tests/test_handoff_eligibility.py -v && python3 -m pytest tests/ -q -k worktree`
386
+ Expected: PASS
387
+
388
+ - [ ] **Step 5: 커밋**
389
+
390
+ ```bash
391
+ git add scripts/okstra_ctl/worktree.py tests/test_handoff_eligibility.py
392
+ git commit -m "feat(okstra-ctl): worktree 경로/브랜치 계산에 group_id 변형 추가"
393
+ ```
394
+
395
+ ---
396
+
397
+ ### Task 4: handoff.py — 자격 판정 (pure)
398
+
399
+ **Files:**
400
+ - Create: `scripts/okstra_ctl/handoff.py`
401
+ - Test: `tests/test_handoff_eligibility.py` (추가)
402
+
403
+ - [ ] **Step 1: 실패하는 테스트 추가**
404
+
405
+ `tests/test_handoff_eligibility.py` 에 append:
406
+
407
+ ```python
408
+ from okstra_ctl import handoff
409
+
410
+
411
+ STAGE_MAP = [
412
+ {"stage_number": 1, "depends_on": []},
413
+ {"stage_number": 2, "depends_on": []},
414
+ {"stage_number": 3, "depends_on": [2]},
415
+ ]
416
+
417
+
418
+ def _rows(*, done=(), verified=(), pr=()):
419
+ rows = []
420
+ for n in done:
421
+ rows.append({"impl_task_key": "k", "stage": n, "status": "done",
422
+ "head_commit": f"head{n}"})
423
+ for n in verified:
424
+ rows.append({"impl_task_key": "k", "stage": n, "status": "verified",
425
+ "verdict": "accepted", "report_path": f"fv-{n}.md"})
426
+ if pr:
427
+ rows.append({"impl_task_key": "k", "stages": list(pr), "status": "pr",
428
+ "branch": "b", "url": "u"})
429
+ return rows
430
+
431
+
432
+ def test_group_id_for_sorts():
433
+ assert handoff.group_id_for([3, 2]) == "g2-3"
434
+
435
+
436
+ def test_eligibility_reasons():
437
+ rows = _rows(done=[1, 2], verified=[2], pr=[1])
438
+ by_stage = {e["stage"]: e for e in handoff.compute_eligibility(STAGE_MAP, rows)}
439
+ assert by_stage[2]["eligible"] is True
440
+ assert by_stage[1]["reasons"] == ["already-in-pr"]
441
+ assert set(by_stage[3]["reasons"]) == {"not-done", "not-verified-accepted"}
442
+ assert by_stage[2]["head_commit"] == "head2"
443
+
444
+
445
+ def test_dependency_closure_blocks_unselected_unmerged_predecessor():
446
+ rows = _rows(done=[2, 3], verified=[2, 3])
447
+ done = {2: {"head_commit": "head2"}, 3: {"head_commit": "head3"}}
448
+ violations = handoff.check_dependency_closure(
449
+ [3], STAGE_MAP, done, is_merged_to_base=lambda c: False)
450
+ assert violations == [(3, 2)]
451
+ # 선행이 base 에 이미 머지됐으면 통과
452
+ assert handoff.check_dependency_closure(
453
+ [3], STAGE_MAP, done, is_merged_to_base=lambda c: True) == []
454
+ # 선행이 같은 그룹에 선택돼도 통과
455
+ assert handoff.check_dependency_closure(
456
+ [2, 3], STAGE_MAP, done, is_merged_to_base=lambda c: False) == []
457
+ ```
458
+
459
+ - [ ] **Step 2: 실패 확인**
460
+
461
+ Run: `python3 -m pytest tests/test_handoff_eligibility.py -v`
462
+ Expected: FAIL — `ImportError: cannot import name 'handoff'`
463
+
464
+ - [ ] **Step 3: 구현**
465
+
466
+ `scripts/okstra_ctl/handoff.py` 생성:
467
+
468
+ ```python
469
+ """release-handoff stage-group 모드의 강제 지점.
470
+
471
+ 자격 판정(eligible) · 수집 브랜치 생성+머지(assemble) · verified/pr 행 기록을
472
+ 단일 모듈로 강제한다. lead 는 `okstra handoff <sub>` 로 호출만 한다.
473
+ 설계: docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md
474
+ """
475
+
476
+ from __future__ import annotations
477
+
478
+ import json
479
+ import subprocess
480
+ from pathlib import Path
481
+ from typing import Any, Callable, Dict, List, Optional, Tuple
482
+
483
+ from . import consumers, worktree_registry
484
+ from .worktree import compute_branch_name, compute_worktree_path
485
+
486
+
487
+ class HandoffError(Exception):
488
+ """자격/전제 위반 — exit 1, actionable 메시지."""
489
+
490
+
491
+ class HandoffConflict(Exception):
492
+ """stage 간 merge 충돌 — exit 2, 충돌 경로 동봉."""
493
+
494
+ def __init__(self, stage: int, paths: List[str]):
495
+ self.stage = stage
496
+ self.paths = paths
497
+ super().__init__(
498
+ f"merge conflict while merging stage {stage}: {', '.join(paths)}")
499
+
500
+
501
+ def group_id_for(stages: List[int]) -> str:
502
+ return "g" + "-".join(str(n) for n in sorted(stages))
503
+
504
+
505
+ def compute_eligibility(stage_map: List[Dict[str, Any]],
506
+ rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
507
+ """stage 별 PR 가능 여부와 차단 사유. 의존 폐포는 선택 집합의 속성이라
508
+ 여기서는 판정하지 않는다 (assemble 의 check_dependency_closure 가 담당)."""
509
+ done = consumers.latest_done_by_stage(rows)
510
+ verified = consumers.verified_accepted_stages(rows)
511
+ in_pr = consumers.pr_covered_stages(rows)
512
+ out = []
513
+ for s in stage_map:
514
+ n = s["stage_number"]
515
+ reasons = []
516
+ if n not in done:
517
+ reasons.append("not-done")
518
+ if n not in verified:
519
+ reasons.append("not-verified-accepted")
520
+ if n in in_pr:
521
+ reasons.append("already-in-pr")
522
+ out.append({
523
+ "stage": n,
524
+ "depends_on": list(s["depends_on"]),
525
+ "eligible": not reasons,
526
+ "reasons": reasons,
527
+ "head_commit": (done.get(n) or {}).get("head_commit", ""),
528
+ })
529
+ return out
530
+
531
+
532
+ def check_dependency_closure(
533
+ selected: List[int],
534
+ stage_map: List[Dict[str, Any]],
535
+ done_by_stage: Dict[int, Dict[str, Any]],
536
+ is_merged_to_base: Callable[[str], bool],
537
+ ) -> List[Tuple[int, int]]:
538
+ """선택 집합의 의존 폐포 위반 목록 [(stage, 미충족 선행)].
539
+ 선행이 같은 그룹에 선택됐거나 이미 base 에 머지된 경우만 허용."""
540
+ sel = set(selected)
541
+ by_n = {s["stage_number"]: s for s in stage_map}
542
+ violations = []
543
+ for n in sorted(selected):
544
+ for d in by_n[n]["depends_on"]:
545
+ if d in sel:
546
+ continue
547
+ head = (done_by_stage.get(d) or {}).get("head_commit", "")
548
+ if not head or not is_merged_to_base(head):
549
+ violations.append((n, d))
550
+ return violations
551
+ ```
552
+
553
+ - [ ] **Step 4: 통과 확인**
554
+
555
+ Run: `python3 -m pytest tests/test_handoff_eligibility.py -v`
556
+ Expected: PASS
557
+
558
+ - [ ] **Step 5: 커밋**
559
+
560
+ ```bash
561
+ git add scripts/okstra_ctl/handoff.py tests/test_handoff_eligibility.py
562
+ git commit -m "feat(okstra-ctl): handoff 자격 판정·의존 폐포 검사 추가"
563
+ ```
564
+
565
+ ---
566
+
567
+ ### Task 5: handoff.py — assemble (수집 브랜치 생성 + 머지)
568
+
569
+ **Files:**
570
+ - Modify: `scripts/okstra_ctl/handoff.py`
571
+ - Test: `tests/test_handoff_assemble.py`
572
+
573
+ - [ ] **Step 1: 실패하는 테스트 작성**
574
+
575
+ `tests/test_handoff_assemble.py` 생성. 실제 임시 git repo(+bare origin)로 검증한다:
576
+
577
+ ```python
578
+ """handoff.assemble — 수집 브랜치 생성/머지/멱등/충돌/폐포의 git 실증."""
579
+ import json
580
+ import subprocess
581
+ from pathlib import Path
582
+
583
+ import pytest
584
+
585
+ from okstra_ctl import consumers, handoff, worktree_registry
586
+ from okstra_ctl.worktree import compute_branch_name
587
+
588
+ ARGS = dict(project_id="proj", task_group="grp", task_id="task")
589
+ WC = "feature-development"
590
+
591
+
592
+ def _stage_branch(n):
593
+ # 머지 대상 브랜치명은 assemble 과 동일한 단일 참조점으로 계산해야
594
+ # work-category prefix 가 바뀌어도 fixture 가 깨지지 않는다.
595
+ return compute_branch_name(work_category=WC, task_id_segment=ARGS["task_id"],
596
+ stage_number=n)
597
+
598
+
599
+ def _git(repo, *args):
600
+ r = subprocess.run(["git", *args], cwd=str(repo),
601
+ capture_output=True, text=True)
602
+ assert r.returncode == 0, r.stderr
603
+ return r.stdout.strip()
604
+
605
+
606
+ def _commit_file(repo, name, content, msg):
607
+ Path(repo, name).write_text(content)
608
+ _git(repo, "add", name)
609
+ _git(repo, "commit", "-m", msg)
610
+ return _git(repo, "rev-parse", "HEAD")
611
+
612
+
613
+ @pytest.fixture
614
+ def repo(tmp_path):
615
+ """base 커밋 1개 + 독립 stage 2/3 브랜치 + bare origin. 반환: (repo, base, heads)"""
616
+ repo = tmp_path / "repo"
617
+ repo.mkdir()
618
+ _git(repo, "init", "-b", "main")
619
+ _git(repo, "config", "user.email", "t@t")
620
+ _git(repo, "config", "user.name", "t")
621
+ base = _commit_file(repo, "base.txt", "base\n", "base")
622
+ heads = {}
623
+ for n, fname in ((2, "s2.txt"), (3, "s3.txt")):
624
+ _git(repo, "checkout", "-b", _stage_branch(n), base)
625
+ heads[n] = _commit_file(repo, fname, f"stage{n}\n", f"stage {n}")
626
+ _git(repo, "checkout", "main")
627
+ origin = tmp_path / "origin.git"
628
+ subprocess.run(["git", "init", "--bare", str(origin)], check=True,
629
+ capture_output=True)
630
+ _git(repo, "remote", "add", "origin", str(origin))
631
+ _git(repo, "push", "origin", "main")
632
+ worktree_registry.set_implementation_base(**ARGS, commit=base)
633
+ return repo, base, heads
634
+
635
+
636
+ def _seed_rows(plan_root, heads, stages):
637
+ for n in stages:
638
+ consumers.append_consumer(plan_root, impl_task_key="k", stage=n,
639
+ status="done", head_commit=heads[n])
640
+ consumers.append_verified(plan_root, impl_task_key="k", stage=n,
641
+ verdict="accepted", report_path=f"fv-{n}.md")
642
+
643
+
644
+ STAGE_MAP = [
645
+ {"stage_number": 2, "depends_on": []},
646
+ {"stage_number": 3, "depends_on": []},
647
+ ]
648
+
649
+
650
+ def test_assemble_merges_selected_stages(repo, tmp_path):
651
+ repo_dir, base, heads = repo
652
+ plan_root = tmp_path / "plan"
653
+ plan_root.mkdir()
654
+ _seed_rows(plan_root, heads, [2, 3])
655
+
656
+ result = handoff.assemble(
657
+ project_root=repo_dir, plan_run_root=plan_root, stage_map=STAGE_MAP,
658
+ stages=[2, 3], base_branch="main", work_category=WC, **ARGS)
659
+
660
+ assert result["branch"].endswith("-task-g2-3")
661
+ assert result["reused"] is False
662
+ wt = Path(result["worktree_path"])
663
+ # 두 stage head 모두 수집 브랜치 HEAD 의 ancestor
664
+ for h in heads.values():
665
+ subprocess.run(["git", "merge-base", "--is-ancestor", h,
666
+ result["head"]], cwd=str(wt), check=True)
667
+
668
+
669
+ def test_assemble_is_idempotent(repo, tmp_path):
670
+ repo_dir, base, heads = repo
671
+ plan_root = tmp_path / "plan"
672
+ plan_root.mkdir()
673
+ _seed_rows(plan_root, heads, [2, 3])
674
+ first = handoff.assemble(
675
+ project_root=repo_dir, plan_run_root=plan_root, stage_map=STAGE_MAP,
676
+ stages=[2, 3], base_branch="main", work_category=WC, **ARGS)
677
+ second = handoff.assemble(
678
+ project_root=repo_dir, plan_run_root=plan_root, stage_map=STAGE_MAP,
679
+ stages=[2, 3], base_branch="main", work_category=WC, **ARGS)
680
+ assert second["reused"] is True
681
+ assert second["head"] == first["head"]
682
+
683
+
684
+ def test_assemble_rejects_ineligible_stage(repo, tmp_path):
685
+ repo_dir, base, heads = repo
686
+ plan_root = tmp_path / "plan"
687
+ plan_root.mkdir()
688
+ _seed_rows(plan_root, heads, [2]) # stage 3 은 미검증
689
+ with pytest.raises(handoff.HandoffError, match="not eligible"):
690
+ handoff.assemble(
691
+ project_root=repo_dir, plan_run_root=plan_root, stage_map=STAGE_MAP,
692
+ stages=[2, 3], base_branch="main", work_category=WC, **ARGS)
693
+
694
+
695
+ def test_assemble_conflict_aborts_and_cleans_up(repo, tmp_path):
696
+ repo_dir, base, heads = repo
697
+ # 같은 파일을 두 stage 가 다르게 수정 → 충돌
698
+ _git(repo_dir, "checkout", _stage_branch(2))
699
+ heads[2] = _commit_file(repo_dir, "shared.txt", "from s2\n", "s2 shared")
700
+ _git(repo_dir, "checkout", _stage_branch(3))
701
+ heads[3] = _commit_file(repo_dir, "shared.txt", "from s3\n", "s3 shared")
702
+ _git(repo_dir, "checkout", "main")
703
+ plan_root = tmp_path / "plan"
704
+ plan_root.mkdir()
705
+ _seed_rows(plan_root, heads, [2, 3])
706
+
707
+ with pytest.raises(handoff.HandoffConflict) as exc:
708
+ handoff.assemble(
709
+ project_root=repo_dir, plan_run_root=plan_root, stage_map=STAGE_MAP,
710
+ stages=[2, 3], base_branch="main", work_category=WC, **ARGS)
711
+ assert "shared.txt" in exc.value.paths
712
+ # 정리됨: 그룹 키 해제(또는 부재) + 브랜치 삭제
713
+ entry = worktree_registry.lookup(**ARGS, group_id="g2-3")
714
+ assert entry is None or entry.status == "released"
715
+ collector = compute_branch_name(work_category=WC,
716
+ task_id_segment=ARGS["task_id"],
717
+ group_id="g2-3")
718
+ r = subprocess.run(["git", "rev-parse", "--verify", "--quiet", collector],
719
+ cwd=str(repo_dir), capture_output=True)
720
+ assert r.returncode != 0
721
+
722
+
723
+ def test_assemble_dependency_closure_violation(repo, tmp_path):
724
+ repo_dir, base, heads = repo
725
+ stage_map = [{"stage_number": 2, "depends_on": []},
726
+ {"stage_number": 3, "depends_on": [2]}]
727
+ plan_root = tmp_path / "plan"
728
+ plan_root.mkdir()
729
+ _seed_rows(plan_root, heads, [2, 3])
730
+ with pytest.raises(handoff.HandoffError, match="depends on stage 2"):
731
+ handoff.assemble(
732
+ project_root=repo_dir, plan_run_root=plan_root, stage_map=stage_map,
733
+ stages=[3], base_branch="main", work_category=WC, **ARGS)
734
+ ```
735
+
736
+ - [ ] **Step 2: 실패 확인**
737
+
738
+ Run: `python3 -m pytest tests/test_handoff_assemble.py -v`
739
+ Expected: FAIL — `AttributeError: ... no attribute 'assemble'`
740
+
741
+ - [ ] **Step 3: 구현**
742
+
743
+ `scripts/okstra_ctl/handoff.py` 에 추가. 함수 50줄 상한을 지키기 위해 단계별 헬퍼로 분해한다:
744
+
745
+ ```python
746
+ def _run_git(args: List[str], cwd, check: bool = True) -> subprocess.CompletedProcess:
747
+ r = subprocess.run(["git", *args], cwd=str(cwd),
748
+ capture_output=True, text=True)
749
+ if check and r.returncode != 0:
750
+ raise HandoffError(f"git {' '.join(args)} failed: {r.stderr.strip()}")
751
+ return r
752
+
753
+
754
+ def _require_eligible(stage_map, rows, stages) -> Dict[int, Dict[str, Any]]:
755
+ elig = {e["stage"]: e for e in compute_eligibility(stage_map, rows)}
756
+ unknown = [n for n in stages if n not in elig]
757
+ if unknown:
758
+ raise HandoffError(f"stages not in Stage Map: {unknown}")
759
+ bad = {n: elig[n]["reasons"] for n in stages if elig[n]["reasons"]}
760
+ if bad:
761
+ raise HandoffError(f"stages not eligible: {bad}")
762
+ return elig
763
+
764
+
765
+ def _require_closure(stages, stage_map, done, project_root, base_branch) -> None:
766
+ def merged(commit: str) -> bool:
767
+ return _run_git(["merge-base", "--is-ancestor", commit,
768
+ f"origin/{base_branch}"], project_root,
769
+ check=False).returncode == 0
770
+ violations = check_dependency_closure(stages, stage_map, done, merged)
771
+ if violations:
772
+ lines = [
773
+ f"stage {n} depends on stage {d} which is neither selected nor "
774
+ f"merged into origin/{base_branch} — include stage {d} in the "
775
+ "group or PR-merge it first"
776
+ for n, d in violations
777
+ ]
778
+ raise HandoffError("dependency closure violated:\n" + "\n".join(lines))
779
+
780
+
781
+ def _reuse_existing(entry, stages, done, project_root) -> Dict[str, Any]:
782
+ head = _run_git(["rev-parse", entry.branch], project_root).stdout.strip()
783
+ for n in stages:
784
+ h = (done.get(n) or {}).get("head_commit", "")
785
+ if _run_git(["merge-base", "--is-ancestor", h, head], project_root,
786
+ check=False).returncode != 0:
787
+ raise HandoffError(
788
+ f"collector branch {entry.branch} exists but stage {n} head "
789
+ f"{h} is not merged into it — remove the branch/worktree and "
790
+ "the registry group key, then retry")
791
+ return {"ok": True, "reused": True, "group_id": group_id_for(stages),
792
+ "branch": entry.branch, "worktree_path": entry.worktree_path,
793
+ "head": head, "stages": sorted(stages), "merge_commits": []}
794
+
795
+
796
+ def _cleanup_group(project_root, wt_path, branch, project_id, task_group,
797
+ task_id, gid) -> None:
798
+ _run_git(["worktree", "remove", "--force", str(wt_path)], project_root,
799
+ check=False)
800
+ _run_git(["branch", "-D", branch], project_root, check=False)
801
+ worktree_registry.release(project_id, task_group, task_id, group_id=gid)
802
+
803
+
804
+ def _merge_stages(wt_path, stages, work_category, task_id, project_root,
805
+ project_id, task_group, gid, branch) -> List[str]:
806
+ merge_commits = []
807
+ for n in sorted(stages):
808
+ stage_branch = compute_branch_name(
809
+ work_category=work_category, task_id_segment=task_id,
810
+ stage_number=n)
811
+ r = _run_git(["merge", "--no-edit", stage_branch], wt_path, check=False)
812
+ if r.returncode != 0:
813
+ paths = _run_git(["diff", "--name-only", "--diff-filter=U"],
814
+ wt_path).stdout.split()
815
+ _run_git(["merge", "--abort"], wt_path, check=False)
816
+ _cleanup_group(project_root, wt_path, branch, project_id,
817
+ task_group, task_id, gid)
818
+ raise HandoffConflict(stage=n, paths=paths)
819
+ merge_commits.append(
820
+ _run_git(["rev-parse", "HEAD"], wt_path).stdout.strip())
821
+ return merge_commits
822
+
823
+
824
+ def assemble(*, project_root, plan_run_root, stage_map, stages, base_branch,
825
+ work_category, project_id, task_group, task_id) -> Dict[str, Any]:
826
+ """수집 브랜치를 만들고 선택 stage 브랜치들을 머지한다. 멱등."""
827
+ stages = sorted(set(stages))
828
+ rows = consumers.read_consumers(Path(plan_run_root))
829
+ _require_eligible(stage_map, rows, stages)
830
+ _run_git(["fetch", "origin", base_branch], project_root)
831
+ done = consumers.latest_done_by_stage(rows)
832
+ _require_closure(stages, stage_map, done, project_root, base_branch)
833
+
834
+ base_commit = worktree_registry.get_implementation_base(
835
+ project_id, task_group, task_id)
836
+ if not base_commit:
837
+ raise HandoffError(
838
+ "implementation_base_commit not recorded in worktree registry — "
839
+ "run at least one implementation stage first")
840
+
841
+ gid = group_id_for(stages)
842
+ existing = worktree_registry.lookup(
843
+ project_id, task_group, task_id, group_id=gid)
844
+ if existing and existing.status == "active":
845
+ return _reuse_existing(existing, stages, done, project_root)
846
+
847
+ branch = compute_branch_name(work_category=work_category,
848
+ task_id_segment=task_id, group_id=gid)
849
+ wt_path = compute_worktree_path(
850
+ project_id=project_id, task_group_segment=task_group,
851
+ task_id_segment=task_id, group_id=gid)
852
+ worktree_registry.reserve(
853
+ project_id=project_id, task_group=task_group, task_id=task_id,
854
+ worktree_path=str(wt_path), branch=branch, base_ref=base_commit,
855
+ phase="release-handoff", group_id=gid, stages=stages)
856
+ _run_git(["worktree", "add", "-b", branch, str(wt_path), base_commit],
857
+ project_root)
858
+ merge_commits = _merge_stages(wt_path, stages, work_category, task_id,
859
+ project_root, project_id, task_group, gid,
860
+ branch)
861
+ head = merge_commits[-1] if merge_commits else base_commit
862
+ return {"ok": True, "reused": False, "group_id": gid, "branch": branch,
863
+ "worktree_path": str(wt_path), "head": head, "stages": stages,
864
+ "merge_commits": merge_commits}
865
+ ```
866
+
867
+ - [ ] **Step 4: 통과 확인**
868
+
869
+ Run: `python3 -m pytest tests/test_handoff_assemble.py tests/test_handoff_eligibility.py -v`
870
+ Expected: PASS
871
+
872
+ - [ ] **Step 5: 커밋**
873
+
874
+ ```bash
875
+ git add scripts/okstra_ctl/handoff.py tests/test_handoff_assemble.py
876
+ git commit -m "feat(okstra-ctl): handoff assemble — 수집 브랜치 생성·머지·멱등·충돌 정리"
877
+ ```
878
+
879
+ ---
880
+
881
+ ### Task 6: handoff.py — record-verified / record-pr + CLI main
882
+
883
+ **Files:**
884
+ - Modify: `scripts/okstra_ctl/handoff.py`
885
+ - Test: `tests/test_handoff_records.py`
886
+
887
+ - [ ] **Step 1: 실패하는 테스트 작성**
888
+
889
+ `tests/test_handoff_records.py` 생성:
890
+
891
+ ```python
892
+ """record-verified / record-pr — data.json 게이트와 consumers 기록, CLI exit code."""
893
+ import json
894
+ import subprocess
895
+ import sys
896
+ from pathlib import Path
897
+
898
+ import pytest
899
+
900
+ from okstra_ctl import consumers, handoff
901
+
902
+
903
+ def _fv_data(tmp_path, *, scope="single-stage", token="accepted"):
904
+ p = tmp_path / "data.json"
905
+ p.write_text(json.dumps({
906
+ "header": {"taskType": "final-verification"},
907
+ "verificationScope": scope,
908
+ "finalVerdict": {"verdictToken": token},
909
+ }))
910
+ return p
911
+
912
+
913
+ def _seed_done(plan_root, stage=2):
914
+ consumers.append_consumer(plan_root, impl_task_key="proj/grp/task",
915
+ stage=stage, status="done", head_commit="h")
916
+
917
+
918
+ def test_record_verified_accepts_single_stage_accepted(tmp_path):
919
+ plan = tmp_path / "plan"
920
+ plan.mkdir()
921
+ _seed_done(plan)
922
+ handoff.record_verified(plan_run_root=plan, stage=2,
923
+ report_path="reports/fv-2.md",
924
+ data_json=_fv_data(tmp_path))
925
+ rows = consumers.read_consumers(plan)
926
+ assert any(r["status"] == "verified" and r["stage"] == 2 for r in rows)
927
+ # impl_task_key 는 done 행에서 승계
928
+ vrow = [r for r in rows if r["status"] == "verified"][0]
929
+ assert vrow["impl_task_key"] == "proj/grp/task"
930
+
931
+
932
+ @pytest.mark.parametrize("scope,token", [
933
+ ("whole-task", "accepted"),
934
+ ("single-stage", "blocked"),
935
+ ])
936
+ def test_record_verified_rejects_wrong_scope_or_token(tmp_path, scope, token):
937
+ plan = tmp_path / "plan"
938
+ plan.mkdir()
939
+ _seed_done(plan)
940
+ with pytest.raises(handoff.HandoffError):
941
+ handoff.record_verified(plan_run_root=plan, stage=2,
942
+ report_path="r.md",
943
+ data_json=_fv_data(tmp_path, scope=scope,
944
+ token=token))
945
+
946
+
947
+ def test_record_verified_requires_done_row(tmp_path):
948
+ plan = tmp_path / "plan"
949
+ plan.mkdir()
950
+ with pytest.raises(handoff.HandoffError, match="no done row"):
951
+ handoff.record_verified(plan_run_root=plan, stage=2,
952
+ report_path="r.md",
953
+ data_json=_fv_data(tmp_path))
954
+
955
+
956
+ def test_record_pr_writes_row(tmp_path):
957
+ plan = tmp_path / "plan"
958
+ plan.mkdir()
959
+ _seed_done(plan)
960
+ handoff.record_pr(plan_run_root=plan, stages=[2], branch="b",
961
+ url="https://example.com/pr/9")
962
+ assert consumers.pr_covered_stages(consumers.read_consumers(plan)) == {2}
963
+
964
+
965
+ def test_cli_eligible_json(tmp_path):
966
+ """CLI smoke — eligible 서브커맨드가 JSON 을 출력하고 exit 0."""
967
+ plan = tmp_path / "plan"
968
+ plan.mkdir()
969
+ approved = tmp_path / "plan.md"
970
+ approved.write_text(
971
+ "## 5.5 Stage Map\n\n"
972
+ "| Stage | Title | Depends-on | Effective Steps |\n"
973
+ "|---|---|---|---|\n"
974
+ "| 1 | one | (none) | 2 |\n")
975
+ r = subprocess.run(
976
+ [sys.executable, "-m", "okstra_ctl.handoff", "eligible",
977
+ "--plan-run-root", str(plan), "--approved-plan", str(approved)],
978
+ capture_output=True, text=True,
979
+ cwd=str(Path(__file__).resolve().parents[1] / "scripts"))
980
+ assert r.returncode == 0, r.stderr
981
+ payload = json.loads(r.stdout)
982
+ assert payload["stages"][0]["stage"] == 1
983
+ assert payload["stages"][0]["eligible"] is False
984
+ ```
985
+
986
+ 주의: CLI 테스트의 Stage Map 표 형식은 [validators/validate-implementation-plan-stages.py:55](../../../validators/validate-implementation-plan-stages.py:55) `_parse_stage_map` 이 실제로 파싱하는 형식을 따라야 한다. 구현 전에 그 파서의 기대 헤더(컬럼명·`(none)` 표기)를 열어 확인하고, 다르면 **테스트 fixture 를 파서에 맞춰 수정**한다 (파서를 바꾸지 말 것).
987
+
988
+ - [ ] **Step 2: 실패 확인**
989
+
990
+ Run: `python3 -m pytest tests/test_handoff_records.py -v`
991
+ Expected: FAIL — `AttributeError: ... no attribute 'record_verified'`
992
+
993
+ - [ ] **Step 3: 구현**
994
+
995
+ `scripts/okstra_ctl/handoff.py` 에 추가:
996
+
997
+ ```python
998
+ def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
999
+ done = consumers.latest_done_by_stage(rows)
1000
+ row = done.get(stage)
1001
+ if not row:
1002
+ raise HandoffError(
1003
+ f"stage {stage} has no done row in consumers.jsonl — "
1004
+ "finish the implementation stage first")
1005
+ return row.get("impl_task_key", "")
1006
+
1007
+
1008
+ def record_verified(*, plan_run_root, stage: int, report_path: str,
1009
+ data_json) -> Dict[str, Any]:
1010
+ """단독-stage accepted 만 기록. data.json 의 taskType/scope/verdict 를 검증해
1011
+ lead 가 임의 보고서를 verified 로 올리는 것을 막는다."""
1012
+ try:
1013
+ data = json.loads(Path(data_json).read_text(encoding="utf-8"))
1014
+ except (OSError, json.JSONDecodeError) as exc:
1015
+ raise HandoffError(f"cannot read final-report data.json: {exc}")
1016
+ if (data.get("header") or {}).get("taskType") != "final-verification":
1017
+ raise HandoffError("data.json is not a final-verification report")
1018
+ scope = data.get("verificationScope")
1019
+ if scope != "single-stage":
1020
+ raise HandoffError(
1021
+ f"record-verified requires verificationScope single-stage, "
1022
+ f"got {scope!r}")
1023
+ token = ((data.get("finalVerdict") or {}).get("verdictToken")
1024
+ or "").strip().lower()
1025
+ if token != "accepted":
1026
+ raise HandoffError(f"verdict token must be `accepted`, got {token!r}")
1027
+ rows = consumers.read_consumers(Path(plan_run_root))
1028
+ key = _impl_task_key_for(rows, stage)
1029
+ consumers.append_verified(Path(plan_run_root), impl_task_key=key,
1030
+ stage=stage, verdict="accepted",
1031
+ report_path=report_path)
1032
+ return {"ok": True, "stage": stage, "report_path": report_path}
1033
+
1034
+
1035
+ def record_pr(*, plan_run_root, stages: List[int], branch: str,
1036
+ url: str) -> Dict[str, Any]:
1037
+ rows = consumers.read_consumers(Path(plan_run_root))
1038
+ key = _impl_task_key_for(rows, sorted(stages)[0])
1039
+ consumers.append_pr(Path(plan_run_root), impl_task_key=key,
1040
+ stages=stages, branch=branch, url=url)
1041
+ return {"ok": True, "stages": sorted(stages), "branch": branch, "url": url}
1042
+ ```
1043
+
1044
+ argparse main (모듈 끝, [git_reconcile.py:237](../../../scripts/okstra_ctl/git_reconcile.py:237) 의 `main` 패턴):
1045
+
1046
+ ```python
1047
+ def _parse_stages_csv(raw: str) -> List[int]:
1048
+ try:
1049
+ out = sorted({int(x) for x in raw.split(",") if x.strip()})
1050
+ except ValueError:
1051
+ raise HandoffError(f"--stages must be a comma-separated int list, got {raw!r}")
1052
+ if not out:
1053
+ raise HandoffError("--stages must select at least one stage")
1054
+ return out
1055
+
1056
+
1057
+ def main(argv: Optional[list] = None) -> int:
1058
+ import argparse
1059
+
1060
+ from .run import _parse_stage_map_into_ctx
1061
+
1062
+ p = argparse.ArgumentParser(prog="okstra handoff")
1063
+ sub = p.add_subparsers(dest="cmd", required=True)
1064
+
1065
+ def common(sp, *, plan=True):
1066
+ if plan:
1067
+ sp.add_argument("--plan-run-root", required=True)
1068
+
1069
+ sp = sub.add_parser("eligible")
1070
+ common(sp)
1071
+ sp.add_argument("--approved-plan", required=True)
1072
+
1073
+ sp = sub.add_parser("assemble")
1074
+ common(sp)
1075
+ sp.add_argument("--approved-plan", required=True)
1076
+ sp.add_argument("--project-root", default=".")
1077
+ sp.add_argument("--project-id", required=True)
1078
+ sp.add_argument("--task-group", required=True)
1079
+ sp.add_argument("--task-id", required=True)
1080
+ sp.add_argument("--work-category", required=True)
1081
+ sp.add_argument("--stages", required=True)
1082
+ sp.add_argument("--base", required=True)
1083
+
1084
+ sp = sub.add_parser("record-verified")
1085
+ common(sp)
1086
+ sp.add_argument("--stage", type=int, required=True)
1087
+ sp.add_argument("--report-path", required=True)
1088
+ sp.add_argument("--data-json", required=True)
1089
+
1090
+ sp = sub.add_parser("record-pr")
1091
+ common(sp)
1092
+ sp.add_argument("--stages", required=True)
1093
+ sp.add_argument("--branch", required=True)
1094
+ sp.add_argument("--url", required=True)
1095
+
1096
+ a = p.parse_args(argv)
1097
+ try:
1098
+ if a.cmd == "eligible":
1099
+ stage_map = _parse_stage_map_into_ctx(a.approved_plan)
1100
+ rows = consumers.read_consumers(Path(a.plan_run_root))
1101
+ out = {"stages": compute_eligibility(stage_map, rows)}
1102
+ elif a.cmd == "assemble":
1103
+ out = assemble(
1104
+ project_root=Path(a.project_root).resolve(),
1105
+ plan_run_root=Path(a.plan_run_root),
1106
+ stage_map=_parse_stage_map_into_ctx(a.approved_plan),
1107
+ stages=_parse_stages_csv(a.stages), base_branch=a.base,
1108
+ work_category=a.work_category, project_id=a.project_id,
1109
+ task_group=a.task_group, task_id=a.task_id)
1110
+ elif a.cmd == "record-verified":
1111
+ out = record_verified(plan_run_root=Path(a.plan_run_root),
1112
+ stage=a.stage, report_path=a.report_path,
1113
+ data_json=a.data_json)
1114
+ else:
1115
+ out = record_pr(plan_run_root=Path(a.plan_run_root),
1116
+ stages=_parse_stages_csv(a.stages),
1117
+ branch=a.branch, url=a.url)
1118
+ except HandoffConflict as exc:
1119
+ print(json.dumps({"error": str(exc), "stage": exc.stage,
1120
+ "conflicts": exc.paths}, ensure_ascii=False))
1121
+ return 2
1122
+ except HandoffError as exc:
1123
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False))
1124
+ return 1
1125
+ print(json.dumps(out, ensure_ascii=False, indent=2))
1126
+ return 0
1127
+
1128
+
1129
+ if __name__ == "__main__":
1130
+ import sys as _sys
1131
+
1132
+ _sys.exit(main())
1133
+ ```
1134
+
1135
+ - [ ] **Step 4: 통과 확인**
1136
+
1137
+ Run: `python3 -m pytest tests/test_handoff_records.py tests/test_handoff_assemble.py tests/test_handoff_eligibility.py -v`
1138
+ Expected: PASS
1139
+
1140
+ - [ ] **Step 5: 커밋**
1141
+
1142
+ ```bash
1143
+ git add scripts/okstra_ctl/handoff.py tests/test_handoff_records.py
1144
+ git commit -m "feat(okstra-ctl): handoff record-verified/record-pr + CLI main 추가"
1145
+ ```
1146
+
1147
+ ---
1148
+
1149
+ ### Task 7: Node 셔틀 + bin/okstra 등록
1150
+
1151
+ **Files:**
1152
+ - Create: `src/handoff.mjs`
1153
+ - Modify: `bin/okstra` (디스패치 테이블 [bin/okstra:24](../../../bin/okstra:24) 부근 + Admin commands 도움말 ~74행)
1154
+
1155
+ - [ ] **Step 1: 셔틀 작성**
1156
+
1157
+ `src/handoff.mjs` 생성 ([src/git-reconcile.mjs](../../../src/git-reconcile.mjs) 와 동일 패턴):
1158
+
1159
+ ```javascript
1160
+ import { runPythonModule } from "./_python-helper.mjs";
1161
+
1162
+ const USAGE = `okstra handoff — release-handoff stage-group 보조 (자격/수집/기록)
1163
+
1164
+ A thin shim over \`python3 -m okstra_ctl.handoff\`. JSON 출력.
1165
+
1166
+ Usage:
1167
+ okstra handoff eligible --plan-run-root <dir> --approved-plan <md>
1168
+ okstra handoff assemble --plan-run-root <dir> --approved-plan <md> \\
1169
+ --project-root <dir> --project-id <id> --task-group <g> --task-id <t> \\
1170
+ --work-category <c> --stages 2,3 --base <branch>
1171
+ okstra handoff record-verified --plan-run-root <dir> --stage <N> \\
1172
+ --report-path <md> --data-json <json>
1173
+ okstra handoff record-pr --plan-run-root <dir> --stages 2,3 \\
1174
+ --branch <b> --url <u>
1175
+
1176
+ Exit codes: 0 ok / 1 자격·전제 위반 / 2 stage 간 merge 충돌(conflicts 동봉)
1177
+ `;
1178
+
1179
+ export async function run(args) {
1180
+ if (args.includes("--help") || args.includes("-h")) {
1181
+ process.stdout.write(USAGE);
1182
+ return 0;
1183
+ }
1184
+ const { code } = await runPythonModule({
1185
+ module: "okstra_ctl.handoff",
1186
+ args,
1187
+ });
1188
+ return code ?? 1;
1189
+ }
1190
+ ```
1191
+
1192
+ - [ ] **Step 2: bin/okstra 등록**
1193
+
1194
+ 디스패치 테이블의 `git-reconcile` 항목 아래에 추가:
1195
+
1196
+ ```javascript
1197
+ ["handoff", () => import("../src/handoff.mjs").then((m) => m.run)],
1198
+ ```
1199
+
1200
+ Admin commands 도움말 블록(`git-reconcile` 설명 행 아래)에 추가:
1201
+
1202
+ ```
1203
+ handoff Stage-group release-handoff helpers (eligible/assemble/record)
1204
+ ```
1205
+
1206
+ - [ ] **Step 3: 스모크 확인**
1207
+
1208
+ Run: `node bin/okstra handoff --help && node bin/okstra --version`
1209
+ Expected: USAGE 출력 + 버전 정상 출력 (exit 0)
1210
+
1211
+ - [ ] **Step 4: 커밋**
1212
+
1213
+ ```bash
1214
+ git add src/handoff.mjs bin/okstra
1215
+ git commit -m "feat(cli): okstra handoff 서브커맨드 — okstra_ctl.handoff 셔틀"
1216
+ ```
1217
+
1218
+ ---
1219
+
1220
+ ### Task 8: validate-run.py — stage-group 라우팅 허용
1221
+
1222
+ **Files:**
1223
+ - Modify: `validators/validate-run.py:1441-1446`
1224
+ - Test: 기존 validator 테스트 파일 위치 확인 — `grep -rln "verificationScope" tests/` 결과의 파일에 케이스 추가 (없으면 `tests/test_validate_run_stage_group_routing.py` 신설)
1225
+
1226
+ - [ ] **Step 1: 실패하는 테스트 작성**
1227
+
1228
+ 먼저 기존 테스트 위치 확인: `grep -rln "single-stage cannot recommend\|verificationScope" tests/`. 기존 파일이 있으면 거기에, 없으면 `tests/test_validate_run_stage_group_routing.py` 를 만들어 다음 두 케이스를 추가한다 (validator 의 해당 함수를 직접 import 해 `failures` 리스트로 검증하는 기존 관례를 따른다 — 그 파일의 기존 테스트가 함수를 부르는 방식을 그대로 복제):
1229
+
1230
+ ```python
1231
+ def _fv_data(routing, scope="single-stage"):
1232
+ return {
1233
+ "header": {"taskType": "final-verification"},
1234
+ "verificationScope": scope,
1235
+ "finalVerdict": {"verdictToken": "accepted"},
1236
+ "finalVerification": {
1237
+ "acceptanceBlockers": [],
1238
+ "routingRecommendation": routing,
1239
+ },
1240
+ }
1241
+
1242
+
1243
+ def test_single_stage_allows_stage_group_handoff_routing():
1244
+ failures = []
1245
+ validate_final_verification_consistency(_fv_data("release-handoff(stage-group)"), failures)
1246
+ assert failures == []
1247
+
1248
+
1249
+ def test_single_stage_still_blocks_plain_handoff_routing():
1250
+ failures = []
1251
+ validate_final_verification_consistency(_fv_data("release-handoff"), failures)
1252
+ assert any("single-stage" in f for f in failures)
1253
+ ```
1254
+
1255
+ (함수명 `validate_final_verification_consistency` 는 [validate-run.py:1397](../../../validators/validate-run.py:1397) 정의부를 열어 실제 이름으로 맞춘다.)
1256
+
1257
+ - [ ] **Step 2: 실패 확인**
1258
+
1259
+ Run: `python3 -m pytest <해당 테스트 파일> -v`
1260
+ Expected: `test_single_stage_allows_stage_group_handoff_routing` FAIL
1261
+
1262
+ - [ ] **Step 3: 구현**
1263
+
1264
+ [validate-run.py:1441](../../../validators/validate-run.py:1441) 의 single-stage 검사 교체:
1265
+
1266
+ ```python
1267
+ if (scope == "single-stage" and "release-handoff" in routing
1268
+ and "release-handoff(stage-group)" not in routing):
1269
+ failures.append(
1270
+ "final-verification: verificationScope `single-stage` cannot recommend "
1271
+ "plain release-handoff routing — a single-stage accepted verdict may "
1272
+ "only route to `release-handoff(stage-group)` (partial-PR mode); "
1273
+ "whole-task release-handoff requires whole-task verification."
1274
+ )
1275
+ ```
1276
+
1277
+ - [ ] **Step 4: 통과 확인**
1278
+
1279
+ Run: `python3 -m pytest <해당 테스트 파일> -v && python3 -m pytest tests/ -q -k "validate"`
1280
+ Expected: PASS
1281
+
1282
+ - [ ] **Step 5: 커밋**
1283
+
1284
+ ```bash
1285
+ git add validators/validate-run.py tests/
1286
+ git commit -m "feat(validators): single-stage 검증의 release-handoff(stage-group) 라우팅 허용"
1287
+ ```
1288
+
1289
+ ---
1290
+
1291
+ ### Task 9: 프로파일 계약 수정
1292
+
1293
+ **Files:**
1294
+ - Modify: `prompts/profiles/release-handoff.md`
1295
+ - Modify: `prompts/profiles/final-verification.md`
1296
+
1297
+ - [ ] **Step 1: final-verification.md 수정**
1298
+
1299
+ (a) [final-verification.md:29](../../../prompts/profiles/final-verification.md:29) 의 문장 끝 `A single-stage run is a partial verification and MUST NOT recommend release-handoff.` 를 다음으로 교체:
1300
+
1301
+ ```
1302
+ A single-stage run is a partial verification: it MUST NOT recommend plain `release-handoff`, but MAY recommend `release-handoff(stage-group)` when the verdict is `accepted` — the stage becomes PR-eligible for a stage-group handoff.
1303
+ ```
1304
+
1305
+ (b) [final-verification.md:40](../../../prompts/profiles/final-verification.md:40) 의 마지막 문장(`a single-stage run is partial and routes to ...`)을 다음으로 교체:
1306
+
1307
+ ```
1308
+ a `single-stage` accepted run routes to `release-handoff(stage-group)` (or `implementation` / `done`); plain `release-handoff` remains whole-task-only. Enforcement: `validators/validate-run.py` rejects a `single-stage` report whose routing cites plain `release-handoff`.
1309
+ ```
1310
+
1311
+ (c) deliverable 목록(같은 파일의 Required deliverable 섹션)에 한 줄 추가:
1312
+
1313
+ ```
1314
+ - **Verified-row recording** (single-stage scope only): when the Verdict Token is `accepted`, the lead MUST run `okstra handoff record-verified --plan-run-root <plan-run-root> --stage <N> --report-path <final-report.md path> --data-json <final-report data.json path>` and quote the command + exit code in the report. The helper re-validates taskType/scope/verdict from data.json, so a non-accepted or whole-task report is rejected at the tool layer.
1315
+ ```
1316
+
1317
+ - [ ] **Step 2: release-handoff.md 수정**
1318
+
1319
+ (a) Purpose 줄([release-handoff.md:3](../../../prompts/profiles/release-handoff.md:3)) 끝에 추가: ` Two modes: **whole-task** (default — the verified task branch becomes one PR) and **stage-group** (a user-selected subset of verified stages is merged into a collector branch and becomes one PR).`
1320
+
1321
+ (b) 진입 게이트([release-handoff.md:14](../../../prompts/profiles/release-handoff.md:14)) 의 단일-보고서 항목을 모드 분기로 교체:
1322
+
1323
+ ```
1324
+ - **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`.
1325
+ - **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.
1326
+ ```
1327
+
1328
+ (c) User interaction protocol 에 stage-group 분기 추가 (기존 Q1 항목 아래):
1329
+
1330
+ ```
1331
+ - **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.
1332
+ 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.
1333
+ 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.
1334
+ ```
1335
+
1336
+ (d) Allowed actions 에 추가:
1337
+
1338
+ ```
1339
+ - stage-group helpers: `okstra handoff eligible`, `okstra handoff assemble`, `okstra handoff record-pr`. The assemble step is the ONLY path that may create commits (merge commits on the collector branch) in this phase.
1340
+ ```
1341
+
1342
+ (e) Forbidden actions 의 `local commit commands of any kind` 항목([release-handoff.md:57](../../../prompts/profiles/release-handoff.md:57))을 다음으로 교체:
1343
+
1344
+ ```
1345
+ - local commit commands of any kind (`git add`, `git commit`, `git restore --staged`, `git stash`), and any direct `git merge` / `git rebase` run by the lead. The single exception is the merge commits `okstra handoff assemble` itself creates on the collector branch — the lead never merges by hand.
1346
+ ```
1347
+
1348
+ (f) push/PR 단계: stage-group 모드에서는 push 대상·PR head 가 수집 브랜치임을 명시 (기존 `git push -u origin <current-branch>` 항목에 `(stage-group mode: the collector branch returned by assemble)` 보충). PR 생성 성공(또는 reuse 확인) 직후 의무 추가:
1349
+
1350
+ ```
1351
+ - after `gh pr create` succeeds (or an existing PR is reused), the lead MUST run `okstra handoff record-pr --plan-run-root <...> --stages <csv> --branch <head branch> --url <pr url>` and quote the command + exit code in the final report. This applies to BOTH modes — whole-task runs record `--stages` as the full Stage Map list — so duplicate-PR prevention converges on one consumers record.
1352
+ ```
1353
+
1354
+ 또한 Inline drafting rules 의 diff 범위 항목에 보충: stage-group 모드의 초안 근거는 `git log <implementation_base_commit>..<collector HEAD>` / `git diff <implementation_base_commit>..<collector HEAD> --stat` 이며, 소스 자료는 그룹 내 stage 별 implementation 보고서 + 단독 검증 보고서 N 개다.
1355
+
1356
+ (g) Required deliverable shape 에 추가:
1357
+
1358
+ ```
1359
+ - **Stage Group** (stage-group mode only): selected stages, each stage's single-stage verification report path + quoted `Verdict Token` row, collector branch name, merge commit SHAs from assemble, and the dependency-closure verdict (from the assemble output / error).
1360
+ ```
1361
+
1362
+ - [ ] **Step 3: 빌드 + 검증**
1363
+
1364
+ Run: `npm run build && bash validators/validate-workflow.sh`
1365
+ Expected: 빌드 성공, validator 통과 (실패 시 메시지의 계약 위반 지점을 고친다)
1366
+
1367
+ - [ ] **Step 4: 커밋**
1368
+
1369
+ ```bash
1370
+ git add prompts/profiles/release-handoff.md prompts/profiles/final-verification.md
1371
+ git commit -m "feat(profiles): release-handoff stage-group 모드 + 단독-stage 라우팅 완화 계약"
1372
+ ```
1373
+
1374
+ ---
1375
+
1376
+ ### Task 10: 문서 반영
1377
+
1378
+ **Files:**
1379
+ - Modify: `docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md` (비범위 절)
1380
+ - Modify: `docs/kr/architecture.md` (release-handoff 절)
1381
+ - Modify: `CHANGES.md`
1382
+
1383
+ - [ ] **Step 1: isolation spec 비목표 주석**
1384
+
1385
+ 비범위 목록의 `**okstra 자동 머지 없음**` 항목 끝에 추가:
1386
+
1387
+ ```
1388
+ (2026-06-10 개정: release-handoff stage-group 모드의 수집 머지는 예외 — [2026-06-10-stage-group-handoff-design.md](2026-06-10-stage-group-handoff-design.md))
1389
+ ```
1390
+
1391
+ - [ ] **Step 2: architecture.md 갱신**
1392
+
1393
+ `docs/kr/architecture.md` 의 release-handoff 절(목차에서 위치 확인)에 stage-group 모드 요약을 추가한다 — 모드 2종, G1→G2→assemble 순서, consumers `verified`/`pr` 행 계약, registry `#group-<id>` 키, `okstra handoff` 서브커맨드 4종. 형식은 그 문서의 인접 절 스타일(한국어, 표/리스트)을 따른다.
1394
+
1395
+ - [ ] **Step 3: CHANGES.md 항목 추가**
1396
+
1397
+ `head -30 CHANGES.md` 로 기존 항목 형식을 확인한 뒤 동일 형식으로 최신 항목을 추가한다. 내용:
1398
+
1399
+ ```
1400
+ - release-handoff 에 stage-group 모드 추가: 단독-stage 검증 accepted 를 받은 stage 들을 골라 수집 브랜치(`<prefix>-<task-id>-g2-3`)로 머지해 하나의 PR 로 내보낼 수 있다. `okstra handoff eligible/assemble/record-verified/record-pr` 신설.
1401
+ 사용자 영향: task 전체가 끝나기를 기다리지 않고 검증 완료된 stage 묶음 단위로 PR 을 낼 수 있다. 단독-stage final-verification 의 라우팅이 `release-handoff(stage-group)` 를 허용하도록 완화됐다.
1402
+ ```
1403
+
1404
+ - [ ] **Step 4: 커밋**
1405
+
1406
+ ```bash
1407
+ git add docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md docs/kr/architecture.md CHANGES.md
1408
+ git commit -m "docs(kr): stage-group handoff 문서 반영 — architecture/CHANGES/isolation 비목표 개정"
1409
+ ```
1410
+
1411
+ ---
1412
+
1413
+ ### Task 11: e2e 시나리오
1414
+
1415
+ **Files:**
1416
+ - Create: `tests-e2e/scenario-13-stage-group-handoff.sh` (11 은 결번, 12 까지 존재 — 13 사용)
1417
+
1418
+ - [ ] **Step 1: 시나리오 작성**
1419
+
1420
+ ```bash
1421
+ #!/usr/bin/env bash
1422
+ # scenario-13-stage-group-handoff
1423
+ #
1424
+ # Goal: 독립 stage 2/3 이 done + 단독검증 accepted 인 상태에서
1425
+ # handoff eligible → assemble → 수집 브랜치 머지 그래프 검증 →
1426
+ # record-pr → eligible 재실행 시 already-in-pr 로 제외되는지 확인.
1427
+
1428
+ set -euo pipefail
1429
+
1430
+ SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1431
+ WORKSPACE_ROOT="$(cd -P "$SCRIPT_DIR/.." && pwd)"
1432
+
1433
+ SANDBOX_ROOT="$(mktemp -d -t okstra-e2e.XXXXXX)"
1434
+ cleanup() { rm -rf "$SANDBOX_ROOT"; }
1435
+ trap cleanup EXIT
1436
+
1437
+ export OKSTRA_HOME="$SANDBOX_ROOT/okstra-home"
1438
+ export PYTHONPATH="$WORKSPACE_ROOT/scripts"
1439
+ mkdir -p "$OKSTRA_HOME"
1440
+
1441
+ PROJECT_ID="okstra-e2e-s13"
1442
+ TASK_GROUP="grp"
1443
+ TASK_ID="task"
1444
+ WC="feature-development"
1445
+
1446
+ REPO="$SANDBOX_ROOT/repo"
1447
+ ORIGIN="$SANDBOX_ROOT/origin.git"
1448
+ PLAN_ROOT="$SANDBOX_ROOT/plan-run"
1449
+ mkdir -p "$REPO" "$PLAN_ROOT"
1450
+
1451
+ git -C "$REPO" init -q -b main
1452
+ git -C "$REPO" config user.email t@t
1453
+ git -C "$REPO" config user.name t
1454
+ echo base > "$REPO/base.txt"
1455
+ git -C "$REPO" add base.txt && git -C "$REPO" commit -qm base
1456
+ BASE_SHA="$(git -C "$REPO" rev-parse HEAD)"
1457
+
1458
+ # stage 브랜치명은 assemble 과 동일한 단일 참조점(compute_branch_name)으로 계산
1459
+ stage_branch() {
1460
+ python3 -c "from okstra_ctl.worktree import compute_branch_name; \
1461
+ print(compute_branch_name(work_category='$WC', task_id_segment='$TASK_ID', stage_number=$1))"
1462
+ }
1463
+
1464
+ declare -A HEADS
1465
+ for N in 2 3; do
1466
+ git -C "$REPO" checkout -qb "$(stage_branch "$N")" "$BASE_SHA"
1467
+ echo "stage$N" > "$REPO/s$N.txt"
1468
+ git -C "$REPO" add "s$N.txt" && git -C "$REPO" commit -qm "stage $N"
1469
+ HEADS[$N]="$(git -C "$REPO" rev-parse HEAD)"
1470
+ done
1471
+ git -C "$REPO" checkout -q main
1472
+ git init -q --bare "$ORIGIN"
1473
+ git -C "$REPO" remote add origin "$ORIGIN"
1474
+ git -C "$REPO" push -q origin main
1475
+
1476
+ # 승인 plan (Stage Map 만 필요). 컬럼 형식은
1477
+ # validators/validate-implementation-plan-stages.py 의 _parse_stage_map 기준.
1478
+ cat > "$SANDBOX_ROOT/approved-plan.md" <<'EOF'
1479
+ ## 5.5 Stage Map
1480
+
1481
+ | Stage | Title | Depends-on | Effective Steps |
1482
+ |---|---|---|---|
1483
+ | 2 | stage two | (none) | 2 |
1484
+ | 3 | stage three | (none) | 2 |
1485
+ EOF
1486
+
1487
+ python3 - "$PLAN_ROOT" "${HEADS[2]}" "${HEADS[3]}" "$BASE_SHA" <<'EOF'
1488
+ import sys
1489
+ from pathlib import Path
1490
+ from okstra_ctl import consumers, worktree_registry
1491
+
1492
+ plan, h2, h3, base = Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
1493
+ key = "okstra-e2e-s13/grp/task"
1494
+ for n, h in ((2, h2), (3, h3)):
1495
+ consumers.append_consumer(plan, impl_task_key=key, stage=n,
1496
+ status="done", head_commit=h)
1497
+ consumers.append_verified(plan, impl_task_key=key, stage=n,
1498
+ verdict="accepted", report_path=f"fv-{n}.md")
1499
+ worktree_registry.set_implementation_base(
1500
+ project_id="okstra-e2e-s13", task_group="grp", task_id="task", commit=base)
1501
+ EOF
1502
+
1503
+ ELIGIBLE_JSON="$(python3 -m okstra_ctl.handoff eligible \
1504
+ --plan-run-root "$PLAN_ROOT" --approved-plan "$SANDBOX_ROOT/approved-plan.md")"
1505
+ echo "$ELIGIBLE_JSON" | python3 -c '
1506
+ import json, sys
1507
+ stages = {s["stage"]: s for s in json.load(sys.stdin)["stages"]}
1508
+ assert stages[2]["eligible"] and stages[3]["eligible"], stages
1509
+ '
1510
+
1511
+ ASSEMBLE_JSON="$(python3 -m okstra_ctl.handoff assemble \
1512
+ --plan-run-root "$PLAN_ROOT" --approved-plan "$SANDBOX_ROOT/approved-plan.md" \
1513
+ --project-root "$REPO" --project-id "$PROJECT_ID" --task-group "$TASK_GROUP" \
1514
+ --task-id "$TASK_ID" --work-category "$WC" --stages 2,3 --base main)"
1515
+ COLLECTOR_BRANCH="$(echo "$ASSEMBLE_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["branch"])')"
1516
+ COLLECTOR_HEAD="$(echo "$ASSEMBLE_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"])')"
1517
+
1518
+ for N in 2 3; do
1519
+ git -C "$REPO" merge-base --is-ancestor "${HEADS[$N]}" "$COLLECTOR_HEAD"
1520
+ done
1521
+
1522
+ python3 -m okstra_ctl.handoff record-pr --plan-run-root "$PLAN_ROOT" \
1523
+ --stages 2,3 --branch "$COLLECTOR_BRANCH" --url "https://example.com/pr/1" >/dev/null
1524
+
1525
+ python3 -m okstra_ctl.handoff eligible \
1526
+ --plan-run-root "$PLAN_ROOT" --approved-plan "$SANDBOX_ROOT/approved-plan.md" \
1527
+ | python3 -c '
1528
+ import json, sys
1529
+ stages = {s["stage"]: s for s in json.load(sys.stdin)["stages"]}
1530
+ assert not stages[2]["eligible"] and "already-in-pr" in stages[2]["reasons"], stages
1531
+ assert not stages[3]["eligible"], stages
1532
+ '
1533
+
1534
+ echo "scenario-13 OK"
1535
+ ```
1536
+
1537
+ 작성 후 `chmod +x tests-e2e/scenario-13-stage-group-handoff.sh`. Stage Map 표 형식이 파서와 안 맞아 `eligible` 이 빈 목록을 내면, Task 6 Step 1 의 주의와 동일하게 **fixture 쪽을 파서 형식에 맞춘다**.
1538
+
1539
+ - [ ] **Step 2: 실행 확인**
1540
+
1541
+ Run: `bash tests-e2e/scenario-13-stage-group-handoff.sh`
1542
+ Expected: `scenario-13 OK` 출력, exit 0
1543
+
1544
+ - [ ] **Step 3: 커밋**
1545
+
1546
+ ```bash
1547
+ git add tests-e2e/scenario-13-stage-group-handoff.sh
1548
+ git commit -m "test(e2e): stage-group handoff 시나리오 — eligible/assemble/record-pr 왕복"
1549
+ ```
1550
+
1551
+ ---
1552
+
1553
+ ### Task 12: 전체 검증
1554
+
1555
+ - [ ] **Step 1: 전체 단위 테스트**
1556
+
1557
+ Run: `python3 -m pytest tests/ -q`
1558
+ Expected: 전부 PASS
1559
+
1560
+ - [ ] **Step 2: 빌드 + 워크플로 검증 + CLI 스모크**
1561
+
1562
+ Run: `npm run build && bash validators/validate-workflow.sh && node bin/okstra doctor`
1563
+ Expected: 전부 성공
1564
+
1565
+ - [ ] **Step 3: e2e 재확인**
1566
+
1567
+ Run: `bash tests-e2e/scenario-13-stage-group-handoff.sh && bash tests-e2e/scenario-01-record-start-reconcile.sh`
1568
+ Expected: 둘 다 OK (기존 시나리오 회귀 없음)
1569
+
1570
+ - [ ] **Step 4: 잔여 diff 리뷰 후 마무리 커밋**
1571
+
1572
+ `git status` 로 누락 파일 확인. 미커밋 변경이 남았으면 해당 task 의 커밋에 맞춰 정리한다.