okstra 0.67.0 → 0.69.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.
- package/bin/okstra +25 -0
- package/docs/kr/architecture.md +17 -1
- package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
- package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
- package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
- package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
- package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
- package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +8 -7
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/gemini-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +5 -5
- package/runtime/python/okstra_ctl/conformance.py +17 -0
- package/runtime/python/okstra_ctl/consumers.py +72 -5
- package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
- package/runtime/python/okstra_ctl/handoff.py +348 -0
- package/runtime/python/okstra_ctl/render.py +44 -2
- package/runtime/python/okstra_ctl/run.py +175 -44
- package/runtime/python/okstra_ctl/wizard.py +89 -22
- package/runtime/python/okstra_ctl/worktree.py +28 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/python/okstra_token_usage/collect.py +27 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +8 -8
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -7
- package/runtime/validators/validate-run.py +51 -11
- package/src/_python-helper.mjs +52 -0
- package/src/error-log.mjs +19 -0
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
- package/src/inject-report-index.mjs +22 -0
- package/src/render-final-report.mjs +22 -0
- package/src/render-views.mjs +9 -48
- package/src/spawn-followups.mjs +23 -0
- package/src/token-usage.mjs +3 -34
|
@@ -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 의 커밋에 맞춰 정리한다.
|