okstra 0.52.0 → 0.54.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 (40) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/kr/architecture.md +1 -0
  4. package/docs/kr/cli.md +2 -1
  5. package/docs/superpowers/plans/2026-06-06-final-verification-whole-task-gate.md +993 -0
  6. package/docs/superpowers/plans/2026-06-06-stage-parallel-and-pending-fixes.md +93 -0
  7. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p1.md +447 -0
  8. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p2.md +289 -0
  9. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p3.md +774 -0
  10. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p4.md +303 -0
  11. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p5-multidep-base.md +387 -0
  12. package/docs/superpowers/specs/2026-06-06-final-verification-whole-task-gate-design.md +126 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +180 -0
  14. package/package.json +1 -1
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/agents/workers/report-writer-worker.md +1 -0
  17. package/runtime/bin/lib/okstra/cli.sh +5 -1
  18. package/runtime/bin/okstra.sh +1 -0
  19. package/runtime/prompts/launch.template.md +1 -0
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  21. package/runtime/prompts/profiles/_implementation-executor.md +16 -9
  22. package/runtime/prompts/profiles/_implementation-verifier.md +4 -1
  23. package/runtime/prompts/profiles/final-verification.md +8 -7
  24. package/runtime/prompts/profiles/implementation-planning.md +8 -4
  25. package/runtime/prompts/wizard/prompts.ko.json +3 -2
  26. package/runtime/python/okstra_ctl/analysis_packet.py +14 -2
  27. package/runtime/python/okstra_ctl/render.py +3 -0
  28. package/runtime/python/okstra_ctl/run.py +541 -41
  29. package/runtime/python/okstra_ctl/wizard.py +25 -7
  30. package/runtime/python/okstra_ctl/worktree.py +126 -9
  31. package/runtime/python/okstra_ctl/worktree_registry.py +88 -17
  32. package/runtime/schemas/final-report-v1.0.schema.json +36 -0
  33. package/runtime/skills/okstra-convergence/SKILL.md +14 -3
  34. package/runtime/skills/okstra-run/SKILL.md +1 -1
  35. package/runtime/templates/reports/final-report.template.md +12 -0
  36. package/runtime/templates/reports/final-verification-input.template.md +8 -5
  37. package/runtime/templates/reports/i18n/en.json +3 -1
  38. package/runtime/templates/reports/i18n/ko.json +3 -1
  39. package/runtime/validators/validate-run.py +143 -1
  40. package/runtime/validators/validate-workflow.sh +6 -1
