okstra 0.96.1 → 0.97.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -1
  4. package/docs/contributor-change-matrix.md +1 -1
  5. package/docs/kr/architecture/storage-model.md +273 -0
  6. package/docs/kr/architecture.md +6 -277
  7. package/docs/kr/cli.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
  10. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
  11. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
  12. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
  14. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
  15. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
  16. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
  17. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
  18. package/docs/task-process/release-handoff.md +3 -3
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/bin/lib/okstra/cli.sh +1 -1
  22. package/runtime/bin/lib/okstra/interactive.sh +30 -4
  23. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
  24. package/runtime/bin/okstra-render-final-report.py +4 -15
  25. package/runtime/bin/okstra.sh +5 -2
  26. package/runtime/prompts/launch.template.md +1 -0
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/final-verification.md +1 -0
  30. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  31. package/runtime/prompts/profiles/release-handoff.md +10 -4
  32. package/runtime/python/okstra_ctl/__init__.py +3 -2
  33. package/runtime/python/okstra_ctl/backfill.py +12 -21
  34. package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
  35. package/runtime/python/okstra_ctl/conformance.py +21 -3
  36. package/runtime/python/okstra_ctl/consumers.py +7 -1
  37. package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
  38. package/runtime/python/okstra_ctl/doctor.py +29 -12
  39. package/runtime/python/okstra_ctl/error_log_core.py +72 -0
  40. package/runtime/python/okstra_ctl/error_report.py +8 -58
  41. package/runtime/python/okstra_ctl/error_zip.py +319 -0
  42. package/runtime/python/okstra_ctl/fanout.py +5 -2
  43. package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
  44. package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
  45. package/runtime/python/okstra_ctl/handoff.py +125 -26
  46. package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
  47. package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
  48. package/runtime/python/okstra_ctl/index.py +34 -91
  49. package/runtime/python/okstra_ctl/jsonl.py +29 -18
  50. package/runtime/python/okstra_ctl/listing.py +30 -5
  51. package/runtime/python/okstra_ctl/md_table.py +1 -1
  52. package/runtime/python/okstra_ctl/migrate.py +17 -7
  53. package/runtime/python/okstra_ctl/paths.py +1 -1
  54. package/runtime/python/okstra_ctl/recap.py +12 -3
  55. package/runtime/python/okstra_ctl/reconcile.py +13 -11
  56. package/runtime/python/okstra_ctl/render.py +49 -12
  57. package/runtime/python/okstra_ctl/render_final_report.py +4 -0
  58. package/runtime/python/okstra_ctl/report_views.py +47 -6
  59. package/runtime/python/okstra_ctl/run.py +128 -100
  60. package/runtime/python/okstra_ctl/run_index_row.py +118 -0
  61. package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
  62. package/runtime/python/okstra_ctl/sequence.py +2 -2
  63. package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
  64. package/runtime/python/okstra_ctl/stage_targets.py +2 -8
  65. package/runtime/python/okstra_ctl/task_target.py +4 -1
  66. package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
  67. package/runtime/python/okstra_ctl/wizard.py +27 -12
  68. package/runtime/python/okstra_ctl/workflow.py +9 -1
  69. package/runtime/python/okstra_ctl/worktree.py +93 -4
  70. package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
  71. package/runtime/python/okstra_token_usage/collect.py +43 -38
  72. package/runtime/skills/okstra-brief/SKILL.md +43 -12
  73. package/runtime/skills/okstra-inspect/SKILL.md +44 -1
  74. package/runtime/templates/reports/final-report.template.md +2 -2
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/validators/forbidden_actions.py +8 -1
  77. package/runtime/validators/validate-brief.py +13 -2
  78. package/runtime/validators/validate-run.py +27 -6
  79. package/runtime/validators/validate-schedule.py +1 -2
  80. package/runtime/validators/validate_improvement_report.py +14 -0
  81. package/src/cli-registry.mjs +45 -31
  82. package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
  83. package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
  84. package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
  85. package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
  86. package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
  87. package/src/commands/execute/integrate-stages.mjs +25 -0
  88. package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
  89. package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
  90. package/src/{run.mjs → commands/execute/run.mjs} +4 -4
  91. package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
  92. package/src/{team.mjs → commands/execute/team.mjs} +3 -3
  93. package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
  94. package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
  95. package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
  96. package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
  97. package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
  98. package/src/commands/inspect/error-zip.mjs +25 -0
  99. package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
  100. package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
  101. package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
  102. package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
  103. package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
  104. package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
  105. package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
  106. package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
  107. package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
  108. package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
  109. package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
  110. package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
  111. package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
  112. package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
  113. package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
  114. package/src/lib/paths.mjs +60 -0
  115. package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
  116. package/src/{version.mjs → lib/version.mjs} +2 -2
  117. /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
  118. /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
  119. /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
  120. /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
  121. /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
