okstra 0.67.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/okstra +7 -0
- package/docs/kr/architecture.md +17 -1
- package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
- package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
- package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
- package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
- package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
- package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +1 -1
- package/runtime/python/okstra_ctl/consumers.py +72 -5
- package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
- package/runtime/python/okstra_ctl/handoff.py +348 -0
- package/runtime/python/okstra_ctl/render.py +44 -2
- package/runtime/python/okstra_ctl/run.py +88 -27
- package/runtime/python/okstra_ctl/wizard.py +25 -4
- package/runtime/python/okstra_ctl/worktree.py +10 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +1 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +49 -9
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
# git-reconcile — stale SHA 회복 3단 방어 구현 계획
|
|
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:** okstra 밖의 git 히스토리 변경(rebase / squash merge / 리뷰 amend / branch 삭제) 이후에도 `implementation` run 을 손편집 없이 이어가게 한다 — patch-id 증명 시 자동 화해, 내용 변경 시 확인 보정.
|
|
6
|
+
|
|
7
|
+
**Architecture:** 신규 모듈 `scripts/okstra_ctl/git_reconcile.py` 가 감지(`classify_task`)·내용 동등성(`content_merged`)·보정(`apply_reconcile`)의 단일 reference point. prepare 경로(run.py)와 `okstra git-reconcile` subcommand(node 패스스루)와 okstra-run 스킬 picker 가 전부 이 모듈을 소비한다. 보정은 `consumers.jsonl` 에 done row 재-append(append-only 유지)이며, 선행 조건으로 done-row 읽기를 last-wins 로 통일한다.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Python 3.10+ (stdlib only, 실제 git subprocess), pytest (mock 금지 — 실 git repo fixture), Node ESM 패스스루, bash e2e.
|
|
10
|
+
|
|
11
|
+
**스펙:** [`docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md`](../specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md)
|
|
12
|
+
|
|
13
|
+
**중요 제약 (전 task 공통):**
|
|
14
|
+
- `runtime/` 은 빌드 산출물 — 절대 직접 수정하지 않는다. 소스는 `scripts/`, `src/`, `bin/`, `skills/`.
|
|
15
|
+
- 함수 효과 라인 50줄 초과 금지 — 초과 시 추출.
|
|
16
|
+
- 커밋 메시지는 Conventional Commits, Claude trailer 금지.
|
|
17
|
+
- 테스트는 `python3 -m pytest tests/<file>.py -v` (conftest.py 가 OKSTRA_HOME 을 격리).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
### Task 1: `latest_done_by_stage` helper (last-wins 의 단일 구현)
|
|
22
|
+
|
|
23
|
+
**Files:**
|
|
24
|
+
- Modify: `scripts/okstra_ctl/consumers.py` (171줄, `read_consumers` 아래에 추가)
|
|
25
|
+
- Test: `tests/test_consumers_jsonl.py` (기존 파일에 테스트 추가)
|
|
26
|
+
|
|
27
|
+
- [ ] **Step 1: 실패하는 테스트 작성** — `tests/test_consumers_jsonl.py` 끝에 추가:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
def test_latest_done_by_stage_last_wins(tmp_path):
|
|
31
|
+
from okstra_ctl.consumers import latest_done_by_stage
|
|
32
|
+
rows = [
|
|
33
|
+
{"impl_task_key": "k", "stage": 1, "status": "done", "head_commit": "aaa"},
|
|
34
|
+
{"impl_task_key": "k", "stage": 2, "status": "started", "head_commit": "x"},
|
|
35
|
+
{"impl_task_key": "k", "stage": 1, "status": "done", "head_commit": "bbb",
|
|
36
|
+
"reconciled": True},
|
|
37
|
+
]
|
|
38
|
+
latest = latest_done_by_stage(rows)
|
|
39
|
+
assert latest[1]["head_commit"] == "bbb"
|
|
40
|
+
assert 2 not in latest # started 는 제외
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_latest_done_by_stage_ignores_non_int_stage():
|
|
44
|
+
from okstra_ctl.consumers import latest_done_by_stage
|
|
45
|
+
assert latest_done_by_stage([{"stage": "x", "status": "done"}]) == {}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- [ ] **Step 2: 실패 확인**
|
|
49
|
+
|
|
50
|
+
Run: `python3 -m pytest tests/test_consumers_jsonl.py -v -k latest_done`
|
|
51
|
+
Expected: FAIL — `ImportError: cannot import name 'latest_done_by_stage'`
|
|
52
|
+
|
|
53
|
+
- [ ] **Step 3: 구현** — `consumers.py` 의 `read_consumers` 함수 바로 아래에 추가:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
def latest_done_by_stage(rows: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
|
|
57
|
+
"""stage → 마지막 done row. 보정(reconciled) row 가 같은 stage 에
|
|
58
|
+
재-append 되므로 done 읽기의 유일한 의미는 last-wins 다."""
|
|
59
|
+
out: Dict[int, Dict[str, Any]] = {}
|
|
60
|
+
for r in rows:
|
|
61
|
+
if r.get("status") == "done" and isinstance(r.get("stage"), int):
|
|
62
|
+
out[r["stage"]] = r
|
|
63
|
+
return out
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- [ ] **Step 4: 통과 확인**
|
|
67
|
+
|
|
68
|
+
Run: `python3 -m pytest tests/test_consumers_jsonl.py -v`
|
|
69
|
+
Expected: 전부 PASS
|
|
70
|
+
|
|
71
|
+
- [ ] **Step 5: Commit**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git add scripts/okstra_ctl/consumers.py tests/test_consumers_jsonl.py
|
|
75
|
+
git commit -m "feat(okstra-ctl): consumers done-row last-wins helper 추가"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### Task 2: `append_consumer` 보정 재-append 지원
|
|
81
|
+
|
|
82
|
+
`append_consumer` 는 `(impl_task_key, stage, status)` 동일 시 no-op([consumers.py:42-46](../../../scripts/okstra_ctl/consumers.py:42)) 이라 보정 row 가 막힌다. `force_reappend` 로 우회하되, 같은 `head_commit` 의 중복 보정은 여전히 no-op.
|
|
83
|
+
|
|
84
|
+
**Files:**
|
|
85
|
+
- Modify: `scripts/okstra_ctl/consumers.py:36-54` (`append_consumer`)
|
|
86
|
+
- Test: `tests/test_consumers_jsonl.py`
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 1: 실패하는 테스트 작성**
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
def test_append_consumer_force_reappend(tmp_path):
|
|
92
|
+
from okstra_ctl.consumers import append_consumer, read_consumers
|
|
93
|
+
append_consumer(tmp_path, impl_task_key="k", stage=1, status="done",
|
|
94
|
+
head_commit="aaa")
|
|
95
|
+
# 기본: 동일 (key, stage, status) 는 no-op
|
|
96
|
+
append_consumer(tmp_path, impl_task_key="k", stage=1, status="done",
|
|
97
|
+
head_commit="bbb")
|
|
98
|
+
assert len(read_consumers(tmp_path)) == 1
|
|
99
|
+
# force_reappend: 새 head_commit 이면 append
|
|
100
|
+
append_consumer(tmp_path, impl_task_key="k", stage=1, status="done",
|
|
101
|
+
head_commit="bbb", force_reappend=True, reconciled=True)
|
|
102
|
+
rows = read_consumers(tmp_path)
|
|
103
|
+
assert len(rows) == 2 and rows[-1]["head_commit"] == "bbb"
|
|
104
|
+
# force_reappend 라도 동일 head_commit 중복은 no-op
|
|
105
|
+
append_consumer(tmp_path, impl_task_key="k", stage=1, status="done",
|
|
106
|
+
head_commit="bbb", force_reappend=True)
|
|
107
|
+
assert len(read_consumers(tmp_path)) == 2
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- [ ] **Step 2: 실패 확인**
|
|
111
|
+
|
|
112
|
+
Run: `python3 -m pytest tests/test_consumers_jsonl.py -v -k force_reappend`
|
|
113
|
+
Expected: FAIL — `TypeError: append_consumer() got an unexpected keyword argument 'force_reappend'`
|
|
114
|
+
|
|
115
|
+
- [ ] **Step 3: 구현** — `append_consumer` 시그니처와 idempotency 루프 수정:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
119
|
+
status: str, force_reappend: bool = False,
|
|
120
|
+
**fields: Any) -> None:
|
|
121
|
+
if status not in ("started", "done"):
|
|
122
|
+
raise ValueError(f"status must be 'started' or 'done', got: {status!r}")
|
|
123
|
+
with consumers_mutex(plan_run_root):
|
|
124
|
+
existing = read_consumers(plan_run_root)
|
|
125
|
+
for row in existing:
|
|
126
|
+
if (row.get("impl_task_key") == impl_task_key
|
|
127
|
+
and row.get("stage") == stage
|
|
128
|
+
and row.get("status") == status):
|
|
129
|
+
if not force_reappend:
|
|
130
|
+
return # idempotent
|
|
131
|
+
if row.get("head_commit") == fields.get("head_commit"):
|
|
132
|
+
return # 동일 보정의 중복 재-append 방지
|
|
133
|
+
record: Dict[str, Any] = {
|
|
134
|
+
"impl_task_key": impl_task_key,
|
|
135
|
+
"stage": stage,
|
|
136
|
+
"status": status,
|
|
137
|
+
**fields,
|
|
138
|
+
}
|
|
139
|
+
with _path(plan_run_root).open("a", encoding="utf-8") as f:
|
|
140
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
모듈 docstring(1-5행)의 identity 설명에 한 줄 추가: `force_reappend=True 인 보정 append 만 같은 tuple 을 다른 head_commit 으로 재기록할 수 있다.`
|
|
144
|
+
|
|
145
|
+
- [ ] **Step 4: 통과 확인**
|
|
146
|
+
|
|
147
|
+
Run: `python3 -m pytest tests/test_consumers_jsonl.py tests/test_consumers_carry_backfill.py -v`
|
|
148
|
+
Expected: 전부 PASS (backfill 경로 회귀 없음)
|
|
149
|
+
|
|
150
|
+
- [ ] **Step 5: Commit**
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
git add scripts/okstra_ctl/consumers.py tests/test_consumers_jsonl.py
|
|
154
|
+
git commit -m "feat(okstra-ctl): append_consumer 에 보정 재-append(force_reappend) 지원"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### Task 3: run.py done-row 읽기를 last-wins 로 통일
|
|
160
|
+
|
|
161
|
+
첫 행 우선인 [run.py:489](../../../scripts/okstra_ctl/run.py:489)(다중 의존)·[run.py:527](../../../scripts/okstra_ctl/run.py:527)(단일 의존)과 마지막 행 우선인 [run.py:556](../../../scripts/okstra_ctl/run.py:556)·[run.py:1488](../../../scripts/okstra_ctl/run.py:1488)을 전부 Task 1 helper 로 수렴.
|
|
162
|
+
|
|
163
|
+
**Files:**
|
|
164
|
+
- Modify: `scripts/okstra_ctl/run.py:482-536` (`_resolve_stage_base_commit`), `run.py:556` (`_resolve_whole_task_target`), `run.py:1488` (merged dict)
|
|
165
|
+
- Test: `tests/test_okstra_run_stage_base.py`
|
|
166
|
+
|
|
167
|
+
- [ ] **Step 1: 실패하는 테스트 작성** — `tests/test_okstra_run_stage_base.py` 에 추가 (기존 테스트의 호출 형태를 따른다):
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
def test_single_dep_uses_latest_done_row():
|
|
171
|
+
from okstra_ctl.run import _resolve_stage_base_commit
|
|
172
|
+
stage = {"stage_number": 2, "depends_on": [1]}
|
|
173
|
+
rows = [
|
|
174
|
+
{"stage": 1, "status": "done", "head_commit": "old111"},
|
|
175
|
+
{"stage": 1, "status": "done", "head_commit": "new222",
|
|
176
|
+
"reconciled": True},
|
|
177
|
+
]
|
|
178
|
+
assert _resolve_stage_base_commit(stage, rows, anchor_base_commit="") == "new222"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- [ ] **Step 2: 실패 확인**
|
|
182
|
+
|
|
183
|
+
Run: `python3 -m pytest tests/test_okstra_run_stage_base.py -v -k latest_done_row`
|
|
184
|
+
Expected: FAIL — 반환값이 `old111` (첫 행 우선)
|
|
185
|
+
|
|
186
|
+
- [ ] **Step 3: 구현** — `_resolve_stage_base_commit` 본문에서 행 스캔 2곳을 교체:
|
|
187
|
+
|
|
188
|
+
함수 도입부에 (docstring 직후):
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from .consumers import latest_done_by_stage
|
|
192
|
+
latest = latest_done_by_stage(consumer_done_rows)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
다중 의존의 `head = next((r.get("head_commit") for r in consumer_done_rows ...), None)` ([run.py:488-493](../../../scripts/okstra_ctl/run.py:488)) 을:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
head = (latest.get(d) or {}).get("head_commit")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
단일 의존의 `for row in consumer_done_rows:` 루프 ([run.py:527-531](../../../scripts/okstra_ctl/run.py:527)) 를:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
pred = deps[0]
|
|
205
|
+
head = (latest.get(pred) or {}).get("head_commit") or ""
|
|
206
|
+
if head:
|
|
207
|
+
return head
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`_resolve_whole_task_target` 의 `done_by_stage = {r["stage"]: r for r in done_rows}` ([run.py:556](../../../scripts/okstra_ctl/run.py:556)) 을:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from .consumers import latest_done_by_stage
|
|
214
|
+
done_by_stage = latest_done_by_stage(done_rows)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`_reserve_final_verification_target` 의 merged dict ([run.py:1488](../../../scripts/okstra_ctl/run.py:1488)) 를:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from .consumers import latest_done_by_stage
|
|
221
|
+
merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
|
|
222
|
+
for s, r in latest_done_by_stage(done_rows).items()}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- [ ] **Step 4: 통과 확인**
|
|
226
|
+
|
|
227
|
+
Run: `python3 -m pytest tests/test_okstra_run_stage_base.py tests/test_e2e_impl_stage_artifact_isolation.py -v`
|
|
228
|
+
Expected: 전부 PASS
|
|
229
|
+
|
|
230
|
+
- [ ] **Step 5: Commit**
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
git add scripts/okstra_ctl/run.py tests/test_okstra_run_stage_base.py
|
|
234
|
+
git commit -m "fix(okstra-ctl): done-row 읽기를 latest_done_by_stage 로 last-wins 통일"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
### Task 4: `git_reconcile.content_merged` — patch-id 내용 동등성 검사기
|
|
240
|
+
|
|
241
|
+
**Files:**
|
|
242
|
+
- Create: `scripts/okstra_ctl/git_reconcile.py`
|
|
243
|
+
- Test: `tests/test_okstra_git_reconcile_content.py` (신규)
|
|
244
|
+
|
|
245
|
+
- [ ] **Step 1: 실패하는 테스트 작성** — 실 git repo fixture (mock 금지):
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
"""content_merged 의 ancestor / patch-equivalent / not-merged 판정 검증.
|
|
249
|
+
실제 git 으로 rebase·squash·cherry-pick·amend 히스토리를 만들어 검사한다."""
|
|
250
|
+
import subprocess
|
|
251
|
+
from pathlib import Path
|
|
252
|
+
|
|
253
|
+
import pytest
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _git(repo: Path, *args: str) -> str:
|
|
257
|
+
r = subprocess.run(["git", "-C", str(repo), *args],
|
|
258
|
+
capture_output=True, text=True, check=True)
|
|
259
|
+
return r.stdout.strip()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.fixture()
|
|
263
|
+
def repo(tmp_path: Path) -> Path:
|
|
264
|
+
r = tmp_path / "repo"
|
|
265
|
+
r.mkdir()
|
|
266
|
+
_git(r, "init", "-q", "-b", "main")
|
|
267
|
+
_git(r, "config", "user.email", "t@e")
|
|
268
|
+
_git(r, "config", "user.name", "t")
|
|
269
|
+
(r / "base.txt").write_text("base\n")
|
|
270
|
+
_git(r, "add", "."); _git(r, "commit", "-q", "-m", "base")
|
|
271
|
+
return r
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _commit_file(repo: Path, name: str, content: str, msg: str) -> str:
|
|
275
|
+
(repo / name).write_text(content)
|
|
276
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", msg)
|
|
277
|
+
return _git(repo, "rev-parse", "HEAD")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_ancestor_passes(repo):
|
|
281
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
282
|
+
base = _git(repo, "rev-parse", "HEAD")
|
|
283
|
+
done = _commit_file(repo, "a.txt", "a\n", "stage work")
|
|
284
|
+
assert content_merged(repo, done, "main", base=base).status == "ancestor"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_squash_merge_is_patch_equivalent(repo):
|
|
288
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
289
|
+
base = _git(repo, "rev-parse", "HEAD")
|
|
290
|
+
_git(repo, "checkout", "-q", "-b", "stage")
|
|
291
|
+
_commit_file(repo, "a.txt", "a\n", "s1")
|
|
292
|
+
done = _commit_file(repo, "b.txt", "b\n", "s2")
|
|
293
|
+
_git(repo, "checkout", "-q", "main")
|
|
294
|
+
_git(repo, "merge", "--squash", "-q", "stage")
|
|
295
|
+
_git(repo, "commit", "-q", "-m", "squash stage")
|
|
296
|
+
squash_sha = _git(repo, "rev-parse", "HEAD")
|
|
297
|
+
_git(repo, "branch", "-q", "-D", "stage")
|
|
298
|
+
res = content_merged(repo, done, "main", base=base)
|
|
299
|
+
assert res.status == "patch-equivalent"
|
|
300
|
+
assert res.matched_commit == squash_sha
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_rebase_is_patch_equivalent(repo):
|
|
304
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
305
|
+
base = _git(repo, "rev-parse", "HEAD")
|
|
306
|
+
_git(repo, "checkout", "-q", "-b", "stage")
|
|
307
|
+
_commit_file(repo, "a.txt", "a\n", "s1")
|
|
308
|
+
done = _commit_file(repo, "b.txt", "b\n", "s2")
|
|
309
|
+
_git(repo, "checkout", "-q", "main")
|
|
310
|
+
_commit_file(repo, "main.txt", "m\n", "main moves")
|
|
311
|
+
_git(repo, "rebase", "-q", "main", "stage")
|
|
312
|
+
rebased_tip = _git(repo, "rev-parse", "stage")
|
|
313
|
+
res = content_merged(repo, done, "stage", base=base)
|
|
314
|
+
assert res.status == "patch-equivalent"
|
|
315
|
+
assert res.matched_commit == rebased_tip
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_amended_content_is_not_merged(repo):
|
|
319
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
320
|
+
base = _git(repo, "rev-parse", "HEAD")
|
|
321
|
+
_git(repo, "checkout", "-q", "-b", "stage")
|
|
322
|
+
done = _commit_file(repo, "a.txt", "a\n", "s1")
|
|
323
|
+
(repo / "a.txt").write_text("a-reviewed\n") # 리뷰 반영 = 내용 변경
|
|
324
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "--amend", "-m", "s1'")
|
|
325
|
+
res = content_merged(repo, done, "stage", base=base)
|
|
326
|
+
assert res.status == "not-merged"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_partial_cherry_pick_is_not_merged(repo):
|
|
330
|
+
"""tip 만 cherry-pick 되고 선행 커밋이 빠지면 머지로 인정하지 않는다."""
|
|
331
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
332
|
+
base = _git(repo, "rev-parse", "HEAD")
|
|
333
|
+
_git(repo, "checkout", "-q", "-b", "stage")
|
|
334
|
+
_commit_file(repo, "a.txt", "a\n", "s1")
|
|
335
|
+
done = _commit_file(repo, "b.txt", "b\n", "s2")
|
|
336
|
+
_git(repo, "checkout", "-q", "main")
|
|
337
|
+
_git(repo, "cherry-pick", "-q", done) # s2 만
|
|
338
|
+
assert content_merged(repo, done, "main", base=base).status == "not-merged"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_unresolvable_commit(repo):
|
|
342
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
343
|
+
assert content_merged(repo, "0" * 40, "main").status == "unresolvable"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- [ ] **Step 2: 실패 확인**
|
|
347
|
+
|
|
348
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_content.py -v`
|
|
349
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'okstra_ctl.git_reconcile'`
|
|
350
|
+
|
|
351
|
+
- [ ] **Step 3: 구현** — `scripts/okstra_ctl/git_reconcile.py` 신규:
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
"""Stale git SHA 감지·화해·보정 (3단 방어)의 단일 reference point.
|
|
355
|
+
|
|
356
|
+
저장된 SHA(anchor / done.head_commit)가 okstra 밖의 히스토리 재작성으로
|
|
357
|
+
stale 해졌을 때 — patch-id 로 내용 동일성이 증명되면 자동 화해(auto),
|
|
358
|
+
내용이 바뀌었으면 사용자 확인 보정(confirm). prepare 경로(run.py)와
|
|
359
|
+
`okstra git-reconcile` subcommand 와 okstra-run 스킬이 모두 이 모듈을
|
|
360
|
+
소비한다. 설계:
|
|
361
|
+
docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md
|
|
362
|
+
"""
|
|
363
|
+
from __future__ import annotations
|
|
364
|
+
|
|
365
|
+
import subprocess
|
|
366
|
+
from dataclasses import dataclass
|
|
367
|
+
from pathlib import Path
|
|
368
|
+
from typing import Dict, List, Optional, Tuple
|
|
369
|
+
|
|
370
|
+
from .worktree import _resolve_commit_sha
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
|
374
|
+
return subprocess.run(["git", "-C", str(cwd), *args],
|
|
375
|
+
capture_output=True, text=True)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass
|
|
379
|
+
class MatchResult:
|
|
380
|
+
status: str # "ancestor" | "patch-equivalent" | "not-merged" | "unresolvable"
|
|
381
|
+
matched_commit: str = ""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _patch_ids_of_range(cwd: Path, base: str, head: str) -> List[Tuple[str, str]]:
|
|
385
|
+
"""base..head 각 커밋의 (patch-id, sha). diff 없는 커밋은 제외된다."""
|
|
386
|
+
log = _git(cwd, "log", "-p", "--no-merges", f"{base}..{head}")
|
|
387
|
+
if log.returncode != 0:
|
|
388
|
+
return []
|
|
389
|
+
pid = subprocess.run(["git", "-C", str(cwd), "patch-id", "--stable"],
|
|
390
|
+
input=log.stdout, capture_output=True, text=True)
|
|
391
|
+
out = []
|
|
392
|
+
for line in pid.stdout.splitlines():
|
|
393
|
+
parts = line.split()
|
|
394
|
+
if len(parts) == 2:
|
|
395
|
+
out.append((parts[0], parts[1]))
|
|
396
|
+
return out
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _patch_id_of_diff(cwd: Path, base: str, head: str) -> str:
|
|
400
|
+
"""base→head 전체를 한 diff 로 본 patch-id (squash 동등성용)."""
|
|
401
|
+
diff = _git(cwd, "diff", base, head)
|
|
402
|
+
if diff.returncode != 0 or not diff.stdout.strip():
|
|
403
|
+
return ""
|
|
404
|
+
pid = subprocess.run(["git", "-C", str(cwd), "patch-id", "--stable"],
|
|
405
|
+
input=diff.stdout + "\n", capture_output=True, text=True)
|
|
406
|
+
parts = pid.stdout.split()
|
|
407
|
+
return parts[0] if parts else ""
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def content_merged(project_root: Path, commit: str, candidate: str,
|
|
411
|
+
base: str = "") -> MatchResult:
|
|
412
|
+
"""commit 의 내용이 candidate 히스토리에 포함되는가.
|
|
413
|
+
|
|
414
|
+
1) ancestor 면 그대로 통과. 2) patch-id fallback 두 granularity:
|
|
415
|
+
커밋 단위(rebase/cherry-pick — base..commit 의 *모든* 커밋이 매칭돼야 함)
|
|
416
|
+
+ 범위 합산(squash — base..commit 전체 diff 가 한 커밋과 매칭).
|
|
417
|
+
증명 실패는 not-merged — 자동 진행 금지는 호출자 계약이다."""
|
|
418
|
+
resolved = _resolve_commit_sha(project_root, commit)
|
|
419
|
+
cand = _resolve_commit_sha(project_root, candidate)
|
|
420
|
+
if not resolved or not cand:
|
|
421
|
+
return MatchResult("unresolvable")
|
|
422
|
+
if _git(project_root, "merge-base", "--is-ancestor", resolved, cand).returncode == 0:
|
|
423
|
+
return MatchResult("ancestor", matched_commit=resolved)
|
|
424
|
+
mb = _git(project_root, "merge-base", resolved, cand).stdout.strip()
|
|
425
|
+
if not mb:
|
|
426
|
+
return MatchResult("not-merged")
|
|
427
|
+
range_base = _resolve_commit_sha(project_root, base) or mb
|
|
428
|
+
cand_ids: Dict[str, str] = dict(_patch_ids_of_range(project_root, mb, cand))
|
|
429
|
+
stage_ids = _patch_ids_of_range(project_root, range_base, resolved)
|
|
430
|
+
if stage_ids and all(pid in cand_ids for pid, _ in stage_ids):
|
|
431
|
+
# git log 는 최신 커밋부터 출력 → stage_ids[0] 이 tip. matched_commit
|
|
432
|
+
# 은 재기록 대상이므로 tip 의 매칭 SHA 여야 한다.
|
|
433
|
+
return MatchResult("patch-equivalent",
|
|
434
|
+
matched_commit=cand_ids[stage_ids[0][0]])
|
|
435
|
+
whole = _patch_id_of_diff(project_root, range_base, resolved)
|
|
436
|
+
if whole and whole in cand_ids:
|
|
437
|
+
return MatchResult("patch-equivalent", matched_commit=cand_ids[whole])
|
|
438
|
+
return MatchResult("not-merged")
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
테스트 `test_rebase_is_patch_equivalent` 가 tip 매칭(`stage_ids[0]`)을 박제한다.
|
|
442
|
+
|
|
443
|
+
- [ ] **Step 4: 통과 확인**
|
|
444
|
+
|
|
445
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_content.py -v`
|
|
446
|
+
Expected: 6 PASS. `test_rebase_is_patch_equivalent` 가 실패하면 위 주의사항(stage_ids[0])을 확인.
|
|
447
|
+
|
|
448
|
+
- [ ] **Step 5: Commit**
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
git add scripts/okstra_ctl/git_reconcile.py tests/test_okstra_git_reconcile_content.py
|
|
452
|
+
git commit -m "feat(okstra-ctl): patch-id 기반 content_merged 검사기 추가"
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
### Task 5: `classify_task` — stale 분류기
|
|
458
|
+
|
|
459
|
+
**Files:**
|
|
460
|
+
- Modify: `scripts/okstra_ctl/git_reconcile.py`
|
|
461
|
+
- Test: `tests/test_okstra_git_reconcile_classify.py` (신규)
|
|
462
|
+
|
|
463
|
+
- [ ] **Step 1: 실패하는 테스트 작성** — registry/consumers 를 실제로 시딩 (conftest 가 OKSTRA_HOME 격리):
|
|
464
|
+
|
|
465
|
+
```python
|
|
466
|
+
"""classify_task 의 ok / auto / confirm 분류 검증 (실 git + 실 registry)."""
|
|
467
|
+
import subprocess
|
|
468
|
+
from pathlib import Path
|
|
469
|
+
|
|
470
|
+
import pytest
|
|
471
|
+
|
|
472
|
+
from okstra_ctl import worktree_registry as reg
|
|
473
|
+
from okstra_ctl.consumers import append_consumer
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _git(repo: Path, *args: str) -> str:
|
|
477
|
+
r = subprocess.run(["git", "-C", str(repo), *args],
|
|
478
|
+
capture_output=True, text=True, check=True)
|
|
479
|
+
return r.stdout.strip()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@pytest.fixture()
|
|
483
|
+
def env(tmp_path: Path):
|
|
484
|
+
repo = tmp_path / "repo"; repo.mkdir()
|
|
485
|
+
_git(repo, "init", "-q", "-b", "main")
|
|
486
|
+
_git(repo, "config", "user.email", "t@e")
|
|
487
|
+
_git(repo, "config", "user.name", "t")
|
|
488
|
+
(repo / "base.txt").write_text("base\n")
|
|
489
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "base")
|
|
490
|
+
plan_root = tmp_path / "runs" / "implementation-planning"
|
|
491
|
+
plan_root.mkdir(parents=True)
|
|
492
|
+
reg.reserve(project_id="proj", task_group="grp", task_id="task",
|
|
493
|
+
worktree_path=str(repo), branch="fix-task", base_ref="main")
|
|
494
|
+
return repo, plan_root
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _done(plan_root, stage, sha):
|
|
498
|
+
append_consumer(plan_root, impl_task_key="proj/grp/task", stage=stage,
|
|
499
|
+
status="done", head_commit=sha)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_intact_done_row_is_ok(env):
|
|
503
|
+
from okstra_ctl.git_reconcile import classify_task
|
|
504
|
+
repo, plan_root = env
|
|
505
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
506
|
+
(repo / "a.txt").write_text("a\n")
|
|
507
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
508
|
+
_done(plan_root, 1, _git(repo, "rev-parse", "HEAD"))
|
|
509
|
+
items = classify_task(project_root=repo, plan_run_root=plan_root,
|
|
510
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
511
|
+
work_category="fix")
|
|
512
|
+
assert [i.classification for i in items if i.kind == "done"] == ["ok"]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_rebased_branch_is_auto(env):
|
|
516
|
+
from okstra_ctl.git_reconcile import classify_task
|
|
517
|
+
repo, plan_root = env
|
|
518
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
519
|
+
(repo / "a.txt").write_text("a\n")
|
|
520
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
521
|
+
done = _git(repo, "rev-parse", "HEAD")
|
|
522
|
+
_done(plan_root, 1, done)
|
|
523
|
+
_git(repo, "checkout", "-q", "main")
|
|
524
|
+
(repo / "m.txt").write_text("m\n")
|
|
525
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "main moves")
|
|
526
|
+
_git(repo, "rebase", "-q", "main", "fix-task-s1")
|
|
527
|
+
tip = _git(repo, "rev-parse", "fix-task-s1")
|
|
528
|
+
items = classify_task(project_root=repo, plan_run_root=plan_root,
|
|
529
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
530
|
+
work_category="fix")
|
|
531
|
+
item = next(i for i in items if i.kind == "done" and i.stage == 1)
|
|
532
|
+
assert item.classification == "auto"
|
|
533
|
+
assert item.suggested_commit == tip
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_amended_branch_is_confirm(env):
|
|
537
|
+
from okstra_ctl.git_reconcile import classify_task
|
|
538
|
+
repo, plan_root = env
|
|
539
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
540
|
+
(repo / "a.txt").write_text("a\n")
|
|
541
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
542
|
+
_done(plan_root, 1, _git(repo, "rev-parse", "HEAD"))
|
|
543
|
+
(repo / "a.txt").write_text("a-reviewed\n")
|
|
544
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "--amend", "-m", "s1'")
|
|
545
|
+
items = classify_task(project_root=repo, plan_run_root=plan_root,
|
|
546
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
547
|
+
work_category="fix")
|
|
548
|
+
item = next(i for i in items if i.kind == "done" and i.stage == 1)
|
|
549
|
+
assert item.classification == "confirm"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def test_unresolvable_anchor_is_confirm(env):
|
|
553
|
+
from okstra_ctl.git_reconcile import classify_task
|
|
554
|
+
repo, plan_root = env
|
|
555
|
+
reg.set_implementation_base("proj", "grp", "task", "0" * 40)
|
|
556
|
+
items = classify_task(project_root=repo, plan_run_root=plan_root,
|
|
557
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
558
|
+
work_category="fix")
|
|
559
|
+
anchor = next(i for i in items if i.kind == "anchor")
|
|
560
|
+
assert anchor.classification == "confirm"
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
- [ ] **Step 2: 실패 확인**
|
|
564
|
+
|
|
565
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_classify.py -v`
|
|
566
|
+
Expected: FAIL — `ImportError: cannot import name 'classify_task'`
|
|
567
|
+
|
|
568
|
+
- [ ] **Step 3: 구현** — `git_reconcile.py` 에 추가:
|
|
569
|
+
|
|
570
|
+
```python
|
|
571
|
+
@dataclass
|
|
572
|
+
class StaleItem:
|
|
573
|
+
kind: str # "anchor" | "done"
|
|
574
|
+
stage: Optional[int]
|
|
575
|
+
recorded: str
|
|
576
|
+
classification: str # "ok" | "auto" | "confirm"
|
|
577
|
+
reason: str
|
|
578
|
+
suggested_commit: str = "" # auto 일 때 재기록 대상
|
|
579
|
+
impl_task_key: str = ""
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _classify_done_row(project_root: Path, stage: int, row: dict,
|
|
583
|
+
branch: str, stage_base: str) -> StaleItem:
|
|
584
|
+
recorded = row.get("head_commit", "")
|
|
585
|
+
item = StaleItem(kind="done", stage=stage, recorded=recorded,
|
|
586
|
+
classification="ok", reason="",
|
|
587
|
+
impl_task_key=row.get("impl_task_key", ""))
|
|
588
|
+
if not _resolve_commit_sha(project_root, recorded):
|
|
589
|
+
item.classification, item.reason = "confirm", "recorded SHA unresolvable"
|
|
590
|
+
return item
|
|
591
|
+
tip = _resolve_commit_sha(project_root, branch)
|
|
592
|
+
if not tip or tip == recorded:
|
|
593
|
+
return item # branch 없음(히스토리 intact) 또는 일치
|
|
594
|
+
if _git(project_root, "merge-base", "--is-ancestor",
|
|
595
|
+
recorded, tip).returncode == 0:
|
|
596
|
+
return item # 커밋이 단순히 더 쌓임 — stale 아님
|
|
597
|
+
match = content_merged(project_root, recorded, tip, base=stage_base)
|
|
598
|
+
if match.status in ("ancestor", "patch-equivalent"):
|
|
599
|
+
item.classification = "auto"
|
|
600
|
+
item.reason = f"branch {branch} rewritten, patch-equivalent"
|
|
601
|
+
item.suggested_commit = tip
|
|
602
|
+
else:
|
|
603
|
+
item.classification = "confirm"
|
|
604
|
+
item.reason = f"branch {branch} rewritten with content changes"
|
|
605
|
+
return item
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def classify_task(*, project_root: Path, plan_run_root: Path,
|
|
609
|
+
project_id: str, task_group: str, task_id: str,
|
|
610
|
+
work_category: str) -> List[StaleItem]:
|
|
611
|
+
"""task 의 anchor + 최신 done row 들을 ok/auto/confirm 으로 분류한다.
|
|
612
|
+
다중 의존 gate(spec §3.2 표 5-6행)는 candidate 가 필요한 prepare 경로에서
|
|
613
|
+
content_merged 로 직접 평가된다 — 여기서 중복 평가하지 않는다."""
|
|
614
|
+
from . import worktree_registry as _reg
|
|
615
|
+
from .consumers import read_consumers, latest_done_by_stage
|
|
616
|
+
from .worktree import compute_branch_name
|
|
617
|
+
|
|
618
|
+
items: List[StaleItem] = []
|
|
619
|
+
anchor = _reg.get_implementation_base(project_id, task_group, task_id) or ""
|
|
620
|
+
if anchor:
|
|
621
|
+
ok = bool(_resolve_commit_sha(project_root, anchor))
|
|
622
|
+
items.append(StaleItem(
|
|
623
|
+
kind="anchor", stage=None, recorded=anchor,
|
|
624
|
+
classification="ok" if ok else "confirm",
|
|
625
|
+
reason="" if ok else "anchor SHA unresolvable",
|
|
626
|
+
))
|
|
627
|
+
latest = latest_done_by_stage(read_consumers(plan_run_root))
|
|
628
|
+
for stage in sorted(latest):
|
|
629
|
+
row = latest[stage]
|
|
630
|
+
branch = compute_branch_name(
|
|
631
|
+
work_category=work_category, task_id_segment=task_id,
|
|
632
|
+
stage_number=stage,
|
|
633
|
+
)
|
|
634
|
+
srow = _reg.get_stage_row(project_id, task_group, task_id, stage) or {}
|
|
635
|
+
items.append(_classify_done_row(
|
|
636
|
+
project_root, stage, row, branch, srow.get("base_ref", "")))
|
|
637
|
+
return items
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
- [ ] **Step 4: 통과 확인**
|
|
641
|
+
|
|
642
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_classify.py -v`
|
|
643
|
+
Expected: 4 PASS
|
|
644
|
+
|
|
645
|
+
- [ ] **Step 5: Commit**
|
|
646
|
+
|
|
647
|
+
```bash
|
|
648
|
+
git add scripts/okstra_ctl/git_reconcile.py tests/test_okstra_git_reconcile_classify.py
|
|
649
|
+
git commit -m "feat(okstra-ctl): stale SHA 분류기 classify_task 추가"
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
---
|
|
653
|
+
|
|
654
|
+
### Task 6: `apply_reconcile` + `reset_implementation_base` + enforcement 가드
|
|
655
|
+
|
|
656
|
+
**Files:**
|
|
657
|
+
- Modify: `scripts/okstra_ctl/git_reconcile.py`, `scripts/okstra_ctl/worktree_registry.py:218` 아래
|
|
658
|
+
- Test: `tests/test_okstra_git_reconcile_apply.py` (신규), `tests/test_okstra_worktree_registry.py`
|
|
659
|
+
|
|
660
|
+
- [ ] **Step 1: 실패하는 테스트 작성** — `tests/test_okstra_git_reconcile_apply.py` (Task 5 의 `env`/`_git`/`_done` fixture 를 복사해 동일하게 사용):
|
|
661
|
+
|
|
662
|
+
```python
|
|
663
|
+
def test_auto_items_are_recorded(env):
|
|
664
|
+
"""rebase 된 stage(auto)는 --use-ref 없이 보정되고 reconciled 필드가 남는다."""
|
|
665
|
+
from okstra_ctl.git_reconcile import apply_reconcile
|
|
666
|
+
from okstra_ctl.consumers import read_consumers, latest_done_by_stage
|
|
667
|
+
repo, plan_root = env
|
|
668
|
+
# Task 5 test_rebased_branch_is_auto 와 동일한 rebase 히스토리 구성
|
|
669
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
670
|
+
(repo / "a.txt").write_text("a\n")
|
|
671
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
672
|
+
_done(plan_root, 1, _git(repo, "rev-parse", "HEAD"))
|
|
673
|
+
_git(repo, "checkout", "-q", "main")
|
|
674
|
+
(repo / "m.txt").write_text("m\n")
|
|
675
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "main moves")
|
|
676
|
+
_git(repo, "rebase", "-q", "main", "fix-task-s1")
|
|
677
|
+
tip = _git(repo, "rev-parse", "fix-task-s1")
|
|
678
|
+
|
|
679
|
+
result = apply_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
680
|
+
project_id="proj", task_group="grp",
|
|
681
|
+
task_id="task", work_category="fix")
|
|
682
|
+
assert result["applied"] and not result["remaining_confirm"]
|
|
683
|
+
row = latest_done_by_stage(read_consumers(plan_root))[1]
|
|
684
|
+
assert row["head_commit"] == tip
|
|
685
|
+
assert row["reconciled"] is True
|
|
686
|
+
assert row["reconcile_reason"] == "auto-patch-id"
|
|
687
|
+
assert row["replaced_commit"]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def test_confirm_item_never_auto_applied(env):
|
|
691
|
+
"""enforcement: confirm(내용 변경) 항목은 use_ref 없이 절대 보정되지 않는다."""
|
|
692
|
+
from okstra_ctl.git_reconcile import apply_reconcile
|
|
693
|
+
from okstra_ctl.consumers import read_consumers
|
|
694
|
+
repo, plan_root = env
|
|
695
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
696
|
+
(repo / "a.txt").write_text("a\n")
|
|
697
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
698
|
+
_done(plan_root, 1, _git(repo, "rev-parse", "HEAD"))
|
|
699
|
+
(repo / "a.txt").write_text("a-reviewed\n")
|
|
700
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "--amend", "-m", "s1'")
|
|
701
|
+
|
|
702
|
+
result = apply_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
703
|
+
project_id="proj", task_group="grp",
|
|
704
|
+
task_id="task", work_category="fix")
|
|
705
|
+
assert not result["applied"]
|
|
706
|
+
assert [i.stage for i in result["remaining_confirm"]] == [1]
|
|
707
|
+
assert len(read_consumers(plan_root)) == 1 # 원본 row 만
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def test_use_ref_resolves_and_records(env):
|
|
711
|
+
from okstra_ctl.git_reconcile import apply_reconcile
|
|
712
|
+
from okstra_ctl.consumers import read_consumers
|
|
713
|
+
repo, plan_root = env
|
|
714
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
715
|
+
(repo / "a.txt").write_text("a\n")
|
|
716
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
717
|
+
_done(plan_root, 1, "0" * 40) # GC 로 소멸한 SHA 가정
|
|
718
|
+
result = apply_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
719
|
+
project_id="proj", task_group="grp",
|
|
720
|
+
task_id="task", work_category="fix",
|
|
721
|
+
stage=1, use_ref="fix-task-s1")
|
|
722
|
+
assert result["applied"]
|
|
723
|
+
row = read_consumers(plan_root)[-1]
|
|
724
|
+
assert row["head_commit"] == _git(repo, "rev-parse", "fix-task-s1")
|
|
725
|
+
assert row["reconcile_reason"] == "user-ref"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def test_use_ref_requires_stage_and_resolvable(env):
|
|
729
|
+
from okstra_ctl.git_reconcile import apply_reconcile, ReconcileError
|
|
730
|
+
repo, plan_root = env
|
|
731
|
+
import pytest as _pt
|
|
732
|
+
with _pt.raises(ReconcileError):
|
|
733
|
+
apply_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
734
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
735
|
+
work_category="fix", use_ref="fix-task-s1") # stage 없음
|
|
736
|
+
with _pt.raises(ReconcileError):
|
|
737
|
+
apply_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
738
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
739
|
+
work_category="fix", stage=1, use_ref="no-such-ref")
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def test_reset_anchor(env):
|
|
743
|
+
from okstra_ctl.git_reconcile import apply_reconcile
|
|
744
|
+
from okstra_ctl import worktree_registry as reg
|
|
745
|
+
repo, plan_root = env
|
|
746
|
+
reg.set_implementation_base("proj", "grp", "task", "0" * 40)
|
|
747
|
+
apply_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
748
|
+
project_id="proj", task_group="grp", task_id="task",
|
|
749
|
+
work_category="fix", reset_anchor="main")
|
|
750
|
+
assert reg.get_implementation_base("proj", "grp", "task") == \
|
|
751
|
+
_git(repo, "rev-parse", "main")
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
`tests/test_okstra_worktree_registry.py` 에 추가:
|
|
755
|
+
|
|
756
|
+
```python
|
|
757
|
+
def test_reset_implementation_base_overrides():
|
|
758
|
+
from okstra_ctl import worktree_registry as reg
|
|
759
|
+
reg.reserve(project_id="p", task_group="g", task_id="t",
|
|
760
|
+
worktree_path="/wt/p/g/t", branch="feat-t", base_ref="main")
|
|
761
|
+
reg.set_implementation_base("p", "g", "t", "aaa")
|
|
762
|
+
assert reg.set_implementation_base("p", "g", "t", "bbb") == "aaa" # 기존 불변
|
|
763
|
+
assert reg.reset_implementation_base("p", "g", "t", "bbb") == "bbb"
|
|
764
|
+
assert reg.get_implementation_base("p", "g", "t") == "bbb"
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
- [ ] **Step 2: 실패 확인**
|
|
768
|
+
|
|
769
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_apply.py tests/test_okstra_worktree_registry.py -v`
|
|
770
|
+
Expected: FAIL — `ImportError` (apply_reconcile / reset_implementation_base 부재)
|
|
771
|
+
|
|
772
|
+
- [ ] **Step 3: 구현** — `worktree_registry.py` 의 `set_implementation_base` 아래에:
|
|
773
|
+
|
|
774
|
+
```python
|
|
775
|
+
def reset_implementation_base(
|
|
776
|
+
project_id: str, task_group: str, task_id: str, commit: str,
|
|
777
|
+
) -> str:
|
|
778
|
+
"""anchor 를 의식적으로 재고정한다. 유일한 호출자는 git-reconcile 의
|
|
779
|
+
`--reset-anchor` — prepare 경로는 절대 anchor 를 움직이지 않는다."""
|
|
780
|
+
key = task_key(project_id, task_group, task_id)
|
|
781
|
+
with _registry_lock():
|
|
782
|
+
data = _load()
|
|
783
|
+
row = data["tasks"].get(key)
|
|
784
|
+
if row is None:
|
|
785
|
+
raise RuntimeError(
|
|
786
|
+
f"no task-key entry to reset implementation base: {key}"
|
|
787
|
+
)
|
|
788
|
+
row["implementation_base_commit"] = commit
|
|
789
|
+
_save(data)
|
|
790
|
+
return commit
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
`git_reconcile.py` 에 추가:
|
|
794
|
+
|
|
795
|
+
```python
|
|
796
|
+
class ReconcileError(Exception):
|
|
797
|
+
pass
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _record_reconciled(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
801
|
+
new_commit: str, replaced: str, reason: str) -> None:
|
|
802
|
+
from .consumers import append_consumer
|
|
803
|
+
append_consumer(
|
|
804
|
+
plan_run_root, impl_task_key=impl_task_key, stage=stage,
|
|
805
|
+
status="done", force_reappend=True, head_commit=new_commit,
|
|
806
|
+
reconciled=True, reconcile_reason=reason, replaced_commit=replaced,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _apply_user_ref(project_root, plan_run_root, latest, stage, use_ref) -> dict:
|
|
811
|
+
if stage is None:
|
|
812
|
+
raise ReconcileError("--use-ref requires --stage")
|
|
813
|
+
sha = _resolve_commit_sha(project_root, use_ref)
|
|
814
|
+
if not sha:
|
|
815
|
+
raise ReconcileError(f"could not resolve ref `{use_ref}`")
|
|
816
|
+
row = latest.get(stage)
|
|
817
|
+
if not row:
|
|
818
|
+
raise ReconcileError(f"stage {stage} has no done row to reconcile")
|
|
819
|
+
_record_reconciled(plan_run_root, impl_task_key=row.get("impl_task_key", ""),
|
|
820
|
+
stage=stage, new_commit=sha,
|
|
821
|
+
replaced=row.get("head_commit", ""), reason="user-ref")
|
|
822
|
+
return {"stage": stage, "new_commit": sha}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
826
|
+
project_id: str, task_group: str, task_id: str,
|
|
827
|
+
work_category: str, stage: Optional[int] = None,
|
|
828
|
+
use_ref: str = "", reset_anchor: str = "") -> dict:
|
|
829
|
+
"""auto 항목 일괄 보정 + (옵션) confirm 항목 1건 보정 + (옵션) anchor 재고정.
|
|
830
|
+
|
|
831
|
+
enforcement(spec §3.6): confirm 항목은 `use_ref` 가 명시된 그 stage 만
|
|
832
|
+
보정된다 — 어떤 경로로도 무확인 자동 보정되지 않는다."""
|
|
833
|
+
from . import worktree_registry as _reg
|
|
834
|
+
from .consumers import read_consumers, latest_done_by_stage
|
|
835
|
+
|
|
836
|
+
applied: List[dict] = []
|
|
837
|
+
if reset_anchor:
|
|
838
|
+
sha = _resolve_commit_sha(project_root, reset_anchor)
|
|
839
|
+
if not sha:
|
|
840
|
+
raise ReconcileError(f"could not resolve ref `{reset_anchor}`")
|
|
841
|
+
_reg.reset_implementation_base(project_id, task_group, task_id, sha)
|
|
842
|
+
applied.append({"anchor": sha})
|
|
843
|
+
if use_ref:
|
|
844
|
+
latest = latest_done_by_stage(read_consumers(plan_run_root))
|
|
845
|
+
applied.append(_apply_user_ref(
|
|
846
|
+
project_root, plan_run_root, latest, stage, use_ref))
|
|
847
|
+
items = classify_task(
|
|
848
|
+
project_root=project_root, plan_run_root=plan_run_root,
|
|
849
|
+
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
850
|
+
work_category=work_category)
|
|
851
|
+
for item in items:
|
|
852
|
+
if item.classification != "auto":
|
|
853
|
+
continue
|
|
854
|
+
_record_reconciled(plan_run_root, impl_task_key=item.impl_task_key,
|
|
855
|
+
stage=item.stage, new_commit=item.suggested_commit,
|
|
856
|
+
replaced=item.recorded, reason="auto-patch-id")
|
|
857
|
+
applied.append({"stage": item.stage, "new_commit": item.suggested_commit})
|
|
858
|
+
remaining = [i for i in items if i.classification == "confirm"
|
|
859
|
+
and not (use_ref and i.stage == stage)]
|
|
860
|
+
return {"applied": applied, "remaining_confirm": remaining}
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
주의: `use_ref` 보정을 classify **이전에** 수행하므로, 같은 stage 가 confirm 으로 재분류되지 않도록 classify 는 use_ref 보정 *후* 의 consumers 를 읽는다 (위 코드 순서가 이미 그렇게 되어 있다 — classify 가 `read_consumers` 를 다시 읽음).
|
|
864
|
+
|
|
865
|
+
- [ ] **Step 4: 통과 확인**
|
|
866
|
+
|
|
867
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_apply.py tests/test_okstra_worktree_registry.py -v`
|
|
868
|
+
Expected: 전부 PASS
|
|
869
|
+
|
|
870
|
+
- [ ] **Step 5: Commit**
|
|
871
|
+
|
|
872
|
+
```bash
|
|
873
|
+
git add scripts/okstra_ctl/git_reconcile.py scripts/okstra_ctl/worktree_registry.py \
|
|
874
|
+
tests/test_okstra_git_reconcile_apply.py tests/test_okstra_worktree_registry.py
|
|
875
|
+
git commit -m "feat(okstra-ctl): apply_reconcile 보정 + anchor 재고정 + confirm enforcement"
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
---
|
|
879
|
+
|
|
880
|
+
### Task 7: CLI — `python3 -m okstra_ctl.git_reconcile` + `okstra git-reconcile` (node)
|
|
881
|
+
|
|
882
|
+
**Files:**
|
|
883
|
+
- Modify: `scripts/okstra_ctl/git_reconcile.py` (main 추가)
|
|
884
|
+
- Create: `src/git-reconcile.mjs`
|
|
885
|
+
- Modify: `bin/okstra` (COMMANDS map + USAGE)
|
|
886
|
+
- Test: `tests/test_okstra_git_reconcile_cli.py` (신규)
|
|
887
|
+
|
|
888
|
+
- [ ] **Step 1: 실패하는 테스트 작성** — main() 을 subprocess 없이 직접 호출:
|
|
889
|
+
|
|
890
|
+
```python
|
|
891
|
+
def test_main_check_json_exit_codes(env, capsys):
|
|
892
|
+
"""stale 없음 → 0, confirm 잔존 → 2."""
|
|
893
|
+
import json
|
|
894
|
+
from okstra_ctl.git_reconcile import main
|
|
895
|
+
repo, plan_root = env
|
|
896
|
+
args = ["--project-root", str(repo), "--plan-run-root", str(plan_root),
|
|
897
|
+
"--project-id", "proj", "--task-group", "grp",
|
|
898
|
+
"--task-id", "task", "--work-category", "fix", "--json"]
|
|
899
|
+
assert main(args) == 0 # done row 없음 = stale 없음
|
|
900
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
901
|
+
(repo / "a.txt").write_text("a\n")
|
|
902
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
903
|
+
_done(plan_root, 1, _git(repo, "rev-parse", "HEAD"))
|
|
904
|
+
(repo / "a.txt").write_text("a-reviewed\n")
|
|
905
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "--amend", "-m", "s1'")
|
|
906
|
+
capsys.readouterr()
|
|
907
|
+
assert main(args) == 2
|
|
908
|
+
report = json.loads(capsys.readouterr().out)
|
|
909
|
+
assert report["items"][0]["classification"] == "confirm"
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def test_main_apply_use_ref(env, capsys):
|
|
913
|
+
from okstra_ctl.git_reconcile import main
|
|
914
|
+
from okstra_ctl.consumers import read_consumers
|
|
915
|
+
repo, plan_root = env
|
|
916
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
917
|
+
(repo / "a.txt").write_text("a\n")
|
|
918
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
919
|
+
_done(plan_root, 1, "0" * 40)
|
|
920
|
+
code = main(["--project-root", str(repo), "--plan-run-root", str(plan_root),
|
|
921
|
+
"--project-id", "proj", "--task-group", "grp",
|
|
922
|
+
"--task-id", "task", "--work-category", "fix",
|
|
923
|
+
"--apply", "--stage", "1", "--use-ref", "fix-task-s1"])
|
|
924
|
+
assert code == 0
|
|
925
|
+
assert read_consumers(plan_root)[-1]["reconcile_reason"] == "user-ref"
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
(fixture `env`/`_git`/`_done` 은 Task 5 테스트 파일과 동일 — 파일 상단에 복사.)
|
|
929
|
+
|
|
930
|
+
- [ ] **Step 2: 실패 확인**
|
|
931
|
+
|
|
932
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_cli.py -v`
|
|
933
|
+
Expected: FAIL — `ImportError: cannot import name 'main'`
|
|
934
|
+
|
|
935
|
+
- [ ] **Step 3: 구현** — `git_reconcile.py` 끝에 ([migrate.py:378](../../../scripts/okstra_ctl/migrate.py:378) 패턴):
|
|
936
|
+
|
|
937
|
+
```python
|
|
938
|
+
def _items_as_json(items: List[StaleItem]) -> list:
|
|
939
|
+
return [{"kind": i.kind, "stage": i.stage, "recorded": i.recorded,
|
|
940
|
+
"classification": i.classification, "reason": i.reason,
|
|
941
|
+
"suggested_commit": i.suggested_commit} for i in items]
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def main(argv: Optional[list] = None) -> int:
|
|
945
|
+
import argparse
|
|
946
|
+
import json as _json
|
|
947
|
+
|
|
948
|
+
p = argparse.ArgumentParser(prog="okstra git-reconcile")
|
|
949
|
+
p.add_argument("--project-root", default=".")
|
|
950
|
+
p.add_argument("--plan-run-root", required=True,
|
|
951
|
+
help="consumers.jsonl 이 있는 runs/implementation-planning/ 경로")
|
|
952
|
+
p.add_argument("--project-id", required=True)
|
|
953
|
+
p.add_argument("--task-group", required=True)
|
|
954
|
+
p.add_argument("--task-id", required=True)
|
|
955
|
+
p.add_argument("--work-category", required=True)
|
|
956
|
+
p.add_argument("--apply", action="store_true",
|
|
957
|
+
help="auto 항목 일괄 보정 (+ --stage/--use-ref, --reset-anchor)")
|
|
958
|
+
p.add_argument("--stage", type=int)
|
|
959
|
+
p.add_argument("--use-ref", default="")
|
|
960
|
+
p.add_argument("--reset-anchor", default="")
|
|
961
|
+
p.add_argument("--json", action="store_true")
|
|
962
|
+
a = p.parse_args(argv)
|
|
963
|
+
|
|
964
|
+
kw = dict(project_root=Path(a.project_root).resolve(),
|
|
965
|
+
plan_run_root=Path(a.plan_run_root).resolve(),
|
|
966
|
+
project_id=a.project_id, task_group=a.task_group,
|
|
967
|
+
task_id=a.task_id, work_category=a.work_category)
|
|
968
|
+
try:
|
|
969
|
+
if a.apply:
|
|
970
|
+
result = apply_reconcile(**kw, stage=a.stage, use_ref=a.use_ref,
|
|
971
|
+
reset_anchor=a.reset_anchor)
|
|
972
|
+
out = {"applied": result["applied"],
|
|
973
|
+
"remaining_confirm": _items_as_json(result["remaining_confirm"])}
|
|
974
|
+
print(_json.dumps(out, ensure_ascii=False,
|
|
975
|
+
indent=None if a.json else 2))
|
|
976
|
+
return 2 if result["remaining_confirm"] else 0
|
|
977
|
+
items = classify_task(**kw)
|
|
978
|
+
stale = [i for i in items if i.classification != "ok"]
|
|
979
|
+
print(_json.dumps({"items": _items_as_json(stale)}, ensure_ascii=False,
|
|
980
|
+
indent=None if a.json else 2))
|
|
981
|
+
return 2 if any(i.classification == "confirm" for i in stale) else 0
|
|
982
|
+
except ReconcileError as exc:
|
|
983
|
+
print(_json.dumps({"error": str(exc)}, ensure_ascii=False))
|
|
984
|
+
return 1
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
if __name__ == "__main__":
|
|
988
|
+
raise SystemExit(main())
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
`src/git-reconcile.mjs` 신규 ([src/migrate.mjs](../../../src/migrate.mjs) 패턴 그대로):
|
|
992
|
+
|
|
993
|
+
```javascript
|
|
994
|
+
import { runPythonModule } from "./_python-helper.mjs";
|
|
995
|
+
|
|
996
|
+
const USAGE = `okstra git-reconcile — okstra 밖 git 히스토리 변경 후 기록 화해
|
|
997
|
+
|
|
998
|
+
A thin shim over \`python3 -m okstra_ctl.git_reconcile\`. 기본은 검사
|
|
999
|
+
(--check 상당): stale 항목을 JSON 으로 출력하고, confirm 항목이 남으면
|
|
1000
|
+
exit 2. patch-id 로 내용 동일성이 증명되는 항목(auto)은 --apply 로 일괄
|
|
1001
|
+
보정되고, 내용이 바뀐 항목(confirm)은 --stage/--use-ref 로만 보정된다.
|
|
1002
|
+
|
|
1003
|
+
Usage:
|
|
1004
|
+
okstra git-reconcile --plan-run-root <dir> --project-id <id> \\
|
|
1005
|
+
--task-group <g> --task-id <t> --work-category <c> \\
|
|
1006
|
+
[--apply] [--stage <N> --use-ref <ref>] [--reset-anchor <ref>] [--json]
|
|
1007
|
+
`;
|
|
1008
|
+
|
|
1009
|
+
export async function run(args) {
|
|
1010
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1011
|
+
process.stdout.write(USAGE);
|
|
1012
|
+
return 0;
|
|
1013
|
+
}
|
|
1014
|
+
const { code } = await runPythonModule({
|
|
1015
|
+
module: "okstra_ctl.git_reconcile",
|
|
1016
|
+
args,
|
|
1017
|
+
});
|
|
1018
|
+
return code ?? 1;
|
|
1019
|
+
}
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
`bin/okstra` 의 COMMANDS map 에 (`migrate` 항목 다음 줄):
|
|
1023
|
+
|
|
1024
|
+
```javascript
|
|
1025
|
+
[
|
|
1026
|
+
"git-reconcile",
|
|
1027
|
+
() => import("../src/git-reconcile.mjs").then((m) => m.run),
|
|
1028
|
+
],
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
USAGE 문자열의 `migrate` 라인 아래에:
|
|
1032
|
+
|
|
1033
|
+
```
|
|
1034
|
+
git-reconcile Reconcile stale stage SHAs after external git history changes
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
- [ ] **Step 4: 통과 확인**
|
|
1038
|
+
|
|
1039
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_cli.py -v && node bin/okstra git-reconcile --help`
|
|
1040
|
+
Expected: pytest PASS, node 가 USAGE 출력 후 exit 0
|
|
1041
|
+
|
|
1042
|
+
- [ ] **Step 5: Commit**
|
|
1043
|
+
|
|
1044
|
+
```bash
|
|
1045
|
+
git add scripts/okstra_ctl/git_reconcile.py src/git-reconcile.mjs bin/okstra \
|
|
1046
|
+
tests/test_okstra_git_reconcile_cli.py
|
|
1047
|
+
git commit -m "feat(cli): okstra git-reconcile subcommand 추가"
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
---
|
|
1051
|
+
|
|
1052
|
+
### Task 8: prepare 경로 통합 — 자동 화해 + gate fallback + 안내 에러
|
|
1053
|
+
|
|
1054
|
+
**Files:**
|
|
1055
|
+
- Modify: `scripts/okstra_ctl/run.py` — `_resolve_stage_base_commit`(465), `_select_and_provision_implementation_stage`(1295), `_reserve_final_verification_target`(1454)
|
|
1056
|
+
- Modify: `scripts/okstra_ctl/git_reconcile.py` (`auto_reconcile`, `guidance` 추가)
|
|
1057
|
+
- Test: `tests/test_okstra_run_stage_base.py`, `tests/test_okstra_git_reconcile_prepare.py` (신규)
|
|
1058
|
+
|
|
1059
|
+
- [ ] **Step 1: `auto_reconcile` + `guidance` 를 git_reconcile.py 에 추가 (실패 테스트 먼저)** — `tests/test_okstra_git_reconcile_prepare.py` (fixture 는 Task 5 와 동일):
|
|
1060
|
+
|
|
1061
|
+
```python
|
|
1062
|
+
def test_auto_reconcile_records_only_auto(env):
|
|
1063
|
+
"""prepare 진입 시 자동 화해: auto 만 기록, confirm 은 그대로."""
|
|
1064
|
+
from okstra_ctl.git_reconcile import auto_reconcile
|
|
1065
|
+
from okstra_ctl.consumers import read_consumers, latest_done_by_stage
|
|
1066
|
+
repo, plan_root = env
|
|
1067
|
+
_git(repo, "checkout", "-q", "-b", "fix-task-s1")
|
|
1068
|
+
(repo / "a.txt").write_text("a\n")
|
|
1069
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "s1")
|
|
1070
|
+
_done(plan_root, 1, _git(repo, "rev-parse", "HEAD"))
|
|
1071
|
+
_git(repo, "checkout", "-q", "main")
|
|
1072
|
+
(repo / "m.txt").write_text("m\n")
|
|
1073
|
+
_git(repo, "add", "."); _git(repo, "commit", "-q", "-m", "main moves")
|
|
1074
|
+
_git(repo, "rebase", "-q", "main", "fix-task-s1")
|
|
1075
|
+
applied = auto_reconcile(project_root=repo, plan_run_root=plan_root,
|
|
1076
|
+
project_id="proj", task_group="grp",
|
|
1077
|
+
task_id="task", work_category="fix")
|
|
1078
|
+
assert len(applied) == 1
|
|
1079
|
+
assert latest_done_by_stage(read_consumers(plan_root))[1]["head_commit"] \
|
|
1080
|
+
== _git(repo, "rev-parse", "fix-task-s1")
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def test_guidance_mentions_command(env):
|
|
1084
|
+
from okstra_ctl.git_reconcile import guidance
|
|
1085
|
+
repo, plan_root = env
|
|
1086
|
+
text = guidance(plan_run_root=plan_root, project_id="proj",
|
|
1087
|
+
task_group="grp", task_id="task", work_category="fix")
|
|
1088
|
+
assert "okstra git-reconcile" in text and "--use-ref" in text
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
- [ ] **Step 2: 실패 확인 후 구현** — `git_reconcile.py` 에:
|
|
1092
|
+
|
|
1093
|
+
```python
|
|
1094
|
+
def auto_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
1095
|
+
project_id: str, task_group: str, task_id: str,
|
|
1096
|
+
work_category: str) -> List[StaleItem]:
|
|
1097
|
+
"""classify 의 auto 항목만 보정 row 로 재기록한다 — patch-id 증명이
|
|
1098
|
+
있으므로 확인 불필요(설계 §2 결정). confirm 은 건드리지 않는다."""
|
|
1099
|
+
applied = []
|
|
1100
|
+
items = classify_task(
|
|
1101
|
+
project_root=project_root, plan_run_root=plan_run_root,
|
|
1102
|
+
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
1103
|
+
work_category=work_category)
|
|
1104
|
+
for item in items:
|
|
1105
|
+
if item.classification != "auto" or item.kind != "done":
|
|
1106
|
+
continue
|
|
1107
|
+
_record_reconciled(plan_run_root, impl_task_key=item.impl_task_key,
|
|
1108
|
+
stage=item.stage, new_commit=item.suggested_commit,
|
|
1109
|
+
replaced=item.recorded, reason="auto-patch-id")
|
|
1110
|
+
applied.append(item)
|
|
1111
|
+
return applied
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def guidance(*, plan_run_root: Path, project_id: str, task_group: str,
|
|
1115
|
+
task_id: str, work_category: str) -> str:
|
|
1116
|
+
"""PrepareError 에 첨부하는 회복 안내 (명령 예시 포함)."""
|
|
1117
|
+
base = (f"okstra git-reconcile --plan-run-root {plan_run_root} "
|
|
1118
|
+
f"--project-id {project_id} --task-group {task_group} "
|
|
1119
|
+
f"--task-id {task_id} --work-category {work_category}")
|
|
1120
|
+
return ("Recorded stage SHAs no longer match the git history "
|
|
1121
|
+
"(external rebase/squash/amend?). Inspect and reconcile:\n"
|
|
1122
|
+
f" {base} --json\n"
|
|
1123
|
+
f" {base} --apply --stage <N> --use-ref <branch|sha>")
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
Run: `python3 -m pytest tests/test_okstra_git_reconcile_prepare.py -v` → PASS
|
|
1127
|
+
|
|
1128
|
+
- [ ] **Step 3: run.py 통합 (실패 테스트 먼저)** — `tests/test_okstra_run_stage_base.py` 에 추가:
|
|
1129
|
+
|
|
1130
|
+
```python
|
|
1131
|
+
def test_multi_dep_accepts_patch_equivalent(tmp_path):
|
|
1132
|
+
"""다중 의존 gate: ancestor 가 아니어도 squash 로 내용이 머지됐으면 통과."""
|
|
1133
|
+
import subprocess
|
|
1134
|
+
from okstra_ctl.run import _resolve_stage_base_commit
|
|
1135
|
+
|
|
1136
|
+
def g(*args):
|
|
1137
|
+
return subprocess.run(["git", "-C", str(repo), *args],
|
|
1138
|
+
capture_output=True, text=True,
|
|
1139
|
+
check=True).stdout.strip()
|
|
1140
|
+
repo = tmp_path / "repo"; repo.mkdir()
|
|
1141
|
+
g("init", "-q", "-b", "main")
|
|
1142
|
+
g("config", "user.email", "t@e"); g("config", "user.name", "t")
|
|
1143
|
+
(repo / "base.txt").write_text("base\n")
|
|
1144
|
+
g("add", "."); g("commit", "-q", "-m", "base")
|
|
1145
|
+
anchor = g("rev-parse", "HEAD")
|
|
1146
|
+
g("checkout", "-q", "-b", "s1")
|
|
1147
|
+
(repo / "a.txt").write_text("a\n")
|
|
1148
|
+
g("add", "."); g("commit", "-q", "-m", "s1")
|
|
1149
|
+
done1 = g("rev-parse", "HEAD")
|
|
1150
|
+
g("checkout", "-q", "main")
|
|
1151
|
+
g("merge", "--squash", "-q", "s1"); g("commit", "-q", "-m", "squash s1")
|
|
1152
|
+
candidate = g("rev-parse", "HEAD")
|
|
1153
|
+
|
|
1154
|
+
plan_root = tmp_path / "plan"; plan_root.mkdir()
|
|
1155
|
+
stage = {"stage_number": 3, "depends_on": [1, 2]}
|
|
1156
|
+
rows = [
|
|
1157
|
+
{"impl_task_key": "k", "stage": 1, "status": "done", "head_commit": done1},
|
|
1158
|
+
{"impl_task_key": "k", "stage": 2, "status": "done", "head_commit": candidate},
|
|
1159
|
+
]
|
|
1160
|
+
base = _resolve_stage_base_commit(
|
|
1161
|
+
stage, rows, anchor_base_commit=anchor, candidate_base=candidate,
|
|
1162
|
+
project_root=repo, plan_run_root=plan_root)
|
|
1163
|
+
assert base == candidate
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
Run → Expected: FAIL — `PrepareError ... not merged` (또는 unexpected kwarg `plan_run_root`)
|
|
1167
|
+
|
|
1168
|
+
- [ ] **Step 4: run.py 구현**
|
|
1169
|
+
|
|
1170
|
+
(a) `_resolve_stage_base_commit` 다중 의존 검사 블록 ([run.py:506-515](../../../scripts/okstra_ctl/run.py:506)) 을 추출 함수로 옮기고 content fallback 추가. 시그니처에 `plan_run_root=None` 추가 (기본값으로 기존 테스트 호환):
|
|
1171
|
+
|
|
1172
|
+
```python
|
|
1173
|
+
def _check_multi_dep_merged(project_root, plan_run_root, latest,
|
|
1174
|
+
pred_commits: dict, candidate_base: str,
|
|
1175
|
+
stage_n: int) -> None:
|
|
1176
|
+
"""모든 선행 done commit 이 candidate 에 (내용 기준으로라도) 머지됐는지
|
|
1177
|
+
검증. patch-equivalent 면 보정 row 를 자동 기록(다음 run 은 ancestor 로
|
|
1178
|
+
바로 통과). 아니면 회복 안내를 담아 PrepareError."""
|
|
1179
|
+
from .git_reconcile import content_merged
|
|
1180
|
+
for d, head in pred_commits.items():
|
|
1181
|
+
if _commit_is_ancestor(project_root, head, candidate_base):
|
|
1182
|
+
continue
|
|
1183
|
+
match = content_merged(project_root, head, candidate_base)
|
|
1184
|
+
if match.status in ("ancestor", "patch-equivalent"):
|
|
1185
|
+
if plan_run_root is not None:
|
|
1186
|
+
from .git_reconcile import _record_reconciled
|
|
1187
|
+
_record_reconciled(
|
|
1188
|
+
plan_run_root,
|
|
1189
|
+
impl_task_key=(latest.get(d) or {}).get("impl_task_key", ""),
|
|
1190
|
+
stage=d, new_commit=match.matched_commit,
|
|
1191
|
+
replaced=head, reason="auto-patch-id")
|
|
1192
|
+
continue
|
|
1193
|
+
raise PrepareError(
|
|
1194
|
+
f"multi-dependency stage {stage_n}: predecessor stage {d} "
|
|
1195
|
+
f"({head[:8]}) is not merged into the task worktree "
|
|
1196
|
+
f"({candidate_base[:8]}). Merge stage branches "
|
|
1197
|
+
f"(e.g. the `-s{d}` branches) into the task worktree "
|
|
1198
|
+
"(or into main, then refresh the worktree) and retry."
|
|
1199
|
+
)
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
`_resolve_stage_base_commit` 의 해당 for 루프를 `_check_multi_dep_merged(project_root, plan_run_root, latest, pred_commits, candidate_base, n)` 호출로 교체하고, 시그니처를 `def _resolve_stage_base_commit(stage, consumer_done_rows, anchor_base_commit, candidate_base="", project_root=None, plan_run_root=None)` 로 확장.
|
|
1203
|
+
|
|
1204
|
+
(b) `_select_and_provision_implementation_stage` — [run.py:1317](../../../scripts/okstra_ctl/run.py:1317) `backfill_done_from_carry(plan_run_root)` 직후에 `_auto_reconcile_best_effort(inp, plan_run_root)` 호출(helper 정의는 아래). 같은 함수의 `_resolve_stage_base_commit(...)` 호출 ([run.py:1360](../../../scripts/okstra_ctl/run.py:1360)) 에 `plan_run_root=plan_run_root` 인자 추가. **주의:** auto_reconcile 이 row 를 추가했을 수 있으므로 `consumed = read_consumers(plan_run_root)` ([run.py:1318](../../../scripts/okstra_ctl/run.py:1318)) 는 반드시 화해 **이후에** 실행되도록 호출 순서를 유지한다.
|
|
1205
|
+
|
|
1206
|
+
(c) 같은 함수에서 단일 의존 base 가 unresolvable 인 채 provision 으로 떨어지는 경우와 다중 의존 not-merged 의 `PrepareError` 에 회복 안내를 덧붙인다 — provision 예외 래핑 ([run.py:1374-1375](../../../scripts/okstra_ctl/run.py:1374)) 을:
|
|
1207
|
+
|
|
1208
|
+
```python
|
|
1209
|
+
except RuntimeError as exc:
|
|
1210
|
+
from .git_reconcile import guidance
|
|
1211
|
+
hint = guidance(plan_run_root=plan_run_root, project_id=inp.project_id,
|
|
1212
|
+
task_group=inp.task_group, task_id=inp.task_id,
|
|
1213
|
+
work_category=inp.work_category)
|
|
1214
|
+
raise PrepareError(
|
|
1215
|
+
f"stage worktree provisioning failed: {exc}\n{hint}") from exc
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
(d) `_reserve_final_verification_target` — [run.py:1466](../../../scripts/okstra_ctl/run.py:1466) `backfill_done_from_carry` 직후에 동일하게 `_auto_reconcile_best_effort(inp, plan_run_root)` 호출. whole-task merged 검사 ([run.py:1488](../../../scripts/okstra_ctl/run.py:1488), Task 3 에서 helper 화) 는 auto_reconcile 이 선행되므로 추가 fallback 불필요 — 화해된 row 의 새 SHA 가 ancestor 검사를 통과한다.
|
|
1219
|
+
|
|
1220
|
+
(b)(d) 가 공유하는 helper — `run.py` 의 `_git_out` 부근에 정의:
|
|
1221
|
+
|
|
1222
|
+
```python
|
|
1223
|
+
def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
|
|
1224
|
+
try:
|
|
1225
|
+
from .git_reconcile import auto_reconcile
|
|
1226
|
+
for it in auto_reconcile(
|
|
1227
|
+
project_root=Path(inp.project_root), plan_run_root=plan_run_root,
|
|
1228
|
+
project_id=inp.project_id, task_group=inp.task_group,
|
|
1229
|
+
task_id=inp.task_id, work_category=inp.work_category):
|
|
1230
|
+
print(f"git-reconcile: stage {it.stage} done commit "
|
|
1231
|
+
f"{it.recorded[:8]} -> {it.suggested_commit[:8]} "
|
|
1232
|
+
"(patch-equivalent)", file=sys.stdout)
|
|
1233
|
+
except Exception as exc: # 화해는 부가 기능 — 실패 시 기존 gate 가 판정
|
|
1234
|
+
print(f"git-reconcile skipped: {exc}", file=sys.stderr)
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
- [ ] **Step 5: 통과 확인**
|
|
1238
|
+
|
|
1239
|
+
Run: `python3 -m pytest tests/test_okstra_run_stage_base.py tests/test_okstra_git_reconcile_prepare.py tests/test_e2e_impl_stage_artifact_isolation.py -v`
|
|
1240
|
+
Expected: 전부 PASS
|
|
1241
|
+
|
|
1242
|
+
- [ ] **Step 6: Commit**
|
|
1243
|
+
|
|
1244
|
+
```bash
|
|
1245
|
+
git add scripts/okstra_ctl/run.py scripts/okstra_ctl/git_reconcile.py \
|
|
1246
|
+
tests/test_okstra_run_stage_base.py tests/test_okstra_git_reconcile_prepare.py
|
|
1247
|
+
git commit -m "feat(okstra-ctl): prepare 경로에 stale SHA 자동 화해 + gate patch-id fallback"
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
---
|
|
1251
|
+
|
|
1252
|
+
### Task 9: okstra-run 스킬 picker 배선
|
|
1253
|
+
|
|
1254
|
+
**Files:**
|
|
1255
|
+
- Modify: `skills/okstra-run/SKILL.md` — qa-waiver 문단 ([SKILL.md:189-197](../../../skills/okstra-run/SKILL.md:189) 부근, `--qa-waiver` 설명 블록) **다음**에 새 소절 삽입
|
|
1256
|
+
|
|
1257
|
+
- [ ] **Step 1: 소절 삽입** — 아래 마크다운을 그대로 추가:
|
|
1258
|
+
|
|
1259
|
+
```markdown
|
|
1260
|
+
### Stale git SHA recovery (git-reconcile gate)
|
|
1261
|
+
|
|
1262
|
+
`render-bundle` 이 `Recorded stage SHAs no longer match the git history` 를 포함한
|
|
1263
|
+
`PrepareError` 로 실패하면, okstra 밖에서 git 히스토리가 바뀐 것이다(rebase /
|
|
1264
|
+
squash / 리뷰 반영 amend / branch 삭제). 절대 registry/consumers 를 손으로
|
|
1265
|
+
고치지 말고 다음 순서로 회복한다:
|
|
1266
|
+
|
|
1267
|
+
1. 에러 메시지에 인쇄된 `okstra git-reconcile … --json` 명령을 그대로 실행해
|
|
1268
|
+
stale 리포트를 얻는다. (patch-id 로 내용 동일성이 증명되는 항목은 prepare
|
|
1269
|
+
가 이미 자동 화해했으므로, 여기 남는 것은 confirm 항목뿐이다.)
|
|
1270
|
+
2. confirm 항목별로 사용자에게 3-옵션 picker 를 제시한다:
|
|
1271
|
+
- **`stage-<N>` branch 의 현재 tip 으로 재기록 (추천)** — 리뷰 반영 등
|
|
1272
|
+
의도된 수정이 그 branch 에 있을 때.
|
|
1273
|
+
- **다른 ref 직접 입력** — 사용자가 commit/branch/tag 를 직접 지정.
|
|
1274
|
+
- **중단** — 회복하지 않고 run 을 멈춘다.
|
|
1275
|
+
3. 선택된 ref 로 `okstra git-reconcile … --apply --stage <N> --use-ref <ref>`
|
|
1276
|
+
를 실행한 뒤, 실패했던 `render-bundle` 을 동일 인자로 재시도한다.
|
|
1277
|
+
|
|
1278
|
+
anchor(`implementation_base_commit`)가 unresolvable 로 보고되면 같은 명령의
|
|
1279
|
+
`--reset-anchor <ref>` 를 사용자 확인 후 실행한다. picker 없이 confirm 항목을
|
|
1280
|
+
보정하는 것은 금지 — 런타임도 `--use-ref` 없는 confirm 보정을 거부한다.
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
- [ ] **Step 2: 검증** — 스킬 빌드 동기화 확인:
|
|
1284
|
+
|
|
1285
|
+
Run: `npm run build && grep -n "git-reconcile" runtime/skills/okstra-run/SKILL.md | head -3`
|
|
1286
|
+
Expected: build 성공, runtime 에 소절 반영
|
|
1287
|
+
|
|
1288
|
+
- [ ] **Step 3: Commit**
|
|
1289
|
+
|
|
1290
|
+
```bash
|
|
1291
|
+
git add skills/okstra-run/SKILL.md
|
|
1292
|
+
git commit -m "feat(skills/okstra-run): stale SHA 회복 picker 단계 추가"
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
---
|
|
1296
|
+
|
|
1297
|
+
### Task 10: e2e 시나리오 + 문서 + 최종 검증
|
|
1298
|
+
|
|
1299
|
+
**Files:**
|
|
1300
|
+
- Create: `tests-e2e/scenario-13-git-reconcile-squash.sh`
|
|
1301
|
+
- Modify: `docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md` (okstra.sh 패스스루 제거 — 검증 결과 [scripts/okstra.sh](../../../scripts/okstra.sh) 에는 subcommand dispatch 가 없어 ops 명령은 node CLI 전용)
|
|
1302
|
+
- Modify: `CHANGES.md` (최상단에 항목 추가)
|
|
1303
|
+
|
|
1304
|
+
- [ ] **Step 1: e2e 시나리오** — 기존 시나리오 패턴(mktemp OKSTRA_HOME + trap 정리). 핵심 흐름: 실 git repo 에서 stage1 done 기록 → squash merge + branch 삭제 → `python3 -m okstra_ctl.git_reconcile` 검사가 auto 화해 가능을 보고하는지 확인:
|
|
1305
|
+
|
|
1306
|
+
```bash
|
|
1307
|
+
#!/usr/bin/env bash
|
|
1308
|
+
# scenario-13: squash merge + branch 삭제 후 git-reconcile 이 patch-equivalent
|
|
1309
|
+
# 를 자동 화해하는지 검증.
|
|
1310
|
+
set -euo pipefail
|
|
1311
|
+
|
|
1312
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
1313
|
+
WORK="$(mktemp -d)"
|
|
1314
|
+
export OKSTRA_HOME="$WORK/okstra-home"
|
|
1315
|
+
mkdir -p "$OKSTRA_HOME"
|
|
1316
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
1317
|
+
export PYTHONPATH="$ROOT/scripts"
|
|
1318
|
+
|
|
1319
|
+
REPO="$WORK/repo"; mkdir -p "$REPO"
|
|
1320
|
+
git -C "$REPO" init -q -b main
|
|
1321
|
+
git -C "$REPO" config user.email t@e
|
|
1322
|
+
git -C "$REPO" config user.name t
|
|
1323
|
+
echo base > "$REPO/base.txt"
|
|
1324
|
+
git -C "$REPO" add . && git -C "$REPO" commit -q -m base
|
|
1325
|
+
|
|
1326
|
+
git -C "$REPO" checkout -q -b fix-task-s1
|
|
1327
|
+
echo a > "$REPO/a.txt"
|
|
1328
|
+
git -C "$REPO" add . && git -C "$REPO" commit -q -m s1
|
|
1329
|
+
DONE_SHA="$(git -C "$REPO" rev-parse HEAD)"
|
|
1330
|
+
git -C "$REPO" checkout -q main
|
|
1331
|
+
git -C "$REPO" merge --squash -q fix-task-s1
|
|
1332
|
+
git -C "$REPO" commit -q -m "squash s1"
|
|
1333
|
+
SQUASH_SHA="$(git -C "$REPO" rev-parse HEAD)"
|
|
1334
|
+
git -C "$REPO" branch -q -D fix-task-s1
|
|
1335
|
+
|
|
1336
|
+
PLAN_ROOT="$WORK/runs/implementation-planning"; mkdir -p "$PLAN_ROOT"
|
|
1337
|
+
python3 - "$PLAN_ROOT" "$DONE_SHA" <<'PY'
|
|
1338
|
+
import sys
|
|
1339
|
+
from pathlib import Path
|
|
1340
|
+
from okstra_ctl.consumers import append_consumer
|
|
1341
|
+
append_consumer(Path(sys.argv[1]), impl_task_key="p/g/t", stage=1,
|
|
1342
|
+
status="done", head_commit=sys.argv[2])
|
|
1343
|
+
PY
|
|
1344
|
+
|
|
1345
|
+
OUT="$(python3 -m okstra_ctl.git_reconcile \
|
|
1346
|
+
--project-root "$REPO" --plan-run-root "$PLAN_ROOT" \
|
|
1347
|
+
--project-id p --task-group g --task-id t --work-category fix --json)" || true
|
|
1348
|
+
# branch 삭제됨 → done SHA 는 여전히 resolve 되므로 stale 항목 없음(exit 0).
|
|
1349
|
+
# squash 머지의 자동 화해는 prepare 의 multi-dep gate 경로이므로 여기서는
|
|
1350
|
+
# content_merged 단위로 직접 검증한다:
|
|
1351
|
+
python3 - "$REPO" "$DONE_SHA" "$SQUASH_SHA" <<'PY'
|
|
1352
|
+
import sys
|
|
1353
|
+
from pathlib import Path
|
|
1354
|
+
from okstra_ctl.git_reconcile import content_merged
|
|
1355
|
+
res = content_merged(Path(sys.argv[1]), sys.argv[2], "main")
|
|
1356
|
+
assert res.status == "patch-equivalent", res
|
|
1357
|
+
assert res.matched_commit == sys.argv[3], res
|
|
1358
|
+
print("scenario-13 OK: squash content reconciled")
|
|
1359
|
+
PY
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
Run: `bash tests-e2e/scenario-13-git-reconcile-squash.sh`
|
|
1363
|
+
Expected: `scenario-13 OK: squash content reconciled`
|
|
1364
|
+
|
|
1365
|
+
- [ ] **Step 2: 스펙 보정** — 스펙 §3.3 의 문장 `` `okstra.sh` 도 동일 패스스루.`` 를 다음으로 교체:
|
|
1366
|
+
|
|
1367
|
+
```
|
|
1368
|
+
`okstra.sh` 는 run 런처(flag 기반)라 ops subcommand dispatch 가 없으므로 배선하지 않는다 — ops 명령은 node CLI 전용(기존 `migrate` 와 동일).
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
- [ ] **Step 3: CHANGES.md 항목** — 파일 최상단 기존 첫 항목 위에 추가 (기존 항목 형식 그대로):
|
|
1372
|
+
|
|
1373
|
+
```markdown
|
|
1374
|
+
### feat(okstra-ctl): okstra 밖 git 히스토리 변경 후 stale SHA 자동 회복 (git-reconcile)
|
|
1375
|
+
|
|
1376
|
+
- **배경**: stage 기록(anchor / done `head_commit`)은 commit SHA 로 고정되는데, PR 리뷰 반영 amend·rebase·squash merge·branch 삭제 등 okstra 밖의 작업이 히스토리를 재작성하면 ancestor 게이트가 "not merged" 로 거부하고 회복 경로가 없어 registry/consumers 손편집이 필요했다.
|
|
1377
|
+
- **변경**: 신규 [`scripts/okstra_ctl/git_reconcile.py`](scripts/okstra_ctl/git_reconcile.py) 가 3단 방어를 제공 — ① patch-id(커밋 단위 + 범위 합산) 로 내용 동일성이 증명되면 prepare 가 자동 화해(보정 done row 재-append), ② 내용이 바뀐 항목은 `okstra git-reconcile --apply --stage N --use-ref <ref>` 로만 보정(무확인 자동 보정은 런타임이 거부), ③ anchor 는 `--reset-anchor` 로 재고정. done-row 읽기는 `latest_done_by_stage` 로 last-wins 통일. okstra-run 스킬은 PrepareError 회복 안내를 받아 3-옵션 picker 를 제시한다. 설계: [`docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md`](docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md).
|
|
1378
|
+
- 사용자 영향: 다음 release + `npx -y okstra@latest install` 후, PR 리뷰 수정·rebase·squash merge 이후에도 `implementation` run 이 손편집 없이 이어진다 — 내용이 같으면 자동, 내용이 바뀌었으면 picker 확인 한 번.
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1381
|
+
- [ ] **Step 4: 최종 검증 (critique mode)**
|
|
1382
|
+
|
|
1383
|
+
```bash
|
|
1384
|
+
npm run build
|
|
1385
|
+
python3 -m pytest tests/ -q
|
|
1386
|
+
bash validators/validate-workflow.sh
|
|
1387
|
+
bash tests-e2e/scenario-13-git-reconcile-squash.sh
|
|
1388
|
+
node bin/okstra git-reconcile --help
|
|
1389
|
+
grep -rn "force_reappend\|latest_done_by_stage\|reset_implementation_base\|content_merged\|auto_reconcile" scripts/ src/ bin/ skills/ --include='*.py' --include='*.mjs' --include='*.md' -l
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
Expected: build 성공, pytest 전체 PASS, validator PASS, e2e OK, help 출력, grep 으로 신규 식별자의 정의·사용처가 의도한 파일에만 존재함을 확인.
|
|
1393
|
+
|
|
1394
|
+
- [ ] **Step 5: Commit**
|
|
1395
|
+
|
|
1396
|
+
```bash
|
|
1397
|
+
git add tests-e2e/scenario-13-git-reconcile-squash.sh CHANGES.md \
|
|
1398
|
+
docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md
|
|
1399
|
+
git commit -m "test(e2e): git-reconcile squash 회복 시나리오 + CHANGES 항목"
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
---
|
|
1403
|
+
|
|
1404
|
+
## Self-Review 체크 결과 (작성 시점)
|
|
1405
|
+
|
|
1406
|
+
- **스펙 커버리지**: §3.1→Task 4, §3.2→Task 5, §3.3→Task 6·7, §3.4→Task 1·3, §3.5→Task 6, §3.6→Task 8·9, §5 테스트→각 task TDD + Task 10. 스펙의 "classify 가 다중 의존 gate 도 평가" 는 prepare 경로 평가로 단순화(Task 5 docstring + Task 10 Step 2 스펙 보정으로 정합).
|
|
1407
|
+
- **타입 일관성**: `MatchResult.status` 4값, `StaleItem.classification` 3값, `reconcile_reason` ∈ {`auto-patch-id`, `user-ref`} 를 전 task 가 공유.
|
|
1408
|
+
- **함수 50줄 캡**: `content_merged`·`classify_task`·`apply_reconcile` 는 보조 함수 추출로 캡 이내. 구현 중 초과 시 추가 추출.
|