@@ -0,0 +1,774 @@
1
+ # Stage Worktree 격리 P3 — 통합 (run.py + provision) 구현 계획
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:** P1/P2 빌딩블록을 결합해 `implementation` run이 실제로 stage 격리 worktree에서 실행되도록 한다 — base 계산 + stage worktree 발급 + `_reserve_implementation_stages` 통합. CLI 패스스루(B2)와 프로파일/release-handoff 문구는 P4.
6
+
7
+ **Architecture:** 새 pure 헬퍼 `_resolve_stage_base_commit(...)`가 stage 종류(독립/단일 의존/다중 의존)에 따라 base commit을 결정한다. 다중 의존은 first cut에서 명시적 `PrepareError`로 거부([spec §9 #1](../specs/2026-06-06-stage-worktree-isolation-design.md)). 새 함수 `provision_stage_worktree(...)`는 `worktree_registry.reserve(stage_number=...)`(P1)을 사용해 `<task-id>/stage-<N>` 격리 worktree를 발급한다. `_reserve_implementation_stages`(현 [run.py:890](../../../scripts/okstra_ctl/run.py))는 P2 occupied set을 전달하고, batch 의 첫 stage 로 stage worktree 를 발급해 `ctx["EXECUTOR_WORKTREE_*"]` 를 덮어쓰며, batch 의 모든 stage 를 그 stage-key 로 registry 에 reserve 한다.
8
+
9
+ **Tech Stack:** Python 3 (`scripts/okstra_ctl/worktree.py`, `worktree_registry.py`, `run.py`), pytest.
10
+
11
+ 설계 문서: [docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md](../specs/2026-06-06-stage-worktree-isolation-design.md) §2.2 / §3 / §4 / §9 #1
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ - Modify: `scripts/okstra_ctl/run.py`
18
+ - 신규 pure 헬퍼 `_resolve_stage_base_commit()` (모듈 최상단의 helper 군 근처)
19
+ - `_reserve_implementation_stages()` 통합 갱신 ([run.py:890](../../../scripts/okstra_ctl/run.py))
20
+ - Modify: `scripts/okstra_ctl/worktree.py`
21
+ - 신규 `provision_stage_worktree()` (기존 `provision_task_worktree` 아래)
22
+ - Test:
23
+ - `tests/test_okstra_run_stage_base.py` (신규)
24
+ - `tests/test_okstra_worktree_stage_provision.py` (신규)
25
+ - `tests/test_okstra_run_reserve_implementation_stages.py` (신규 또는 기존 인접 파일에 추가)
26
+
27
+ 각 task는 위 한 빌딩블록을 완결한다. P4가 프로파일 문구·CLI·release-handoff 를 다룬다.
28
+
29
+ ---
30
+
31
+ ### Task 1: pure 헬퍼 `_resolve_stage_base_commit` — base 계산 + 다중 의존 거부
32
+
33
+ **Files:**
34
+ - Modify: `scripts/okstra_ctl/run.py` (신규 모듈 함수, `_resolve_effective_stages` 직후)
35
+ - Test: `tests/test_okstra_run_stage_base.py` (신규)
36
+
37
+ - [ ] **Step 1: Write the failing test**
38
+
39
+ ```python
40
+ # tests/test_okstra_run_stage_base.py (신규)
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
45
+
46
+ import pytest
47
+
48
+ from okstra_ctl.run import _resolve_stage_base_commit, PrepareError
49
+
50
+
51
+ def _stage(n, deps):
52
+ return {"stage_number": n, "depends_on": deps, "step_count": 1}
53
+
54
+
55
+ def test_independent_stage_uses_anchor_base_commit():
56
+ stage = _stage(1, [])
57
+ got = _resolve_stage_base_commit(
58
+ stage, consumer_done_rows=[],
59
+ anchor_base_commit="commit-AAA",
60
+ )
61
+ assert got == "commit-AAA"
62
+
63
+
64
+ def test_single_dependency_uses_predecessor_done_head_commit():
65
+ stage = _stage(3, [1])
66
+ consumer_done = [
67
+ {"stage": 1, "status": "done", "head_commit": "commit-S1-done"},
68
+ {"stage": 1, "status": "started", "head_commit": "ignored"},
69
+ ]
70
+ got = _resolve_stage_base_commit(
71
+ stage, consumer_done_rows=consumer_done,
72
+ anchor_base_commit="commit-AAA",
73
+ )
74
+ assert got == "commit-S1-done"
75
+
76
+
77
+ def test_single_dependency_without_predecessor_done_raises():
78
+ stage = _stage(3, [1])
79
+ # stage 1 이 consumers 에 done 으로 없음 — depends-on 미충족
80
+ with pytest.raises(PrepareError, match="predecessor stage 1 has no done"):
81
+ _resolve_stage_base_commit(
82
+ stage, consumer_done_rows=[],
83
+ anchor_base_commit="commit-AAA",
84
+ )
85
+
86
+
87
+ def test_multi_dependency_rejected_with_guidance():
88
+ stage = _stage(4, [2, 3])
89
+ with pytest.raises(PrepareError, match="multi-dependency stage"):
90
+ _resolve_stage_base_commit(
91
+ stage, consumer_done_rows=[
92
+ {"stage": 2, "status": "done", "head_commit": "c2"},
93
+ {"stage": 3, "status": "done", "head_commit": "c3"},
94
+ ],
95
+ anchor_base_commit="commit-AAA",
96
+ )
97
+
98
+
99
+ def test_independent_stage_without_anchor_raises():
100
+ stage = _stage(1, [])
101
+ with pytest.raises(PrepareError, match="anchor base commit missing"):
102
+ _resolve_stage_base_commit(
103
+ stage, consumer_done_rows=[],
104
+ anchor_base_commit="",
105
+ )
106
+ ```
107
+
108
+ - [ ] **Step 2: Run test to verify it fails**
109
+
110
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/test_okstra_run_stage_base.py -v`
111
+ Expected: FAIL — `cannot import name '_resolve_stage_base_commit'`.
112
+
113
+ - [ ] **Step 3: Write minimal implementation**
114
+
115
+ ```python
116
+ # run.py — _resolve_effective_stages 정의 바로 아래에 추가
117
+ def _resolve_stage_base_commit(
118
+ stage: dict,
119
+ consumer_done_rows: list,
120
+ anchor_base_commit: str,
121
+ ) -> str:
122
+ """Pick the git base commit a stage's isolated worktree branches from.
123
+
124
+ - 독립 (`depends-on (none)`): anchor (= implementation_base_commit fixed
125
+ at first stage entry). Caller is responsible for supplying it.
126
+ - 단일 의존 (`depends-on X`): predecessor X 의 `done.head_commit` from
127
+ `consumers.jsonl`.
128
+ - 다중 의존 (`depends-on X,Y…`): first cut 미지원. PrepareError with
129
+ guidance to merge predecessor branches first (spec §9 #1).
130
+
131
+ Raises PrepareError on missing predecessor / missing anchor."""
132
+ deps = stage.get("depends_on") or []
133
+ if len(deps) >= 2:
134
+ raise PrepareError(
135
+ f"multi-dependency stage {stage['stage_number']} (depends-on "
136
+ f"{deps}) is not supported in the first stage-isolation cut; "
137
+ "merge predecessor stage branches first, then start a fresh "
138
+ "task targeting the merged base"
139
+ )
140
+ if not deps:
141
+ if not anchor_base_commit:
142
+ raise PrepareError(
143
+ f"anchor base commit missing for independent stage "
144
+ f"{stage['stage_number']}; first-stage prepare should have "
145
+ "fixed it via worktree_registry.set_implementation_base"
146
+ )
147
+ return anchor_base_commit
148
+ # 단일 의존
149
+ pred = deps[0]
150
+ for row in consumer_done_rows:
151
+ if row.get("stage") == pred and row.get("status") == "done":
152
+ head = row.get("head_commit") or ""
153
+ if head:
154
+ return head
155
+ raise PrepareError(
156
+ f"predecessor stage {pred} has no done row with head_commit in "
157
+ "consumers.jsonl; cannot derive base for stage "
158
+ f"{stage['stage_number']}"
159
+ )
160
+ ```
161
+
162
+ - [ ] **Step 4: Run test to verify it passes**
163
+
164
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/test_okstra_run_stage_base.py -v`
165
+ Expected: PASS (5개).
166
+
167
+ - [ ] **Step 5: Commit**
168
+
169
+ ```bash
170
+ cd /Volumes/Workspaces/workspace/projects/Okstra
171
+ git add scripts/okstra_ctl/run.py tests/test_okstra_run_stage_base.py
172
+ git commit -m "feat(run): resolve stage base commit and reject multi-dependency stages"
173
+ ```
174
+
175
+ ---
176
+
177
+ ### Task 2: `provision_stage_worktree` — stage 격리 worktree 발급
178
+
179
+ **Files:**
180
+ - Modify: `scripts/okstra_ctl/worktree.py` (신규 함수, `provision_task_worktree` 정의 직후)
181
+ - Test: `tests/test_okstra_worktree_stage_provision.py` (신규)
182
+
183
+ - [ ] **Step 1: Write the failing test**
184
+
185
+ ```python
186
+ # tests/test_okstra_worktree_stage_provision.py (신규)
187
+ import subprocess
188
+ import sys
189
+ from pathlib import Path
190
+
191
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
192
+
193
+ import pytest
194
+
195
+
196
+ def _init_repo(repo: Path) -> str:
197
+ """tmp 디렉터리에 빈 git repo 를 만들고 첫 커밋 sha 를 반환한다."""
198
+ subprocess.run(["git", "init", "-q", str(repo)], check=True)
199
+ subprocess.run(["git", "-C", str(repo), "config", "user.email", "t@e"], check=True)
200
+ subprocess.run(["git", "-C", str(repo), "config", "user.name", "t"], check=True)
201
+ (repo / "README").write_text("seed\n")
202
+ subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
203
+ subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "init"], check=True)
204
+ return subprocess.run(
205
+ ["git", "-C", str(repo), "rev-parse", "HEAD"],
206
+ capture_output=True, text=True, check=True,
207
+ ).stdout.strip()
208
+
209
+
210
+ def test_provision_stage_worktree_creates_dir_branch_and_registry_entry(tmp_path):
211
+ repo = tmp_path / "repo"
212
+ base_sha = _init_repo(repo)
213
+ from okstra_ctl.worktree import provision_stage_worktree
214
+ from okstra_ctl.worktree_registry import lookup
215
+
216
+ res = provision_stage_worktree(
217
+ project_root=repo, project_id="proj",
218
+ task_group_segment="grp", task_id_segment="tid",
219
+ work_category="feature",
220
+ stage_number=2, base_commit=base_sha,
221
+ )
222
+ assert res.status == "created"
223
+ assert Path(res.path).name == "stage-2"
224
+ assert res.branch == "feat-tid-s2"
225
+ # registry stage-key 가 active 로 reserve 됐는지
226
+ entry = lookup("proj", "grp", "tid", stage_number=2)
227
+ assert entry is not None and entry.stage == 2 and entry.status == "active"
228
+ assert entry.branch == "feat-tid-s2"
229
+
230
+
231
+ def test_provision_stage_worktree_reuses_existing_registry_entry(tmp_path):
232
+ repo = tmp_path / "repo"
233
+ base_sha = _init_repo(repo)
234
+ from okstra_ctl.worktree import provision_stage_worktree
235
+
236
+ first = provision_stage_worktree(
237
+ project_root=repo, project_id="proj",
238
+ task_group_segment="grp", task_id_segment="tid",
239
+ work_category="feature",
240
+ stage_number=2, base_commit=base_sha,
241
+ )
242
+ second = provision_stage_worktree(
243
+ project_root=repo, project_id="proj",
244
+ task_group_segment="grp", task_id_segment="tid",
245
+ work_category="feature",
246
+ stage_number=2, base_commit=base_sha,
247
+ )
248
+ assert second.status == "reused"
249
+ assert second.path == first.path
250
+ assert second.branch == first.branch
251
+
252
+
253
+ def test_provision_stage_worktree_rolls_back_on_registry_conflict(tmp_path):
254
+ """registry stage-key 충돌 시 worktree 디렉터리/브랜치도 정리되어야 한다."""
255
+ repo = tmp_path / "repo"
256
+ base_sha = _init_repo(repo)
257
+ from okstra_ctl.worktree import provision_stage_worktree
258
+ from okstra_ctl.worktree_registry import reserve
259
+
260
+ # 미리 stage-2 branch 이름을 다른 task-key 에 등록 (branch 충돌 유도)
261
+ reserve(project_id="proj", task_group="grp", task_id="other",
262
+ worktree_path="/wt/other", branch="feat-tid-s2", base_ref=base_sha)
263
+
264
+ with pytest.raises(RuntimeError, match="branch .* is already registered"):
265
+ provision_stage_worktree(
266
+ project_root=repo, project_id="proj",
267
+ task_group_segment="grp", task_id_segment="tid",
268
+ work_category="feature",
269
+ stage_number=2, base_commit=base_sha,
270
+ )
271
+ # worktree 디렉터리·branch 가 롤백됐는지
272
+ expected_wt = Path(tmp_path / ".okstra-isolated" / "worktrees" / "proj" / "grp" / "tid" / "stage-2")
273
+ # OKSTRA_HOME 은 conftest 가 isolated 경로로 set, 정확한 경로는 시스템에 따라 다르므로
274
+ # 핵심 단언은 "git 브랜치가 만들어졌다 사라졌다" 여부
275
+ res = subprocess.run(
276
+ ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", "feat-tid-s2"],
277
+ capture_output=True, text=True,
278
+ )
279
+ assert res.returncode != 0, "rollback 후 브랜치가 남아있으면 안 됨"
280
+ ```
281
+
282
+ - [ ] **Step 2: Run test to verify it fails**
283
+
284
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/test_okstra_worktree_stage_provision.py -v`
285
+ Expected: FAIL — `cannot import name 'provision_stage_worktree'`.
286
+
287
+ - [ ] **Step 3: Write minimal implementation**
288
+
289
+ ```python
290
+ # worktree.py — provision_task_worktree 정의 바로 아래에 추가
291
+ def provision_stage_worktree(
292
+ *,
293
+ project_root: Path,
294
+ project_id: str,
295
+ task_group_segment: str,
296
+ task_id_segment: str,
297
+ work_category: str,
298
+ stage_number: int,
299
+ base_commit: str,
300
+ ) -> WorktreeProvision:
301
+ """Materialise an isolated worktree for one implementation stage.
302
+
303
+ Unlike `provision_task_worktree` (one worktree per task-key shared
304
+ across phases), this provisions a per-stage worktree branched from
305
+ `base_commit` at `<task-key>/stage-<N>/` on branch `<prefix>-<task>-s<N>`.
306
+ The stage-key (`<task-key>#stage-<N>`) is reserved atomically through
307
+ `worktree_registry`; re-entry of the same stage-key returns the
308
+ existing entry. Branch / on-disk conflicts roll back the worktree
309
+ before re-raising so a retry is not blocked.
310
+ """
311
+ if not base_commit:
312
+ raise RuntimeError("provision_stage_worktree requires a base_commit")
313
+
314
+ safe_project = _safe_segment(project_id)
315
+ safe_group = _safe_segment(task_group_segment)
316
+ safe_task = _safe_segment(task_id_segment)
317
+ worktree_path = compute_worktree_path(
318
+ project_id=safe_project, task_group_segment=safe_group,
319
+ task_id_segment=safe_task, stage_number=stage_number,
320
+ )
321
+ branch = compute_branch_name(
322
+ work_category=work_category, task_id_segment=safe_task,
323
+ stage_number=stage_number,
324
+ )
325
+
326
+ existing = worktree_registry.lookup(
327
+ safe_project, safe_group, safe_task, stage_number=stage_number)
328
+ if existing is not None and existing.status == "active":
329
+ return WorktreeProvision(
330
+ status="reused", path=existing.worktree_path,
331
+ branch=existing.branch, base_ref=existing.base_ref,
332
+ note=(
333
+ f"stage {stage_number} worktree reused at "
334
+ f"{existing.worktree_path} on branch {existing.branch} "
335
+ f"(base {existing.base_ref[:12]})"
336
+ ),
337
+ )
338
+
339
+ if worktree_path.exists():
340
+ raise RuntimeError(
341
+ f"stage worktree path already exists but is not in the registry: "
342
+ f"{worktree_path}. Remove it before retrying."
343
+ )
344
+ if _branch_exists(project_root, branch):
345
+ raise RuntimeError(
346
+ f"stage worktree branch already exists: {branch}. "
347
+ "Delete it (`git branch -D <branch>`) before retrying."
348
+ )
349
+
350
+ main_root = main_worktree_path(project_root)
351
+ resolved_sha = _resolve_commit_sha(main_root, base_commit)
352
+ if not resolved_sha:
353
+ raise RuntimeError(
354
+ f"could not resolve base_commit `{base_commit}` in main worktree "
355
+ f"({main_root}); ensure the commit exists locally"
356
+ )
357
+
358
+ worktree_path.parent.mkdir(parents=True, exist_ok=True)
359
+ res = _git(
360
+ main_root,
361
+ "worktree", "add", "-b", branch, str(worktree_path), resolved_sha,
362
+ )
363
+ if res.returncode != 0:
364
+ raise RuntimeError(
365
+ f"`git worktree add` failed (exit={res.returncode}): "
366
+ f"{(res.stderr or res.stdout).strip()}"
367
+ )
368
+
369
+ _link_sync_dirs(main_root, worktree_path)
370
+ _link_sync_files(main_root, worktree_path)
371
+ _copy_snapshot_files(main_root, worktree_path)
372
+
373
+ try:
374
+ worktree_registry.reserve(
375
+ project_id=safe_project,
376
+ task_group=safe_group,
377
+ task_id=safe_task,
378
+ worktree_path=str(worktree_path),
379
+ branch=branch,
380
+ base_ref=resolved_sha,
381
+ phase="implementation",
382
+ stage_number=stage_number,
383
+ )
384
+ except RuntimeError:
385
+ _git(main_root, "worktree", "remove", "--force", str(worktree_path))
386
+ _git(main_root, "branch", "-D", branch)
387
+ raise
388
+
389
+ _seed_worktree_settings_symlink(worktree_path)
390
+
391
+ return WorktreeProvision(
392
+ status="created", path=str(worktree_path),
393
+ branch=branch, base_ref=resolved_sha,
394
+ note=(
395
+ f"stage {stage_number} worktree created at {worktree_path} "
396
+ f"on branch {branch} (base {resolved_sha[:12]})"
397
+ ),
398
+ )
399
+ ```
400
+
401
+ - [ ] **Step 4: Run test to verify it passes**
402
+
403
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/test_okstra_worktree_stage_provision.py -v`
404
+ Expected: PASS (3개).
405
+
406
+ - [ ] **Step 5: Commit**
407
+
408
+ ```bash
409
+ cd /Volumes/Workspaces/workspace/projects/Okstra
410
+ git add scripts/okstra_ctl/worktree.py tests/test_okstra_worktree_stage_provision.py
411
+ git commit -m "feat(worktree): provision per-stage isolated worktree"
412
+ ```
413
+
414
+ ---
415
+
416
+ ### Task 3: `_reserve_implementation_stages` — P1/P2 + Task 1/2 통합
417
+
418
+ 이 task가 가장 무겁다. 한 함수 안에서 (a) anchor base commit 고정, (b) occupied 검출, (c) ready 해소, (d) stage worktree 발급, (e) ctx EXECUTOR_WORKTREE_* 덮어쓰기, (f) consumers append 까지 묶는다. step을 잘게 쪼개 각 step 의 실패 신호를 분명히 한다.
419
+
420
+ **Files:**
421
+ - Modify: `scripts/okstra_ctl/run.py:890` (`_reserve_implementation_stages`)
422
+ - Test: `tests/test_okstra_run_reserve_implementation_stages.py` (신규)
423
+
424
+ - [ ] **Step 1: Write the integration test**
425
+
426
+ ```python
427
+ # tests/test_okstra_run_reserve_implementation_stages.py (신규)
428
+ import subprocess
429
+ import sys
430
+ from pathlib import Path
431
+
432
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
433
+
434
+ import pytest
435
+
436
+
437
+ def _init_repo(repo: Path) -> str:
438
+ subprocess.run(["git", "init", "-q", str(repo)], check=True)
439
+ subprocess.run(["git", "-C", str(repo), "config", "user.email", "t@e"], check=True)
440
+ subprocess.run(["git", "-C", str(repo), "config", "user.name", "t"], check=True)
441
+ (repo / "README").write_text("seed\n")
442
+ subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
443
+ subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "init"], check=True)
444
+ return subprocess.run(
445
+ ["git", "-C", str(repo), "rev-parse", "HEAD"],
446
+ capture_output=True, text=True, check=True,
447
+ ).stdout.strip()
448
+
449
+
450
+ def _stage(n, deps, steps=2):
451
+ return {"stage_number": n, "depends_on": deps, "step_count": steps,
452
+ "title": f"S{n}", "exit_contract_summary": ""}
453
+
454
+
455
+ class _Inp:
456
+ """_reserve_implementation_stages 가 읽는 PrepareInputs 필드만 흉내."""
457
+ def __init__(self, *, project_root, approved_plan_path, stage,
458
+ project_id="proj", task_group="grp", task_id="tid",
459
+ work_category="feature"):
460
+ self.project_root = project_root
461
+ self.approved_plan_path = approved_plan_path
462
+ self.stage = stage
463
+ self.project_id = project_id
464
+ self.task_group = task_group
465
+ self.task_id = task_id
466
+ self.work_category = work_category
467
+
468
+
469
+ def _consumers_dir(tmp: Path) -> Path:
470
+ """consumers.jsonl 이 사는 plan_run_root 모양. read_consumers 가 그 경로의
471
+ consumers.jsonl 을 찾는다."""
472
+ d = tmp / "planning" / "reports"
473
+ d.mkdir(parents=True)
474
+ plan = d / "final-report.md"
475
+ plan.write_text("dummy\n")
476
+ return plan
477
+
478
+
479
+ def test_first_stage_run_fixes_anchor_and_provisions_stage_worktree(tmp_path, monkeypatch):
480
+ repo = tmp_path / "repo"
481
+ base_sha = _init_repo(repo)
482
+ plan_path = _consumers_dir(tmp_path)
483
+
484
+ from okstra_ctl.run import _reserve_implementation_stages
485
+ from okstra_ctl.worktree_registry import (
486
+ reserve as registry_reserve, get_implementation_base, lookup,
487
+ )
488
+ # task-key 엔트리 사전 등록 (provision_task_worktree 가 일반 phase 에서 만들었다고 가정)
489
+ registry_reserve(
490
+ project_id="proj", task_group="grp", task_id="tid",
491
+ worktree_path=str(repo), branch="feat-tid", base_ref=base_sha,
492
+ )
493
+
494
+ ctx = {
495
+ "TASK_KEY": "proj/grp/tid",
496
+ "TASK_GROUP_SEGMENT": "grp", "TASK_ID_SEGMENT": "tid",
497
+ "EXECUTOR_WORKTREE_PATH": str(repo),
498
+ "EXECUTOR_WORKTREE_BRANCH": "feat-tid",
499
+ "EXECUTOR_WORKTREE_BASE_REF": base_sha,
500
+ }
501
+ inp = _Inp(project_root=repo, approved_plan_path=str(plan_path), stage="auto")
502
+ stages = [_stage(1, [])]
503
+
504
+ _reserve_implementation_stages(inp, ctx, stages)
505
+
506
+ # anchor 가 고정됐고
507
+ assert get_implementation_base("proj", "grp", "tid") == base_sha
508
+ # stage worktree 가 발급되어 ctx 가 덮어쓰여졌고
509
+ assert ctx["EXECUTOR_WORKTREE_PATH"].endswith("/stage-1")
510
+ assert ctx["EXECUTOR_WORKTREE_BRANCH"] == "feat-tid-s1"
511
+ # registry 에 stage-key 가 active
512
+ entry = lookup("proj", "grp", "tid", stage_number=1)
513
+ assert entry is not None and entry.status == "active"
514
+
515
+
516
+ def test_concurrent_second_run_skips_reserved_stage(tmp_path):
517
+ """첫 run 이 stage 1 을 reserve 한 뒤, 두번째 run 이 auto 로 와도 stage 1 은
518
+ 잡지 않고 ready 면 다음 stage 를 잡는다 (P2 occupied 통합 검증)."""
519
+ repo = tmp_path / "repo"
520
+ base_sha = _init_repo(repo)
521
+ plan_path = _consumers_dir(tmp_path)
522
+
523
+ from okstra_ctl.run import _reserve_implementation_stages
524
+ from okstra_ctl.worktree_registry import reserve as registry_reserve, lookup
525
+ registry_reserve(
526
+ project_id="proj", task_group="grp", task_id="tid",
527
+ worktree_path=str(repo), branch="feat-tid", base_ref=base_sha,
528
+ )
529
+
530
+ # 첫 run: stage 1 (독립) 예약
531
+ ctx1 = {
532
+ "TASK_KEY": "proj/grp/tid",
533
+ "TASK_GROUP_SEGMENT": "grp", "TASK_ID_SEGMENT": "tid",
534
+ "EXECUTOR_WORKTREE_PATH": str(repo),
535
+ "EXECUTOR_WORKTREE_BRANCH": "feat-tid",
536
+ "EXECUTOR_WORKTREE_BASE_REF": base_sha,
537
+ }
538
+ stages = [_stage(1, []), _stage(2, [])]
539
+ _reserve_implementation_stages(
540
+ _Inp(project_root=repo, approved_plan_path=str(plan_path), stage="auto"),
541
+ ctx1, stages,
542
+ )
543
+ assert lookup("proj", "grp", "tid", stage_number=1) is not None
544
+
545
+ # 두번째 run: auto — stage 1 reserved, stage 2 ready (독립) → stage 2 잡아야 함
546
+ ctx2 = {
547
+ "TASK_KEY": "proj/grp/tid",
548
+ "TASK_GROUP_SEGMENT": "grp", "TASK_ID_SEGMENT": "tid",
549
+ "EXECUTOR_WORKTREE_PATH": str(repo),
550
+ "EXECUTOR_WORKTREE_BRANCH": "feat-tid",
551
+ "EXECUTOR_WORKTREE_BASE_REF": base_sha,
552
+ }
553
+ _reserve_implementation_stages(
554
+ _Inp(project_root=repo, approved_plan_path=str(plan_path), stage="auto"),
555
+ ctx2, stages,
556
+ )
557
+ assert ctx2["EXECUTOR_WORKTREE_BRANCH"] == "feat-tid-s2"
558
+ assert lookup("proj", "grp", "tid", stage_number=2) is not None
559
+
560
+
561
+ def test_multi_dependency_stage_prepare_rejected(tmp_path):
562
+ repo = tmp_path / "repo"
563
+ base_sha = _init_repo(repo)
564
+ plan_path = _consumers_dir(tmp_path)
565
+
566
+ from okstra_ctl.run import _reserve_implementation_stages, PrepareError
567
+ from okstra_ctl.worktree_registry import reserve as registry_reserve
568
+ registry_reserve(
569
+ project_id="proj", task_group="grp", task_id="tid",
570
+ worktree_path=str(repo), branch="feat-tid", base_ref=base_sha,
571
+ )
572
+
573
+ # consumers 에 stage 2, 3 done 으로 미리 기록해 ready 가 stage 4 다중 의존이 되게
574
+ from okstra_ctl.consumers import append_consumer
575
+ plan_run_root = Path(plan_path).resolve().parents[1]
576
+ append_consumer(plan_run_root, impl_task_key="proj/grp/tid",
577
+ stage=2, status="done", head_commit="c2")
578
+ append_consumer(plan_run_root, impl_task_key="proj/grp/tid",
579
+ stage=3, status="done", head_commit="c3")
580
+
581
+ ctx = {
582
+ "TASK_KEY": "proj/grp/tid",
583
+ "TASK_GROUP_SEGMENT": "grp", "TASK_ID_SEGMENT": "tid",
584
+ "EXECUTOR_WORKTREE_PATH": str(repo),
585
+ "EXECUTOR_WORKTREE_BRANCH": "feat-tid",
586
+ "EXECUTOR_WORKTREE_BASE_REF": base_sha,
587
+ }
588
+ stages = [_stage(2, []), _stage(3, []), _stage(4, [2, 3])]
589
+
590
+ with pytest.raises(PrepareError, match="multi-dependency stage"):
591
+ _reserve_implementation_stages(
592
+ _Inp(project_root=repo, approved_plan_path=str(plan_path), stage="auto"),
593
+ ctx, stages,
594
+ )
595
+ ```
596
+
597
+ - [ ] **Step 2: Run test to verify it fails**
598
+
599
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/test_okstra_run_reserve_implementation_stages.py -v`
600
+ Expected: FAIL — 세 테스트 모두 다른 이유로 실패해야 한다 (anchor 고정 안 됨 / ctx 덮어쓰기 안 됨 / 다중 의존 거부 안 됨).
601
+
602
+ - [ ] **Step 3: Replace `_reserve_implementation_stages` body**
603
+
604
+ > **수정 (2026-06-06):** 초기 plan 은 batch 의 모든 stage 를 같은 branch 로 reserve 하려 했으나, P1 registry 의 branch-uniqueness 불변식과 충돌한다(같은 branch 가 두 stage-key 에 등록될 수 없음). spec §2.3 에 명시된 "한 run = 한 stage" 원칙에 맞춰, `_resolve_effective_stages` 가 반환한 batch 의 **첫 번째 stage 만** 실행한다. batch 의 나머지는 자동으로 다음 run 의 ready 가 된다. cost-aware-design 의 batch 는 격리 모델에서 의미를 잃는다.
605
+
606
+ [run.py:890](../../../scripts/okstra_ctl/run.py) 의 함수 전체를 아래로 치환:
607
+
608
+ ```python
609
+ def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map: list) -> None:
610
+ """implementation run 의 첫 ready stage 를 결정하고 stage 격리 worktree 를
611
+ 발급해 ctx 의 EXECUTOR_WORKTREE_* 를 덮어쓴다. anchor base commit 을 1회
612
+ 고정하고 그 stage 를 consumers.jsonl 에 started 로 append.
613
+
614
+ spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 는 backward
615
+ compat 로 batch 리스트를 반환하지만 첫 번째만 실행한다 — stage 마다 격리
616
+ worktree·branch 가 필요해 batch 가 의미를 잃기 때문(cost-aware-design 의
617
+ run-batch 와의 의도된 트레이드오프)."""
618
+ from .consumers import read_consumers, append_consumer
619
+ from . import worktree as _worktree
620
+ from . import worktree_registry as _reg
621
+ import datetime as _dt
622
+
623
+ ctx["parsed_stage_map"] = ctx_stage_map
624
+ plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
625
+ consumed = read_consumers(plan_run_root)
626
+ done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
627
+ started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
628
+ reserved_stages = _reg.list_active_stage_numbers(
629
+ inp.project_id, inp.task_group, inp.task_id,
630
+ )
631
+
632
+ batch = _resolve_effective_stages(
633
+ ctx_stage_map, done_stages, inp.stage,
634
+ started_stages=started_stages, reserved_stages=reserved_stages,
635
+ )
636
+ # stage 격리: 한 run = 한 stage. 첫 ready stage 만 실행.
637
+ selected = batch[0]
638
+ ctx["effective_stages"] = [selected]
639
+ csv = str(selected)
640
+ ctx["EFFECTIVE_STAGES"] = csv
641
+ ctx["STAGE_BATCH_DIRECTIVE"] = (
642
+ f"- **Stage for this implementation run:** `{csv}`. "
643
+ "Execute exactly this Stage Map stage — this is the authoritative scope. "
644
+ "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
645
+ "and reserved this stage."
646
+ )
647
+ inp.stage = csv
648
+ print(f"selected stages: {csv}", file=sys.stdout)
649
+
650
+ # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
651
+ # stage 격리도 동일하게 degrade — consumers 만 기록 (기존 평면 동작 보존).
652
+ wt_status = ctx.get("EXECUTOR_WORKTREE_STATUS", "")
653
+ if wt_status.startswith("skipped"):
654
+ head_proc = _subprocess.run(
655
+ ["git", "rev-parse", "HEAD"],
656
+ cwd=inp.project_root, capture_output=True, text=True,
657
+ )
658
+ head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
659
+ now = _dt.datetime.now(_dt.timezone.utc).isoformat()
660
+ append_consumer(
661
+ plan_run_root,
662
+ impl_task_key=ctx["TASK_KEY"],
663
+ stage=selected,
664
+ status="started",
665
+ started_at=now,
666
+ head_commit=head_sha,
667
+ )
668
+ return
669
+
670
+ # anchor base commit 1회 고정 (task-key worktree HEAD 기준)
671
+ head_proc = _subprocess.run(
672
+ ["git", "rev-parse", "HEAD"],
673
+ cwd=inp.project_root, capture_output=True, text=True,
674
+ )
675
+ head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
676
+ if head_sha:
677
+ _reg.set_implementation_base(
678
+ inp.project_id, inp.task_group, inp.task_id, head_sha,
679
+ )
680
+ anchor = _reg.get_implementation_base(
681
+ inp.project_id, inp.task_group, inp.task_id,
682
+ ) or ""
683
+
684
+ # stage 격리 worktree 발급 — 의존 종류별 base 결정
685
+ selected_stage = next(
686
+ s for s in ctx_stage_map if s["stage_number"] == selected
687
+ )
688
+ consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
689
+ stage_base = _resolve_stage_base_commit(
690
+ selected_stage, consumer_done_rows, anchor_base_commit=anchor,
691
+ )
692
+ try:
693
+ provision = _worktree.provision_stage_worktree(
694
+ project_root=Path(inp.project_root),
695
+ project_id=inp.project_id,
696
+ task_group_segment=ctx["TASK_GROUP_SEGMENT"],
697
+ task_id_segment=ctx["TASK_ID_SEGMENT"],
698
+ work_category=inp.work_category,
699
+ stage_number=selected,
700
+ base_commit=stage_base,
701
+ )
702
+ except RuntimeError as exc:
703
+ raise PrepareError(f"stage worktree provisioning failed: {exc}") from exc
704
+
705
+ ctx["EXECUTOR_WORKTREE_PATH"] = provision.path
706
+ ctx["EXECUTOR_WORKTREE_BRANCH"] = provision.branch
707
+ ctx["EXECUTOR_WORKTREE_BASE_REF"] = provision.base_ref
708
+ ctx["EXECUTOR_WORKTREE_STATUS"] = provision.status
709
+ ctx["EXECUTOR_WORKTREE_NOTE"] = provision.note
710
+
711
+ # consumers append — stage worktree base 를 head_commit 으로
712
+ now = _dt.datetime.now(_dt.timezone.utc).isoformat()
713
+ append_consumer(
714
+ plan_run_root,
715
+ impl_task_key=ctx["TASK_KEY"],
716
+ stage=selected,
717
+ status="started",
718
+ started_at=now,
719
+ head_commit=provision.base_ref,
720
+ )
721
+ ```
722
+
723
+ - [ ] **Step 4: Run target tests**
724
+
725
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/test_okstra_run_reserve_implementation_stages.py -v`
726
+ Expected: PASS (3개).
727
+
728
+ - [ ] **Step 5: Regression — multi-stage / consumers / stage 관련 기존 테스트**
729
+
730
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/ -k "multi_stage or consumers or stage or reserve" -v`
731
+ Expected: 모두 PASS. 만약 e2e 가 `EXECUTOR_WORKTREE_*` 의 task-key 경로를 단언하는 게 있다면 그 단언은 **스토리상 stage-N 경로로 바뀌는 것이 정상**이지만, 임의 수정은 안 된다 — 깨지면 BLOCKED 보고. ([spec §2.1](../specs/2026-06-06-stage-worktree-isolation-design.md) 그대로의 의미 변경)
732
+
733
+ - [ ] **Step 6: Full regression**
734
+
735
+ Run: `cd /Volumes/Workspaces/workspace/projects/Okstra && python3 -m pytest tests/ -q`
736
+ Expected: PASS. 회귀 발견 시 BLOCKED.
737
+
738
+ - [ ] **Step 7: Commit**
739
+
740
+ ```bash
741
+ cd /Volumes/Workspaces/workspace/projects/Okstra
742
+ git add scripts/okstra_ctl/run.py tests/test_okstra_run_reserve_implementation_stages.py
743
+ git commit -m "feat(run): provision stage-isolated worktree in _reserve_implementation_stages"
744
+ ```
745
+
746
+ ---
747
+
748
+ ## Self-Review
749
+
750
+ **Spec coverage (P3 범위):**
751
+ - §2.1 implementation 에서만 stage worktree → Task 3 `_reserve_implementation_stages` 만 stage 발급 ✓
752
+ - §2.2 base 결정 (독립/단일 의존/다중 의존) → Task 1 `_resolve_stage_base_commit` ✓
753
+ - §2.3 점유 SSOT = registry → Task 3 가 P2 의 `list_active_stage_numbers` 를 occupied 로 전달 ✓
754
+ - §3.1 stage 경로/브랜치 → P1 `compute_*` 를 Task 2 `provision_stage_worktree` 가 사용 ✓
755
+ - §3.2 anchor base commit 고정(멱등) → Task 3 가 P1 `set_implementation_base` 호출 ✓
756
+ - §3.3 ready 식 → P2 가 강제, Task 3 가 인자 전달 ✓
757
+ - §9 #1 다중 의존 first-cut 미지원 → Task 1 + Task 3 의 e2e 가 거부 검증 ✓
758
+ - 프로파일 문구·CLI `--stage` 패스스루(B2)·release-handoff → **P4 범위**
759
+
760
+ **Placeholder scan:** 없음. 모든 step 에 실제 코드/테스트.
761
+
762
+ **Type consistency:** `stage_number: int`, `base_commit: str`, `consumer_done_rows: list[dict]` 모두 Task 1↔2↔3 일관. `provision_stage_worktree`/`provision_task_worktree` 둘 다 `WorktreeProvision` 반환.
763
+
764
+ **위험 플래그(구현자 주의):**
765
+ - Task 3 의 e2e 테스트는 `_Inp` 보조 클래스로 `PrepareInputs` 의 실제 구조에 의존한다. `_reserve_implementation_stages` 가 읽는 attribute(`project_root`, `project_id`, `task_group`, `task_id`, `work_category`, `approved_plan_path`, `stage`) 만 흉내내면 충분하다 — 추가 필드가 필요해지면 BLOCKED 보고.
766
+ - `provision_stage_worktree` 가 `_link_sync_dirs`/`_link_sync_files`/`_copy_snapshot_files` 를 호출하지만 sync dir 미존재면 no-op 이라 무해. snapshot 파일이 정의된 프로젝트는 별도지만 stage 격리 시 동일하게 작동해야 한다 (기존 패턴 답습).
767
+
768
+ ## 검증 (P3 완료 기준)
769
+
770
+ ```bash
771
+ cd /Volumes/Workspaces/workspace/projects/Okstra
772
+ python3 -m pytest tests/test_okstra_run_stage_base.py tests/test_okstra_worktree_stage_provision.py tests/test_okstra_run_reserve_implementation_stages.py -v
773
+ python3 -m pytest tests/ -q
774
+ ```