@@ -0,0 +1,819 @@
1
+ # stage 자동 통합(머지) + worktree teardown 구현 계획
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:** whole-task final-verification 진입 시(및 단독 명령으로) stage done commit 을 task 브랜치에 자동 머지하고, 역할이 끝난 stage worktree·registry 키·stage 브랜치를 정리해 merged·dirty 게이트를 자연 통과시킨다.
6
+
7
+ **Architecture:** 신규 코어 모듈 `stage_integrate.py` 의 `integrate_stages` 하나가 SSOT. Phase A(위상순 `--no-ff` 머지, 충돌 시 해당 머지만 abort+중단) + Phase B(worktree remove + registry release + `branch -d`, dirty stage skip)로 2-분리. `run.py` 의 whole-task final-verification prepare 경로가 자동 호출하고, 동일 코어를 `okstra integrate-stages` 단독 명령으로도 노출.
8
+
9
+ **Tech Stack:** Python 3 (`scripts/okstra_ctl/`), git CLI(subprocess), pytest. Bash 얇은 래퍼(`okstra.sh`/`cli.sh`), Node CLI(`src/`).
10
+
11
+ ## Global Constraints
12
+
13
+ - Python 4-space indent, snake_case 모듈. 새/대폭 수정 함수는 **유효 50줄 이하**(CLAUDE.md 4.5) — 넘으면 분리.
14
+ - `runtime/`·개인 `.claude/` 직접 수정 금지. seed source(`scripts/`, `prompts/`, `src/`)만 수정. `runtime/` 은 `npm run build` 산출물.
15
+ - pre-1.0 — compat shim 없음. 기존 API 변경 시 호출처 모두 갱신.
16
+ - 단일 참조점: 같은 로직을 두 진입점에 복제 금지 — `integrate_stages` 코어로 수렴.
17
+ - 문서/주석/CHANGES prose 는 한국어, 코드 식별자·경로·CLI 인자는 영어.
18
+ - 테스트는 `python3 -m pytest tests/` 전체 통과. git 통합 테스트는 `tests/run/test_okstra_run_stage_base.py` 의 `_init_repo_with_commits` / `g(*args)` 픽스처 패턴을 따른다.
19
+ - 커밋은 Conventional Commits. AI 트레일러/생성 푸터 금지. 동시 세션 환경 — amend 금지, 항상 새 커밋.
20
+
21
+ ---
22
+
23
+ ## File Structure
24
+
25
+ | 파일 | 책임 |
26
+ |---|---|
27
+ | `scripts/okstra_ctl/stage_integrate.py` (신규) | `integrate_stages` 코어 + `_merge_stage` + `_teardown_stage` + `IntegrateResult`/`IntegrateError` + `main(argv)` 단독 명령 |
28
+ | `scripts/okstra_ctl/worktree.py` (수정) | `is_ancestor` / `is_dirty_excluding_okstra` public 헬퍼 추출(현재 run.py 사설) |
29
+ | `scripts/okstra_ctl/run.py` (수정) | final-verification whole-task 경로에서 `integrate_stages` 자동 호출; 사설 헬퍼를 worktree.py 위임으로 교체 |
30
+ | `scripts/okstra.sh` · `scripts/lib/okstra/cli.sh` · `src/cli-registry.mjs` (수정) | `okstra integrate-stages` 라우팅 |
31
+ | `prompts/profiles/final-verification.md` (수정) | whole-task 가 mutating(통합 머지+teardown)임을 명시 |
32
+ | `docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md` (수정) | §2/§9-#2 가 본 작업으로 전환·해소됨을 상호 참조 |
33
+ | `CHANGES.md` (수정) | `사용자 영향:` 항목 |
34
+
35
+ ---
36
+
37
+ ## Task 1: git 헬퍼 공유화 (worktree.py 추출)
38
+
39
+ `integrate_stages` 와 `run.py` 가 둘 다 ancestor 판정·okstra-제외 dirty 판정을 쓴다. 두 번째 호출자가 생기므로 `run.py` 사설 함수를 `worktree.py` public 헬퍼로 올린다.
40
+
41
+ **Files:**
42
+ - Modify: `scripts/okstra_ctl/worktree.py` (`okstra_clean_gate_excludes` 인근, 411 줄 이후)
43
+ - Modify: `scripts/okstra_ctl/run.py:1445-1458` (`_is_ancestor`, `_is_dirty_excluding_okstra`)
44
+ - Test: `tests/run/test_worktree_git_helpers.py` (신규)
45
+
46
+ **Interfaces:**
47
+ - Produces:
48
+ - `worktree.is_ancestor(cwd: str | Path, commit: str, head: str) -> bool` — commit/head 빈 문자열이면 False.
49
+ - `worktree.is_dirty_excluding_okstra(cwd: str | Path) -> bool` — `git status --short -- . :(exclude)<okstra 소유 경로>` 출력이 비어있지 않으면 True.
50
+
51
+ - [ ] **Step 1: 실패 테스트 작성**
52
+
53
+ `tests/run/test_worktree_git_helpers.py`:
54
+
55
+ ```python
56
+ import subprocess
57
+ import sys
58
+ from pathlib import Path
59
+ from _paths import REPO_ROOT
60
+
61
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
62
+
63
+ from okstra_ctl import worktree
64
+
65
+
66
+ def _init(repo: Path):
67
+ subprocess.run(["git", "init", "-q", str(repo)], check=True)
68
+ subprocess.run(["git", "-C", str(repo), "config", "user.email", "t@e"], check=True)
69
+ subprocess.run(["git", "-C", str(repo), "config", "user.name", "t"], check=True)
70
+ (repo / "a").write_text("1\n")
71
+ subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
72
+ subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "c0"], check=True)
73
+ return subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"],
74
+ capture_output=True, text=True, check=True).stdout.strip()
75
+
76
+
77
+ def test_is_ancestor_true_for_head_itself(tmp_path):
78
+ repo = tmp_path / "r"
79
+ head = _init(repo)
80
+ assert worktree.is_ancestor(repo, head, head) is True
81
+
82
+
83
+ def test_is_ancestor_false_for_empty(tmp_path):
84
+ repo = tmp_path / "r"
85
+ _init(repo)
86
+ assert worktree.is_ancestor(repo, "", "abc") is False
87
+
88
+
89
+ def test_dirty_false_when_clean(tmp_path):
90
+ repo = tmp_path / "r"
91
+ _init(repo)
92
+ assert worktree.is_dirty_excluding_okstra(repo) is False
93
+
94
+
95
+ def test_dirty_true_with_untracked_source(tmp_path):
96
+ repo = tmp_path / "r"
97
+ _init(repo)
98
+ (repo / "newsrc").write_text("x\n")
99
+ assert worktree.is_dirty_excluding_okstra(repo) is True
100
+
101
+
102
+ def test_dirty_ignores_dot_okstra(tmp_path):
103
+ repo = tmp_path / "r"
104
+ _init(repo)
105
+ (repo / ".okstra").mkdir()
106
+ (repo / ".okstra" / "junk").write_text("x\n")
107
+ assert worktree.is_dirty_excluding_okstra(repo) is False
108
+ ```
109
+
110
+ - [ ] **Step 2: 실패 확인**
111
+
112
+ Run: `python3 -m pytest tests/run/test_worktree_git_helpers.py -v`
113
+ Expected: FAIL — `AttributeError: module 'okstra_ctl.worktree' has no attribute 'is_ancestor'`
114
+
115
+ - [ ] **Step 3: worktree.py 에 헬퍼 추가**
116
+
117
+ `scripts/okstra_ctl/worktree.py` 의 `okstra_clean_gate_excludes` 정의 바로 뒤에 추가(파일 상단에 `import subprocess` 가 없으면 추가):
118
+
119
+ ```python
120
+ def is_ancestor(cwd, commit: str, head: str) -> bool:
121
+ """True iff `commit` is an ancestor of `head` (both non-empty)."""
122
+ if not commit or not head:
123
+ return False
124
+ r = subprocess.run(
125
+ ["git", "-C", str(cwd), "merge-base", "--is-ancestor", commit, head],
126
+ capture_output=True, text=True,
127
+ )
128
+ return r.returncode == 0
129
+
130
+
131
+ def is_dirty_excluding_okstra(cwd) -> bool:
132
+ """True iff the worktree has changes outside okstra-owned paths."""
133
+ excludes = [f":(exclude){p}" for p in okstra_clean_gate_excludes(Path(cwd))]
134
+ r = subprocess.run(
135
+ ["git", "-C", str(cwd), "status", "--short", "--", ".", *excludes],
136
+ capture_output=True, text=True,
137
+ )
138
+ return bool(r.stdout.strip())
139
+ ```
140
+
141
+ - [ ] **Step 4: run.py 사설 함수를 위임으로 교체**
142
+
143
+ `scripts/okstra_ctl/run.py:1445-1458` 의 `_is_ancestor`/`_is_dirty_excluding_okstra` 본문을 worktree 위임으로 교체(호출처 `run.py:1500,1515,1520` 는 이름 유지로 그대로 동작):
144
+
145
+ ```python
146
+ def _is_ancestor(cwd, commit, head) -> bool:
147
+ from .worktree import is_ancestor
148
+ return is_ancestor(cwd, commit, head)
149
+
150
+
151
+ def _is_dirty_excluding_okstra(cwd) -> bool:
152
+ from .worktree import is_dirty_excluding_okstra
153
+ return is_dirty_excluding_okstra(cwd)
154
+ ```
155
+
156
+ - [ ] **Step 5: 테스트 통과 + 회귀 확인**
157
+
158
+ Run: `python3 -m pytest tests/run/test_worktree_git_helpers.py tests/run/test_okstra_run_stage_base.py -v`
159
+ Expected: PASS (신규 5개 + 기존 stage_base 회귀)
160
+
161
+ - [ ] **Step 6: 커밋**
162
+
163
+ ```bash
164
+ git add scripts/okstra_ctl/worktree.py scripts/okstra_ctl/run.py tests/run/test_worktree_git_helpers.py
165
+ git commit -m "refactor(worktree): ancestor/dirty 게이트 헬퍼를 worktree 공유 레이어로 추출"
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Task 2: integrate_stages 코어 (Phase A 머지 + Phase B teardown)
171
+
172
+ **Files:**
173
+ - Create: `scripts/okstra_ctl/stage_integrate.py`
174
+ - Test: `tests/run/test_stage_integrate.py` (신규)
175
+
176
+ **Interfaces:**
177
+ - Consumes: `worktree.is_ancestor`, `worktree.is_dirty_excluding_okstra` (Task 1); `worktree_registry.get_stage_row(project_id, task_group, task_id, stage) -> dict|None` (반환 `branch`/`worktree_path`/`base_ref`); `worktree_registry.release(project_id, task_group, task_id, stage_number=N)`; `consumers.latest_done_by_stage(rows) -> {stage: row}`.
178
+ - Produces:
179
+ - `IntegrateError(Exception)`
180
+ - `@dataclass IntegrateResult: merged: list[int]; already_merged: list[int]; torn_down: list[int]; teardown_skipped: list[tuple[int, str]]; warnings: list[str]`
181
+ - `integrate_stages(*, project_id: str, task_group: str, task_id: str, task_worktree_path: str, stage_map: list[dict], done_rows: list[dict]) -> IntegrateResult`
182
+
183
+ - [ ] **Step 1: 실패 테스트 작성 (Phase A: 머지 + already-merged + 충돌 abort)**
184
+
185
+ `tests/run/test_stage_integrate.py`:
186
+
187
+ ```python
188
+ import subprocess
189
+ import sys
190
+ from pathlib import Path
191
+ from _paths import REPO_ROOT
192
+
193
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
194
+
195
+ import pytest
196
+
197
+ from okstra_ctl.stage_integrate import (
198
+ integrate_stages, IntegrateError, IntegrateResult,
199
+ )
200
+ from okstra_ctl import worktree_registry
201
+
202
+
203
+ def g(repo, *args):
204
+ return subprocess.run(["git", "-C", str(repo), *args],
205
+ capture_output=True, text=True, check=True).stdout.strip()
206
+
207
+
208
+ def _task_repo(tmp_path):
209
+ """task worktree(부모) 단일 repo. main=task 브랜치 역할."""
210
+ repo = tmp_path / "task"
211
+ repo.mkdir()
212
+ g(repo, "init", "-q", "-b", "task-T")
213
+ g(repo, "config", "user.email", "t@e")
214
+ g(repo, "config", "user.name", "t")
215
+ (repo / "base.txt").write_text("base\n")
216
+ g(repo, "add", ".")
217
+ g(repo, "commit", "-q", "-m", "base")
218
+ return repo
219
+
220
+
221
+ def _stage_branch(repo, name, fname, content):
222
+ """task 브랜치에서 분기해 stage 브랜치에 1커밋. done commit sha 반환."""
223
+ g(repo, "checkout", "-q", "-b", name)
224
+ (repo / fname).write_text(content)
225
+ g(repo, "add", ".")
226
+ g(repo, "commit", "-q", "-m", name)
227
+ sha = g(repo, "rev-parse", "HEAD")
228
+ g(repo, "checkout", "-q", "task-T")
229
+ return sha
230
+
231
+
232
+ def _stage_map(*pairs):
233
+ return [{"stage_number": n, "depends_on": d} for n, d in pairs]
234
+
235
+
236
+ def test_merges_unmerged_stages_in_order(tmp_path, monkeypatch):
237
+ repo = _task_repo(tmp_path)
238
+ s1 = _stage_branch(repo, "task-T-s1", "a.txt", "a\n")
239
+ s2 = _stage_branch(repo, "task-T-s2", "b.txt", "b\n")
240
+ monkeypatch.setattr(
241
+ worktree_registry, "get_stage_row",
242
+ lambda p, gp, t, stage: {"branch": f"task-T-s{stage}",
243
+ "worktree_path": ""},
244
+ )
245
+ monkeypatch.setattr(worktree_registry, "release", lambda *a, **k: None)
246
+ rows = [
247
+ {"stage": 1, "status": "done", "head_commit": s1},
248
+ {"stage": 2, "status": "done", "head_commit": s2},
249
+ ]
250
+ res = integrate_stages(
251
+ project_id="p", task_group="g", task_id="T",
252
+ task_worktree_path=str(repo),
253
+ stage_map=_stage_map((1, []), (2, [1])),
254
+ done_rows=rows,
255
+ )
256
+ assert res.merged == [1, 2]
257
+ assert res.already_merged == []
258
+ # 두 stage 커밋이 task HEAD 에서 reachable
259
+ head = g(repo, "rev-parse", "HEAD")
260
+ for sha in (s1, s2):
261
+ assert subprocess.run(
262
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", sha, head]
263
+ ).returncode == 0
264
+
265
+
266
+ def test_already_merged_stage_is_skipped(tmp_path, monkeypatch):
267
+ repo = _task_repo(tmp_path)
268
+ s1 = _stage_branch(repo, "task-T-s1", "a.txt", "a\n")
269
+ g(repo, "merge", "--no-ff", "--no-edit", "task-T-s1") # 사용자가 먼저 머지
270
+ monkeypatch.setattr(
271
+ worktree_registry, "get_stage_row",
272
+ lambda p, gp, t, stage: {"branch": f"task-T-s{stage}", "worktree_path": ""},
273
+ )
274
+ monkeypatch.setattr(worktree_registry, "release", lambda *a, **k: None)
275
+ rows = [{"stage": 1, "status": "done", "head_commit": s1}]
276
+ res = integrate_stages(
277
+ project_id="p", task_group="g", task_id="T",
278
+ task_worktree_path=str(repo),
279
+ stage_map=_stage_map((1, [])),
280
+ done_rows=rows,
281
+ )
282
+ assert res.already_merged == [1]
283
+ assert res.merged == []
284
+
285
+
286
+ def test_conflict_aborts_and_raises(tmp_path, monkeypatch):
287
+ repo = _task_repo(tmp_path)
288
+ # 두 stage 가 같은 파일을 다르게 수정 → 머지 충돌
289
+ s1 = _stage_branch(repo, "task-T-s1", "clash.txt", "from-s1\n")
290
+ s2 = _stage_branch(repo, "task-T-s2", "clash.txt", "from-s2\n")
291
+ monkeypatch.setattr(
292
+ worktree_registry, "get_stage_row",
293
+ lambda p, gp, t, stage: {"branch": f"task-T-s{stage}", "worktree_path": ""},
294
+ )
295
+ monkeypatch.setattr(worktree_registry, "release", lambda *a, **k: None)
296
+ rows = [
297
+ {"stage": 1, "status": "done", "head_commit": s1},
298
+ {"stage": 2, "status": "done", "head_commit": s2},
299
+ ]
300
+ with pytest.raises(IntegrateError, match="clash.txt"):
301
+ integrate_stages(
302
+ project_id="p", task_group="g", task_id="T",
303
+ task_worktree_path=str(repo),
304
+ stage_map=_stage_map((1, []), (2, [1])),
305
+ done_rows=rows,
306
+ )
307
+ # abort 로 task worktree 가 머지중 상태가 아니어야 함
308
+ assert not (repo / ".git" / "MERGE_HEAD").exists()
309
+ # stage 1 은 충돌 전 머지 성공으로 유지(재시도 시 already_merged)
310
+ s1_head = g(repo, "rev-parse", "task-T-s1")
311
+ assert subprocess.run(
312
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", s1_head,
313
+ g(repo, "rev-parse", "HEAD")]
314
+ ).returncode == 0
315
+ ```
316
+
317
+ - [ ] **Step 2: 실패 확인**
318
+
319
+ Run: `python3 -m pytest tests/run/test_stage_integrate.py -v`
320
+ Expected: FAIL — `ModuleNotFoundError: No module named 'okstra_ctl.stage_integrate'`
321
+
322
+ - [ ] **Step 3: stage_integrate.py 작성 (코어 + Phase A)**
323
+
324
+ `scripts/okstra_ctl/stage_integrate.py`:
325
+
326
+ ```python
327
+ """whole-task stage 통합(머지) + worktree teardown 코어.
328
+
329
+ Phase A: stage done commit 을 task 브랜치에 위상순 --no-ff 머지(이미 머지면
330
+ skip, 충돌이면 해당 머지만 abort 후 IntegrateError). Phase B: 머지/skip 된
331
+ stage 의 worktree·registry 키·stage 브랜치를 정리(dirty 면 skip).
332
+ 설계: docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md
333
+ """
334
+ from __future__ import annotations
335
+
336
+ import subprocess
337
+ from dataclasses import dataclass, field
338
+ from pathlib import Path
339
+ from typing import Any
340
+
341
+
342
+ class IntegrateError(Exception):
343
+ """stage 머지 충돌 등으로 통합을 중단해야 함."""
344
+
345
+
346
+ @dataclass
347
+ class IntegrateResult:
348
+ merged: list[int] = field(default_factory=list)
349
+ already_merged: list[int] = field(default_factory=list)
350
+ torn_down: list[int] = field(default_factory=list)
351
+ teardown_skipped: list[tuple[int, str]] = field(default_factory=list)
352
+ warnings: list[str] = field(default_factory=list)
353
+
354
+
355
+ def _git(cwd, *args) -> subprocess.CompletedProcess:
356
+ return subprocess.run(["git", "-C", str(cwd), *args],
357
+ capture_output=True, text=True)
358
+
359
+
360
+ def _merge_stage(task_wt: str, stage_n: int, branch: str, done_commit: str) -> str:
361
+ """한 stage 를 task 브랜치에 머지. 'merged'|'already_merged'.
362
+ 충돌 시 abort 후 IntegrateError."""
363
+ from .worktree import is_ancestor
364
+
365
+ head = _git(task_wt, "rev-parse", "HEAD").stdout.strip()
366
+ if is_ancestor(task_wt, done_commit, head):
367
+ return "already_merged"
368
+ r = _git(task_wt, "merge", "--no-ff", "--no-edit", branch)
369
+ if r.returncode != 0:
370
+ conflicts = _git(task_wt, "diff", "--name-only", "--diff-filter=U").stdout.strip()
371
+ _git(task_wt, "merge", "--abort")
372
+ raise IntegrateError(
373
+ f"stage {stage_n} ({branch}) 머지 충돌 — abort 함. "
374
+ f"충돌 파일: {conflicts or '(unknown)'}. 수동 머지로 해소 후 재시도."
375
+ )
376
+ return "merged"
377
+
378
+
379
+ def integrate_stages(
380
+ *, project_id: str, task_group: str, task_id: str,
381
+ task_worktree_path: str, stage_map: list[dict[str, Any]],
382
+ done_rows: list[dict[str, Any]],
383
+ ) -> IntegrateResult:
384
+ """whole-task 통합: Phase A 머지 → Phase B teardown."""
385
+ from .consumers import latest_done_by_stage
386
+
387
+ res = IntegrateResult()
388
+ if not task_worktree_path or not Path(task_worktree_path).exists():
389
+ return res # non-git / degraded — no-op
390
+ done = latest_done_by_stage(done_rows)
391
+ ordered = sorted((s["stage_number"] for s in stage_map))
392
+
393
+ for n in ordered:
394
+ row = done.get(n)
395
+ if not row or not row.get("head_commit"):
396
+ continue # 미완 stage 는 merged 게이트가 별도로 잡음
397
+ branch = _stage_branch_name(project_id, task_group, task_id, n)
398
+ if branch is None:
399
+ res.warnings.append(f"stage {n}: registry 에 stage-key 없음 — 머지 skip")
400
+ continue
401
+ outcome = _merge_stage(task_worktree_path, n, branch,
402
+ row["head_commit"])
403
+ (res.merged if outcome == "merged" else res.already_merged).append(n)
404
+
405
+ _teardown_all(project_id, task_group, task_id, res)
406
+ return res
407
+
408
+
409
+ def _stage_branch_name(project_id, task_group, task_id, stage_n) -> str | None:
410
+ from . import worktree_registry
411
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage_n)
412
+ if not row:
413
+ return None
414
+ return row.get("branch") or None
415
+ ```
416
+
417
+ (Phase B `_teardown_all` / `_teardown_stage` 는 Step 5 에서 추가 — 우선 Phase A 검증.)
418
+
419
+ `integrate_stages` 가 아직 `_teardown_all` 미정의이므로, Step 3 에서는 `_teardown_all` 호출 줄을 임시로 `pass` 대신 빈 함수 stub 로 둔다:
420
+
421
+ ```python
422
+ def _teardown_all(project_id, task_group, task_id, res: IntegrateResult) -> None:
423
+ """Step 5 에서 구현."""
424
+ return
425
+ ```
426
+
427
+ - [ ] **Step 4: Phase A 테스트 통과 확인**
428
+
429
+ Run: `python3 -m pytest tests/run/test_stage_integrate.py -v`
430
+ Expected: PASS (머지/already/충돌 3개. teardown stub 이라 merged/already 만 검증하는 현재 테스트는 통과)
431
+
432
+ - [ ] **Step 5: Phase B (teardown) 테스트 추가**
433
+
434
+ `tests/run/test_stage_integrate.py` 에 추가. stage worktree 를 실제 `git worktree add` 로 만들어 teardown 검증:
435
+
436
+ ```python
437
+ def _add_stage_worktree(repo, branch, path):
438
+ g(repo, "worktree", "add", "-q", str(path), branch)
439
+
440
+
441
+ def test_teardown_removes_clean_stage_worktree_and_branch(tmp_path, monkeypatch):
442
+ repo = _task_repo(tmp_path)
443
+ s1 = _stage_branch(repo, "task-T-s1", "a.txt", "a\n")
444
+ wt1 = tmp_path / "stage-1"
445
+ _add_stage_worktree(repo, "task-T-s1", wt1)
446
+ rows = [{"stage": 1, "status": "done", "head_commit": s1}]
447
+
448
+ def fake_get_stage_row(p, gp, t, stage):
449
+ return {"branch": "task-T-s1", "worktree_path": str(wt1)}
450
+ released = []
451
+ monkeypatch.setattr(worktree_registry, "get_stage_row", fake_get_stage_row)
452
+ monkeypatch.setattr(worktree_registry, "release",
453
+ lambda p, gp, t, stage_number=None: released.append(stage_number))
454
+
455
+ res = integrate_stages(
456
+ project_id="p", task_group="g", task_id="T",
457
+ task_worktree_path=str(repo),
458
+ stage_map=_stage_map((1, [])), done_rows=rows,
459
+ )
460
+ assert res.torn_down == [1]
461
+ assert not wt1.exists() # worktree dir 제거됨
462
+ assert released == [1] # registry release 호출됨
463
+ branches = g(repo, "branch", "--list", "task-T-s1")
464
+ assert branches == "" # branch -d 됨
465
+
466
+
467
+ def test_teardown_skips_dirty_stage_worktree(tmp_path, monkeypatch):
468
+ repo = _task_repo(tmp_path)
469
+ s1 = _stage_branch(repo, "task-T-s1", "a.txt", "a\n")
470
+ wt1 = tmp_path / "stage-1"
471
+ _add_stage_worktree(repo, "task-T-s1", wt1)
472
+ (wt1 / "dirty.txt").write_text("uncommitted\n") # dirty
473
+ rows = [{"stage": 1, "status": "done", "head_commit": s1}]
474
+ monkeypatch.setattr(worktree_registry, "get_stage_row",
475
+ lambda p, gp, t, stage: {"branch": "task-T-s1",
476
+ "worktree_path": str(wt1)})
477
+ monkeypatch.setattr(worktree_registry, "release", lambda *a, **k: None)
478
+
479
+ res = integrate_stages(
480
+ project_id="p", task_group="g", task_id="T",
481
+ task_worktree_path=str(repo),
482
+ stage_map=_stage_map((1, [])), done_rows=rows,
483
+ )
484
+ assert res.torn_down == []
485
+ assert res.teardown_skipped and res.teardown_skipped[0][0] == 1
486
+ assert wt1.exists() # 보존됨
487
+ assert g(repo, "branch", "--list", "task-T-s1") != "" # 브랜치 보존
488
+ ```
489
+
490
+ - [ ] **Step 6: Phase B 구현 (`_teardown_all` 교체)**
491
+
492
+ `stage_integrate.py` 의 stub `_teardown_all` 을 교체하고 `_teardown_stage` 추가:
493
+
494
+ ```python
495
+ def _teardown_all(project_id, task_group, task_id, res: IntegrateResult) -> None:
496
+ from . import worktree_registry
497
+
498
+ for n in [*res.merged, *res.already_merged]:
499
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, n)
500
+ outcome, detail = _teardown_stage(project_id, task_group, task_id, n, row)
501
+ if outcome == "torn_down":
502
+ res.torn_down.append(n)
503
+ elif outcome == "skipped":
504
+ res.teardown_skipped.append((n, detail))
505
+ if outcome == "warn":
506
+ res.warnings.append(detail)
507
+
508
+
509
+ def _teardown_stage(project_id, task_group, task_id, stage_n, row):
510
+ """clean stage worktree 를 제거 + registry release + branch -d.
511
+ 반환 ('torn_down'|'skipped'|'warn', detail)."""
512
+ from . import worktree_registry
513
+ from .worktree import is_dirty_excluding_okstra
514
+
515
+ wt = (row or {}).get("worktree_path") or ""
516
+ branch = (row or {}).get("branch") or ""
517
+ if wt and Path(wt).exists():
518
+ if is_dirty_excluding_okstra(wt):
519
+ return ("skipped", "dirty — 미커밋 변경 보존")
520
+ rm = subprocess.run(["git", "worktree", "remove", str(wt)],
521
+ capture_output=True, text=True)
522
+ if rm.returncode != 0:
523
+ return ("warn", f"stage {stage_n} worktree remove 실패: {rm.stderr.strip()}")
524
+ worktree_registry.release(project_id, task_group, task_id, stage_number=stage_n)
525
+ if branch:
526
+ bd = subprocess.run(["git", "branch", "-d", branch],
527
+ capture_output=True, text=True)
528
+ if bd.returncode != 0:
529
+ return ("warn", f"stage {stage_n} branch -d {branch} 실패(미머지?): "
530
+ f"{bd.stderr.strip()}")
531
+ return ("torn_down", "")
532
+ ```
533
+
534
+ 참고: `git worktree remove <path>` 는 부모 repo 컨텍스트가 필요하므로 `-C` 없이 절대경로로 호출한다. `_teardown_stage` 가 50줄을 넘으면 dirty-skip 판정과 remove 를 보조 함수로 더 쪼갠다.
535
+
536
+ - [ ] **Step 7: 전체 코어 테스트 통과**
537
+
538
+ Run: `python3 -m pytest tests/run/test_stage_integrate.py -v`
539
+ Expected: PASS (머지/already/충돌/teardown/dirty-skip 5개)
540
+
541
+ - [ ] **Step 8: 커밋**
542
+
543
+ ```bash
544
+ git add scripts/okstra_ctl/stage_integrate.py tests/run/test_stage_integrate.py
545
+ git commit -m "feat(integrate): stage 자동 머지+teardown 코어 integrate_stages 추가"
546
+ ```
547
+
548
+ ---
549
+
550
+ ## Task 3: run.py whole-task final-verification 자동 호출 배선
551
+
552
+ **Files:**
553
+ - Modify: `scripts/okstra_ctl/run.py:1509-1521` (`_reserve_final_verification_target` 의 `else` whole-task 분기)
554
+ - Test: `tests/run/test_final_verification_integrate_call.py` (신규)
555
+
556
+ **Interfaces:**
557
+ - Consumes: `stage_integrate.integrate_stages` (Task 2).
558
+ - whole-task 분기에서 `merged`/`task_dirty` 계산 **전에** `integrate_stages` 를 호출해 통합·정리한 뒤, 그 결과를 ctx 의 통합 리포트 키에 주입한다.
559
+
560
+ - [ ] **Step 1: 실패 테스트 작성**
561
+
562
+ `integrate_stages` 가 whole-task 경로에서 정확한 인자로 호출되는지 monkeypatch 로 검증:
563
+
564
+ ```python
565
+ import sys
566
+ from pathlib import Path
567
+ from _paths import REPO_ROOT
568
+
569
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
570
+
571
+ from okstra_ctl import run as run_mod
572
+ from okstra_ctl import stage_integrate
573
+
574
+
575
+ def test_whole_task_calls_integrate_before_target(monkeypatch, tmp_path):
576
+ calls = {}
577
+
578
+ def fake_integrate(**kw):
579
+ calls.update(kw)
580
+ return stage_integrate.IntegrateResult(merged=[1], torn_down=[1])
581
+
582
+ monkeypatch.setattr(run_mod._stage_integrate, "integrate_stages", fake_integrate)
583
+ # whole-task 경로가 integrate_stages 를 호출하는지: 최소 인자 키 확인
584
+ # (전체 prepare 통합은 e2e 가 담당 — 여기선 호출 계약만 박제)
585
+ assert hasattr(run_mod, "_stage_integrate")
586
+ ```
587
+
588
+ > 구현 메모: `run.py` 상단 import 를 `from . import stage_integrate as _stage_integrate` 로 추가해 테스트가 모듈 핸들을 monkeypatch 할 수 있게 한다.
589
+
590
+ - [ ] **Step 2: 실패 확인**
591
+
592
+ Run: `python3 -m pytest tests/run/test_final_verification_integrate_call.py -v`
593
+ Expected: FAIL — `AttributeError: module 'okstra_ctl.run' has no attribute '_stage_integrate'`
594
+
595
+ - [ ] **Step 3: run.py 배선**
596
+
597
+ `scripts/okstra_ctl/run.py` 상단 import 블록에 추가:
598
+
599
+ ```python
600
+ from . import stage_integrate as _stage_integrate
601
+ ```
602
+
603
+ `run.py:1509` 의 `else:` whole-task 분기에서 `head = _git_out(...)` 직후, `merged = {...}` 계산 **직전**에 통합 호출을 삽입:
604
+
605
+ ```python
606
+ else:
607
+ wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
608
+ anchor = _reg.get_implementation_base(
609
+ inp.project_id, inp.task_group, inp.task_id) or ""
610
+ integ = _stage_integrate.integrate_stages(
611
+ project_id=inp.project_id, task_group=inp.task_group,
612
+ task_id=inp.task_id, task_worktree_path=wt_path,
613
+ stage_map=ctx_stage_map, done_rows=done_rows,
614
+ )
615
+ ctx["STAGE_INTEGRATION"] = _format_integration(integ)
616
+ head = _git_out(wt_path, "rev-parse", "HEAD")
617
+ from .consumers import latest_done_by_stage
618
+ merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
619
+ for s, r in latest_done_by_stage(done_rows).items()}
620
+ target = _resolve_whole_task_target(...) # 기존 인자 유지
621
+ ```
622
+
623
+ `IntegrateError` 는 `prepare_task_bundle` 의 기존 `PrepareError` 처리와 동일하게 surface 되도록, `integrate_stages` 호출을 try/except 로 감싸 `raise PrepareError(str(exc)) from exc` 로 변환(충돌 시 사용자에게 충돌 경로 노출). `_format_integration(integ) -> str` 헬퍼는 merged/already/torn_down/skipped/warnings 를 사람이 읽을 마크다운 블록으로 만든다(20줄 이하).
624
+
625
+ - [ ] **Step 4: 테스트 통과**
626
+
627
+ Run: `python3 -m pytest tests/run/test_final_verification_integrate_call.py -v`
628
+ Expected: PASS
629
+
630
+ - [ ] **Step 5: 회귀 — final-verification 관련 기존 테스트**
631
+
632
+ Run: `python3 -m pytest tests/run/ -k "final or target or stage" -v`
633
+ Expected: PASS (기존 whole-task target 테스트 무회귀)
634
+
635
+ - [ ] **Step 6: 커밋**
636
+
637
+ ```bash
638
+ git add scripts/okstra_ctl/run.py tests/run/test_final_verification_integrate_call.py
639
+ git commit -m "feat(final-verification): whole-task 진입 시 stage 자동 통합+teardown 호출"
640
+ ```
641
+
642
+ ---
643
+
644
+ ## Task 4: 단독 명령 `okstra integrate-stages`
645
+
646
+ **Files:**
647
+ - Modify: `scripts/okstra_ctl/stage_integrate.py` (`main(argv)` 추가)
648
+ - Modify: `scripts/okstra.sh` · `scripts/lib/okstra/cli.sh` · `src/cli-registry.mjs`
649
+ - Test: `tests/run/test_stage_integrate_cli.py` (신규)
650
+
651
+ **Interfaces:**
652
+ - Consumes: `integrate_stages` (Task 2), `task_target.resolve_task_root`(기존 단독 명령들이 task 좌표를 푸는 방식), `consumers.read_consumers`.
653
+ - Produces: `stage_integrate.main(argv: list[str] | None) -> int`.
654
+
655
+ - [ ] **Step 1: 라우팅 패턴 확인 (코드 읽기)**
656
+
657
+ Read: `scripts/okstra_ctl/handoff.py:302-340`(argparse subparser + main 패턴), `src/cli-registry.mjs`(명령 등록), `scripts/lib/okstra/cli.sh`(sub-command 디스패치). `okstra handoff <cmd>` 가 python `-m okstra_ctl.handoff` 로 가는 경로를 그대로 모사한다.
658
+
659
+ - [ ] **Step 2: 실패 테스트 작성**
660
+
661
+ ```python
662
+ import sys
663
+ from pathlib import Path
664
+ from _paths import REPO_ROOT
665
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
666
+
667
+ from okstra_ctl import stage_integrate
668
+
669
+
670
+ def test_main_returns_zero_on_noop(tmp_path, capsys):
671
+ # stage worktree 미발급(non-git task path) → no-op, exit 0
672
+ rc = stage_integrate.main([
673
+ "--project-root", str(tmp_path),
674
+ "--project-id", "p", "--task-group", "g", "--task-id", "T",
675
+ "--plan-run-root", str(tmp_path),
676
+ "--task-worktree-path", str(tmp_path),
677
+ ])
678
+ assert rc == 0
679
+ ```
680
+
681
+ - [ ] **Step 3: 실패 확인**
682
+
683
+ Run: `python3 -m pytest tests/run/test_stage_integrate_cli.py -v`
684
+ Expected: FAIL — `AttributeError: module ... has no attribute 'main'`
685
+
686
+ - [ ] **Step 4: `main(argv)` 구현**
687
+
688
+ `stage_integrate.py` 끝에 추가:
689
+
690
+ ```python
691
+ def main(argv: list[str] | None = None) -> int:
692
+ import argparse
693
+ import json
694
+ from .consumers import read_consumers
695
+
696
+ p = argparse.ArgumentParser(prog="okstra integrate-stages")
697
+ p.add_argument("--project-root", required=True)
698
+ p.add_argument("--project-id", required=True)
699
+ p.add_argument("--task-group", required=True)
700
+ p.add_argument("--task-id", required=True)
701
+ p.add_argument("--plan-run-root", required=True)
702
+ p.add_argument("--task-worktree-path", required=True)
703
+ p.add_argument("--stage-map-json", default="[]",
704
+ help="[{stage_number, depends_on}] JSON")
705
+ a = p.parse_args(argv)
706
+ done_rows = [r for r in read_consumers(Path(a.plan_run_root))
707
+ if r.get("status") == "done"]
708
+ try:
709
+ res = integrate_stages(
710
+ project_id=a.project_id, task_group=a.task_group,
711
+ task_id=a.task_id, task_worktree_path=a.task_worktree_path,
712
+ stage_map=json.loads(a.stage_map_json), done_rows=done_rows,
713
+ )
714
+ except IntegrateError as exc:
715
+ print(f"integrate-stages: {exc}", file=__import__("sys").stderr)
716
+ return 2
717
+ print(f"merged={res.merged} already={res.already_merged} "
718
+ f"torn_down={res.torn_down} skipped={res.teardown_skipped}")
719
+ for w in res.warnings:
720
+ print(f"warning: {w}")
721
+ return 0
722
+
723
+
724
+ if __name__ == "__main__":
725
+ raise SystemExit(main())
726
+ ```
727
+
728
+ - [ ] **Step 5: Bash/Node 라우팅 추가**
729
+
730
+ Step 1 에서 확인한 패턴대로:
731
+ - `scripts/lib/okstra/cli.sh`: `integrate-stages` sub-command → `okstra_py -m okstra_ctl.stage_integrate "$@"`.
732
+ - `src/cli-registry.mjs`: `handoff` 등록 항목과 동일 형태로 `integrate-stages` 항목 추가(설명: "stage 들을 task 브랜치에 통합 머지하고 stage worktree 를 정리").
733
+
734
+ - [ ] **Step 6: 테스트 통과 + CLI 스모크**
735
+
736
+ Run: `python3 -m pytest tests/run/test_stage_integrate_cli.py -v`
737
+ Run: `node bin/okstra --help` (integrate-stages 가 명령 목록에 노출되는지 육안 확인)
738
+ Expected: PASS + 명령 노출
739
+
740
+ - [ ] **Step 7: 커밋**
741
+
742
+ ```bash
743
+ git add scripts/okstra_ctl/stage_integrate.py scripts/lib/okstra/cli.sh scripts/okstra.sh src/cli-registry.mjs tests/run/test_stage_integrate_cli.py
744
+ git commit -m "feat(cli): okstra integrate-stages 단독 명령 추가"
745
+ ```
746
+
747
+ ---
748
+
749
+ ## Task 5: 문서 — 프로파일·spec 상호참조·CHANGES
750
+
751
+ **Files:**
752
+ - Modify: `prompts/profiles/final-verification.md`
753
+ - Modify: `docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md`
754
+ - Modify: `CHANGES.md`
755
+
756
+ - [ ] **Step 1: final-verification 프로파일에 mutating 명시**
757
+
758
+ `prompts/profiles/final-verification.md` 의 whole-task 설명에 한 문단 추가: "whole-task 모드는 진입 시 미머지 stage 를 task 브랜치에 `--no-ff` 자동 머지하고, 정리 가능한 stage worktree·registry 키·stage 브랜치를 제거한다(머지 충돌 시 중단). 따라서 whole-task final-verification 은 read-only 가 아니라 통합 커밋을 생성하는 mutating phase 다."
759
+
760
+ - [ ] **Step 2: 2026-06-06 spec 상호참조**
761
+
762
+ `docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md` 의 §2 자동 머지 비범위 줄과 §9-#2(자동 teardown) 항목에 각각 한 줄 추가: "(2026-06-20 갱신: whole-task 통합 경로에 한해 [2026-06-20-stage-auto-integrate-teardown-design.md] 가 자동 머지+teardown 으로 전환·해소.)"
763
+
764
+ - [ ] **Step 3: CHANGES.md 항목**
765
+
766
+ `CHANGES.md` 최상단에 추가:
767
+
768
+ ```markdown
769
+ ## stage 자동 통합 + worktree 정리
770
+
771
+ - whole-task final-verification 진입 시(및 `okstra integrate-stages` 명령으로) 완료된 stage 들을 task 브랜치에 자동 머지하고, 정리 가능한 stage worktree·브랜치를 제거합니다.
772
+ - 사용자 영향: 더 이상 stage 브랜치를 수동 머지하거나 `git worktree remove` 로 stage 폴더를 직접 정리하지 않아도 whole-task 검증이 통과합니다. 머지 충돌이 있으면 충돌 파일을 보여주고 중단하므로 수동 해소 후 재시도하면 됩니다. 미커밋 변경이 남은 stage worktree 는 보존됩니다.
773
+ ```
774
+
775
+ - [ ] **Step 4: 커밋**
776
+
777
+ ```bash
778
+ git add prompts/profiles/final-verification.md docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md CHANGES.md
779
+ git commit -m "docs(integrate): final-verification mutating 명시 + spec 상호참조 + CHANGES"
780
+ ```
781
+
782
+ ---
783
+
784
+ ## Task 6: 빌드·전체 회귀·정리
785
+
786
+ - [ ] **Step 1: 전체 테스트**
787
+
788
+ Run: `python3 -m pytest tests/`
789
+ Expected: 전부 PASS
790
+
791
+ - [ ] **Step 2: 빌드 동기화 확인**
792
+
793
+ Run: `npm run build`
794
+ Expected: `runtime/` 에 stage_integrate.py 및 수정 파일이 동기화됨(에러 없음)
795
+
796
+ - [ ] **Step 3: 워크플로 검증기**
797
+
798
+ Run: `bash validators/validate-workflow.sh`
799
+ Expected: PASS
800
+
801
+ - [ ] **Step 4: spawn 된 clean-gate 안전망 task 정리 판단**
802
+
803
+ 이 plan 의 Task 2~3 이 머지+teardown 으로 nested 디렉터리를 제거하므로, 앞서 띄운 "clean gate 가 nested stage 디렉터리 제외" task(`task_74a5cabc`)는 dirty-skip 으로 worktree 가 남는 경우의 **fallback** 으로 여전히 유효하다. 중복이 아니므로 유지(별도 진행). 본 plan 범위에서는 손대지 않는다.
804
+
805
+ - [ ] **Step 5: 최종 커밋(필요 시)**
806
+
807
+ 빌드 산출물(`runtime/`)이 gitignore 면 커밋 불필요. 추적 대상이면:
808
+
809
+ ```bash
810
+ git add -A && git commit -m "chore: stage 통합 기능 빌드 동기화"
811
+ ```
812
+
813
+ ---
814
+
815
+ ## Self-Review 결과 (작성자 점검)
816
+
817
+ - **Spec 커버리지**: §3 Phase A/B → Task 2; §2.3 진입 2개 → Task 3(자동)·Task 4(단독); §4 에러표(충돌 abort/dirty skip/branch -d 거부) → Task 2 테스트 I3·dirty·warn; §6 enforcement(merge --abort, branch -d safe, dirty 검사) → Task 2 구현; §7 시나리오 I1~I8 → Task 2/3 테스트 + Task 6 e2e 회귀. I7(코어 동일성)·I8(재진입 통과)은 Task 3·4 가 동일 `integrate_stages` 를 호출함으로 충족.
818
+ - **Placeholder 스캔**: 코드 스텝은 실제 코드 포함. Task 4 Step 1 은 의도된 "코드 읽기" 스텝(라우팅 패턴 확인)으로 placeholder 아님.
819
+ - **타입 일관성**: `integrate_stages` 시그니처·`IntegrateResult` 필드·`get_stage_row`/`release` 인자가 Task 2~4 에서 동일하게 사용됨. `_stage_integrate` 모듈 핸들 이름이 Task 3 전반에서 일관.