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.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/kr/architecture/storage-model.md +273 -0
- package/docs/kr/architecture.md +6 -277
- package/docs/kr/cli.md +1 -1
- package/docs/project-structure-overview.md +26 -22
- package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
- package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
- package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
- package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
- package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
- package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
- package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
- package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +1 -1
- package/runtime/bin/lib/okstra/interactive.sh +30 -4
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
- package/runtime/bin/okstra-render-final-report.py +4 -15
- package/runtime/bin/okstra.sh +5 -2
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +10 -4
- package/runtime/python/okstra_ctl/__init__.py +3 -2
- package/runtime/python/okstra_ctl/backfill.py +12 -21
- package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
- package/runtime/python/okstra_ctl/conformance.py +21 -3
- package/runtime/python/okstra_ctl/consumers.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
- package/runtime/python/okstra_ctl/doctor.py +29 -12
- package/runtime/python/okstra_ctl/error_log_core.py +72 -0
- package/runtime/python/okstra_ctl/error_report.py +8 -58
- package/runtime/python/okstra_ctl/error_zip.py +319 -0
- package/runtime/python/okstra_ctl/fanout.py +5 -2
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
- package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
- package/runtime/python/okstra_ctl/handoff.py +125 -26
- package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
- package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
- package/runtime/python/okstra_ctl/index.py +34 -91
- package/runtime/python/okstra_ctl/jsonl.py +29 -18
- package/runtime/python/okstra_ctl/listing.py +30 -5
- package/runtime/python/okstra_ctl/md_table.py +1 -1
- package/runtime/python/okstra_ctl/migrate.py +17 -7
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/recap.py +12 -3
- package/runtime/python/okstra_ctl/reconcile.py +13 -11
- package/runtime/python/okstra_ctl/render.py +49 -12
- package/runtime/python/okstra_ctl/render_final_report.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +47 -6
- package/runtime/python/okstra_ctl/run.py +128 -100
- package/runtime/python/okstra_ctl/run_index_row.py +118 -0
- package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
- package/runtime/python/okstra_ctl/sequence.py +2 -2
- package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
- package/runtime/python/okstra_ctl/stage_targets.py +2 -8
- package/runtime/python/okstra_ctl/task_target.py +4 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
- package/runtime/python/okstra_ctl/wizard.py +27 -12
- package/runtime/python/okstra_ctl/workflow.py +9 -1
- package/runtime/python/okstra_ctl/worktree.py +93 -4
- package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
- package/runtime/python/okstra_token_usage/collect.py +43 -38
- package/runtime/skills/okstra-brief/SKILL.md +43 -12
- package/runtime/skills/okstra-inspect/SKILL.md +44 -1
- package/runtime/templates/reports/final-report.template.md +2 -2
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/validators/forbidden_actions.py +8 -1
- package/runtime/validators/validate-brief.py +13 -2
- package/runtime/validators/validate-run.py +27 -6
- package/runtime/validators/validate-schedule.py +1 -2
- package/runtime/validators/validate_improvement_report.py +14 -0
- package/src/cli-registry.mjs +45 -31
- package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
- package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
- package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
- package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
- package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
- package/src/commands/execute/integrate-stages.mjs +25 -0
- package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
- package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
- package/src/{run.mjs → commands/execute/run.mjs} +4 -4
- package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
- package/src/{team.mjs → commands/execute/team.mjs} +3 -3
- package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
- package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
- package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
- package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
- package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
- package/src/commands/inspect/error-zip.mjs +25 -0
- package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
- package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
- package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
- package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
- package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
- package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
- package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
- package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
- package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
- package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
- package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
- package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
- package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
- package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
- package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
- package/src/lib/paths.mjs +60 -0
- package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
- package/src/{version.mjs → lib/version.mjs} +2 -2
- /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
- /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
- /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
- /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
- /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 전반에서 일관.
|