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.
Files changed (32) hide show
  1. package/bin/okstra +7 -0
  2. package/docs/kr/architecture.md +17 -1
  3. package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
  4. package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
  5. package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
  6. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
  7. package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
  8. package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
  9. package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
  10. package/package.json +1 -1
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/agents/SKILL.md +5 -4
  13. package/runtime/prompts/profiles/_common-contract.md +6 -6
  14. package/runtime/prompts/profiles/final-verification.md +3 -2
  15. package/runtime/prompts/profiles/release-handoff.md +12 -5
  16. package/runtime/prompts/wizard/prompts.ko.json +1 -1
  17. package/runtime/python/okstra_ctl/consumers.py +72 -5
  18. package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
  19. package/runtime/python/okstra_ctl/handoff.py +348 -0
  20. package/runtime/python/okstra_ctl/render.py +44 -2
  21. package/runtime/python/okstra_ctl/run.py +88 -27
  22. package/runtime/python/okstra_ctl/wizard.py +25 -4
  23. package/runtime/python/okstra_ctl/worktree.py +10 -0
  24. package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
  25. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  26. package/runtime/skills/okstra-convergence/SKILL.md +1 -1
  27. package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
  28. package/runtime/skills/okstra-run/SKILL.md +43 -3
  29. package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
  30. package/runtime/validators/validate-run.py +49 -9
  31. package/src/git-reconcile.mjs +31 -0
  32. 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` 는 보조 함수 추출로 캡 이내. 구현 중 초과 시 추가 추출.