okstra 0.67.0 → 0.69.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 (50) hide show
  1. package/bin/okstra +25 -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 +8 -7
  13. package/runtime/agents/workers/claude-worker.md +1 -1
  14. package/runtime/agents/workers/codex-worker.md +3 -3
  15. package/runtime/agents/workers/gemini-worker.md +3 -3
  16. package/runtime/agents/workers/report-writer-worker.md +2 -2
  17. package/runtime/prompts/launch.template.md +2 -2
  18. package/runtime/prompts/profiles/_common-contract.md +6 -6
  19. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  20. package/runtime/prompts/profiles/_implementation-executor.md +3 -1
  21. package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
  22. package/runtime/prompts/profiles/final-verification.md +3 -2
  23. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  24. package/runtime/prompts/profiles/release-handoff.md +12 -5
  25. package/runtime/prompts/wizard/prompts.ko.json +5 -5
  26. package/runtime/python/okstra_ctl/conformance.py +17 -0
  27. package/runtime/python/okstra_ctl/consumers.py +72 -5
  28. package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
  29. package/runtime/python/okstra_ctl/handoff.py +348 -0
  30. package/runtime/python/okstra_ctl/render.py +44 -2
  31. package/runtime/python/okstra_ctl/run.py +175 -44
  32. package/runtime/python/okstra_ctl/wizard.py +89 -22
  33. package/runtime/python/okstra_ctl/worktree.py +28 -0
  34. package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
  35. package/runtime/python/okstra_token_usage/collect.py +27 -0
  36. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  37. package/runtime/skills/okstra-convergence/SKILL.md +3 -3
  38. package/runtime/skills/okstra-report-writer/SKILL.md +8 -8
  39. package/runtime/skills/okstra-run/SKILL.md +43 -3
  40. package/runtime/skills/okstra-team-contract/SKILL.md +7 -7
  41. package/runtime/validators/validate-run.py +51 -11
  42. package/src/_python-helper.mjs +52 -0
  43. package/src/error-log.mjs +19 -0
  44. package/src/git-reconcile.mjs +31 -0
  45. package/src/handoff.mjs +30 -0
  46. package/src/inject-report-index.mjs +22 -0
  47. package/src/render-final-report.mjs +22 -0
  48. package/src/render-views.mjs +9 -48
  49. package/src/spawn-followups.mjs +23 -0
  50. package/src/token-usage.mjs +3 -34
@@ -0,0 +1,322 @@
1
+ """Stale git SHA 감지·화해·보정 (3단 방어)의 단일 reference point.
2
+
3
+ 저장된 SHA(anchor / done.head_commit)가 okstra 밖의 히스토리 재작성으로
4
+ stale 해졌을 때 — patch-id 로 내용 동일성이 증명되면 자동 화해(auto),
5
+ 내용이 바뀌었으면 사용자 확인 보정(confirm). 설계상 소비자는 prepare
6
+ 경로(run.py), `okstra git-reconcile` subcommand, okstra-run 스킬 —
7
+ 셋 모두 이 모듈 하나를 통해야 한다. 설계:
8
+ docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ from .worktree import _resolve_commit_sha
18
+
19
+
20
+ def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
21
+ return subprocess.run(["git", "-C", str(cwd), *args],
22
+ capture_output=True, text=True)
23
+
24
+
25
+ @dataclass
26
+ class MatchResult:
27
+ status: str # "ancestor" | "patch-equivalent" | "not-merged" | "unresolvable"
28
+ matched_commit: str = ""
29
+
30
+
31
+ def _patch_ids_of_range(cwd: Path, base: str, head: str) -> List[Tuple[str, str]]:
32
+ """base..head 각 커밋의 (patch-id, sha). diff 없는 커밋은 제외된다."""
33
+ log = _git(cwd, "log", "-p", "--no-merges", f"{base}..{head}")
34
+ if log.returncode != 0:
35
+ return []
36
+ pid = subprocess.run(["git", "-C", str(cwd), "patch-id", "--stable"],
37
+ input=log.stdout, capture_output=True, text=True)
38
+ out = []
39
+ for line in pid.stdout.splitlines():
40
+ parts = line.split()
41
+ if len(parts) == 2:
42
+ out.append((parts[0], parts[1]))
43
+ return out
44
+
45
+
46
+ def _patch_id_of_diff(cwd: Path, base: str, head: str) -> str:
47
+ """base→head 전체를 한 diff 로 본 patch-id (squash 동등성용)."""
48
+ diff = _git(cwd, "diff", base, head)
49
+ if diff.returncode != 0 or not diff.stdout.strip():
50
+ return ""
51
+ pid = subprocess.run(["git", "-C", str(cwd), "patch-id", "--stable"],
52
+ input=diff.stdout + "\n", capture_output=True, text=True)
53
+ parts = pid.stdout.split()
54
+ return parts[0] if parts else ""
55
+
56
+
57
+ def content_merged(project_root: Path, commit: str, candidate: str,
58
+ base: str = "") -> MatchResult:
59
+ """commit 의 내용이 candidate 히스토리에 포함되는가.
60
+
61
+ 1) ancestor 면 그대로 통과. 2) patch-id fallback 두 granularity:
62
+ 커밋 단위(rebase/cherry-pick — base..commit 의 *모든* 커밋이 매칭돼야 함)
63
+ + 범위 합산(squash — base..commit 전체 diff 가 한 커밋과 매칭).
64
+ 증명 실패는 not-merged — 자동 진행 금지는 호출자 계약이다."""
65
+ resolved = _resolve_commit_sha(project_root, commit)
66
+ cand = _resolve_commit_sha(project_root, candidate)
67
+ if not resolved or not cand:
68
+ return MatchResult("unresolvable")
69
+ if _git(project_root, "merge-base", "--is-ancestor", resolved, cand).returncode == 0:
70
+ return MatchResult("ancestor", matched_commit=resolved)
71
+ mb = _git(project_root, "merge-base", resolved, cand).stdout.strip()
72
+ if not mb:
73
+ return MatchResult("not-merged")
74
+ range_base = _resolve_commit_sha(project_root, base) or mb
75
+ cand_ids: Dict[str, str] = dict(_patch_ids_of_range(project_root, mb, cand))
76
+ stage_ids = _patch_ids_of_range(project_root, range_base, resolved)
77
+ if stage_ids and all(pid in cand_ids for pid, _ in stage_ids):
78
+ # git log 는 최신 커밋부터 출력 → stage_ids[0] 이 tip. matched_commit
79
+ # 은 재기록 대상이므로 tip 의 매칭 SHA 여야 한다.
80
+ return MatchResult("patch-equivalent",
81
+ matched_commit=cand_ids[stage_ids[0][0]])
82
+ whole = _patch_id_of_diff(project_root, range_base, resolved)
83
+ if whole and whole in cand_ids:
84
+ return MatchResult("patch-equivalent", matched_commit=cand_ids[whole])
85
+ return MatchResult("not-merged")
86
+
87
+
88
+ @dataclass
89
+ class StaleItem:
90
+ kind: str # "anchor" | "done"
91
+ stage: Optional[int]
92
+ recorded: str
93
+ classification: str # "ok" | "auto" | "confirm"
94
+ reason: str
95
+ suggested_commit: str = "" # auto 일 때 재기록 대상
96
+ impl_task_key: str = ""
97
+
98
+
99
+ def _classify_done_row(project_root: Path, stage: int, row: dict,
100
+ branch: str, stage_base: str) -> StaleItem:
101
+ recorded = row.get("head_commit", "")
102
+ item = StaleItem(kind="done", stage=stage, recorded=recorded,
103
+ classification="ok", reason="",
104
+ impl_task_key=row.get("impl_task_key", ""))
105
+ if not _resolve_commit_sha(project_root, recorded):
106
+ item.classification, item.reason = "confirm", "recorded SHA unresolvable"
107
+ return item
108
+ tip = _resolve_commit_sha(project_root, branch)
109
+ if not tip or tip == recorded:
110
+ return item # branch 없음(히스토리 intact) 또는 일치
111
+ if _git(project_root, "merge-base", "--is-ancestor",
112
+ recorded, tip).returncode == 0:
113
+ return item # 커밋이 단순히 더 쌓임 — stale 아님
114
+ match = content_merged(project_root, recorded, tip, base=stage_base)
115
+ if match.status in ("ancestor", "patch-equivalent"):
116
+ item.classification = "auto"
117
+ item.reason = f"branch {branch} rewritten, patch-equivalent"
118
+ item.suggested_commit = tip
119
+ else:
120
+ item.classification = "confirm"
121
+ item.reason = f"branch {branch} rewritten with content changes"
122
+ return item
123
+
124
+
125
+ def classify_task(*, project_root: Path, plan_run_root: Path,
126
+ project_id: str, task_group: str, task_id: str,
127
+ work_category: str) -> List[StaleItem]:
128
+ """task 의 anchor + 최신 done row 들을 ok/auto/confirm 으로 분류한다.
129
+ 다중 의존 gate(spec §3.2 표 5-6행)는 candidate 가 필요한 prepare 경로에서
130
+ content_merged 로 직접 평가된다 — 여기서 중복 평가하지 않는다."""
131
+ from . import worktree_registry as _reg
132
+ from .consumers import read_consumers, latest_done_by_stage
133
+ from .worktree import compute_branch_name
134
+
135
+ items: List[StaleItem] = []
136
+ anchor = _reg.get_implementation_base(project_id, task_group, task_id) or ""
137
+ if anchor:
138
+ ok = bool(_resolve_commit_sha(project_root, anchor))
139
+ items.append(StaleItem(
140
+ kind="anchor", stage=None, recorded=anchor,
141
+ classification="ok" if ok else "confirm",
142
+ reason="" if ok else "anchor SHA unresolvable",
143
+ ))
144
+ latest = latest_done_by_stage(read_consumers(plan_run_root))
145
+ for stage in sorted(latest):
146
+ row = latest[stage]
147
+ branch = compute_branch_name(
148
+ work_category=work_category, task_id_segment=task_id,
149
+ stage_number=stage,
150
+ )
151
+ srow = _reg.get_stage_row(project_id, task_group, task_id, stage) or {}
152
+ items.append(_classify_done_row(
153
+ project_root, stage, row, branch, srow.get("base_ref", "")))
154
+ return items
155
+
156
+
157
+ class ReconcileError(Exception):
158
+ pass
159
+
160
+
161
+ def _record_reconciled(plan_run_root: Path, *, impl_task_key: str, stage: int,
162
+ new_commit: str, replaced: str, reason: str) -> None:
163
+ from .consumers import append_consumer
164
+ append_consumer(
165
+ plan_run_root, impl_task_key=impl_task_key, stage=stage,
166
+ status="done", force_reappend=True, head_commit=new_commit,
167
+ reconciled=True, reconcile_reason=reason, replaced_commit=replaced,
168
+ )
169
+
170
+
171
+ def auto_reconcile(*, project_root: Path, plan_run_root: Path,
172
+ project_id: str, task_group: str, task_id: str,
173
+ work_category: str) -> List[StaleItem]:
174
+ """classify 의 auto 항목만 보정 row 로 재기록한다 — patch-id 증명이
175
+ 있으므로 확인 불필요(설계 §2 결정). confirm 은 건드리지 않는다."""
176
+ applied = []
177
+ items = classify_task(
178
+ project_root=project_root, plan_run_root=plan_run_root,
179
+ project_id=project_id, task_group=task_group, task_id=task_id,
180
+ work_category=work_category)
181
+ for item in items:
182
+ if item.classification != "auto" or item.kind != "done":
183
+ continue
184
+ _record_reconciled(plan_run_root, impl_task_key=item.impl_task_key,
185
+ stage=item.stage, new_commit=item.suggested_commit,
186
+ replaced=item.recorded, reason="auto-patch-id")
187
+ applied.append(item)
188
+ return applied
189
+
190
+
191
+ def guidance(*, plan_run_root: Path, project_id: str, task_group: str,
192
+ task_id: str, work_category: str) -> str:
193
+ """PrepareError 에 첨부하는 회복 안내 (명령 예시 포함)."""
194
+ base = (f"okstra git-reconcile --plan-run-root {plan_run_root} "
195
+ f"--project-id {project_id} --task-group {task_group} "
196
+ f"--task-id {task_id} --work-category {work_category}")
197
+ return ("Recorded stage SHAs no longer match the git history "
198
+ "(external rebase/squash/amend?). Inspect and reconcile:\n"
199
+ f" {base} --check --json\n"
200
+ f" {base} --apply --stage <N> --use-ref <branch|sha>")
201
+
202
+
203
+ def _apply_user_ref(project_root: Path, plan_run_root: Path,
204
+ latest: Dict[int, dict], stage: Optional[int],
205
+ use_ref: str) -> dict:
206
+ if stage is None:
207
+ raise ReconcileError("--use-ref requires --stage")
208
+ sha = _resolve_commit_sha(project_root, use_ref)
209
+ if not sha:
210
+ raise ReconcileError(f"could not resolve ref `{use_ref}`")
211
+ row = latest.get(stage)
212
+ if not row:
213
+ raise ReconcileError(f"stage {stage} has no done row to reconcile")
214
+ _record_reconciled(plan_run_root, impl_task_key=row.get("impl_task_key", ""),
215
+ stage=stage, new_commit=sha,
216
+ replaced=row.get("head_commit", ""), reason="user-ref")
217
+ return {"stage": stage, "new_commit": sha}
218
+
219
+
220
+ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
221
+ project_id: str, task_group: str, task_id: str,
222
+ work_category: str, stage: Optional[int] = None,
223
+ use_ref: str = "", reset_anchor: str = "") -> dict:
224
+ """auto 항목 일괄 보정 + (옵션) confirm 항목 1건 보정 + (옵션) anchor 재고정.
225
+
226
+ enforcement(spec §3.6): confirm 항목은 `use_ref` 가 명시된 그 stage 만
227
+ 보정된다 — 어떤 경로로도 무확인 자동 보정되지 않는다.
228
+
229
+ classify 와 보정 append 사이는 원자적이지 않다 — 동시 run 이 활발한
230
+ 시점이 아니라 사용자 보정(수동) 시점에 호출되는 것을 전제한다."""
231
+ from . import worktree_registry as _reg
232
+ from .consumers import read_consumers, latest_done_by_stage
233
+
234
+ applied: List[dict] = []
235
+ if reset_anchor:
236
+ sha = _resolve_commit_sha(project_root, reset_anchor)
237
+ if not sha:
238
+ raise ReconcileError(f"could not resolve ref `{reset_anchor}`")
239
+ _reg.reset_implementation_base(project_id, task_group, task_id, sha)
240
+ applied.append({"anchor": sha})
241
+ if use_ref:
242
+ latest = latest_done_by_stage(read_consumers(plan_run_root))
243
+ applied.append(_apply_user_ref(
244
+ project_root, plan_run_root, latest, stage, use_ref))
245
+ items = classify_task(
246
+ project_root=project_root, plan_run_root=plan_run_root,
247
+ project_id=project_id, task_group=task_group, task_id=task_id,
248
+ work_category=work_category)
249
+ for item in items:
250
+ if item.classification != "auto":
251
+ continue
252
+ if use_ref and item.stage == stage:
253
+ continue # 사용자가 명시 보정한 stage 는 auto 가 덮지 않는다
254
+ _record_reconciled(plan_run_root, impl_task_key=item.impl_task_key,
255
+ stage=item.stage, new_commit=item.suggested_commit,
256
+ replaced=item.recorded, reason="auto-patch-id")
257
+ applied.append({"stage": item.stage, "new_commit": item.suggested_commit})
258
+ remaining = [i for i in items if i.classification == "confirm"
259
+ and not (use_ref and i.stage == stage)]
260
+ return {"applied": applied, "remaining_confirm": remaining}
261
+
262
+
263
+ def _items_as_json(items: List[StaleItem]) -> list:
264
+ return [{"kind": i.kind, "stage": i.stage, "recorded": i.recorded,
265
+ "classification": i.classification, "reason": i.reason,
266
+ "suggested_commit": i.suggested_commit} for i in items]
267
+
268
+
269
+ def main(argv: Optional[list] = None) -> int:
270
+ import argparse
271
+ import json as _json
272
+
273
+ p = argparse.ArgumentParser(
274
+ prog="okstra git-reconcile",
275
+ epilog="exit codes: 0 = clean/applied, 2 = confirm items remain, 1 = error")
276
+ p.add_argument("--project-root", default=".")
277
+ p.add_argument("--plan-run-root", required=True,
278
+ help="consumers.jsonl 이 있는 runs/implementation-planning/ 경로")
279
+ p.add_argument("--project-id", required=True)
280
+ p.add_argument("--task-group", required=True)
281
+ p.add_argument("--task-id", required=True)
282
+ p.add_argument("--work-category", required=True)
283
+ p.add_argument("--check", action="store_true",
284
+ help="stale 검사만 (기본 동작과 동일 — 명시용)")
285
+ p.add_argument("--apply", action="store_true",
286
+ help="auto 항목 일괄 보정 (+ --stage/--use-ref, --reset-anchor)")
287
+ p.add_argument("--stage", type=int)
288
+ p.add_argument("--use-ref", default="")
289
+ p.add_argument("--reset-anchor", default="")
290
+ p.add_argument("--json", action="store_true")
291
+ a = p.parse_args(argv)
292
+ if a.check and a.apply:
293
+ p.error("--check and --apply are mutually exclusive")
294
+ if a.apply and a.stage is not None and not a.use_ref:
295
+ p.error("--stage requires --use-ref (confirm 항목은 명시 ref 로만 보정된다)")
296
+
297
+ kw = dict(project_root=Path(a.project_root).resolve(),
298
+ plan_run_root=Path(a.plan_run_root).resolve(),
299
+ project_id=a.project_id, task_group=a.task_group,
300
+ task_id=a.task_id, work_category=a.work_category)
301
+ try:
302
+ if a.apply:
303
+ result = apply_reconcile(**kw, stage=a.stage, use_ref=a.use_ref,
304
+ reset_anchor=a.reset_anchor)
305
+ out = {"applied": result["applied"],
306
+ "remaining_confirm": _items_as_json(result["remaining_confirm"])}
307
+ print(_json.dumps(out, ensure_ascii=False,
308
+ indent=None if a.json else 2))
309
+ return 2 if result["remaining_confirm"] else 0
310
+ items = classify_task(**kw)
311
+ stale = [i for i in items if i.classification != "ok"]
312
+ print(_json.dumps({"items": _items_as_json(stale)}, ensure_ascii=False,
313
+ indent=None if a.json else 2))
314
+ return 2 if any(i.classification == "confirm" for i in stale) else 0
315
+ except ReconcileError as exc:
316
+ print(_json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False,
317
+ indent=None if a.json else 2))
318
+ return 1
319
+
320
+
321
+ if __name__ == "__main__":
322
+ raise SystemExit(main())
@@ -0,0 +1,348 @@
1
+ """release-handoff stage-group 모드의 강제 지점.
2
+
3
+ 자격 판정(eligible) · 수집 브랜치 생성+머지(assemble) · verified/pr 행 기록을
4
+ 단일 모듈로 강제한다. lead 는 `okstra handoff <sub>` 로 호출만 한다.
5
+ 설계: docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, List, Optional, Tuple
14
+
15
+ from . import consumers, worktree_registry
16
+ from .worktree import compute_branch_name, compute_worktree_path
17
+
18
+
19
+ class HandoffError(Exception):
20
+ """자격/전제 위반 — exit 1, actionable 메시지."""
21
+
22
+
23
+ class HandoffConflict(Exception):
24
+ """stage 간 merge 충돌 — exit 2, 충돌 경로 동봉."""
25
+
26
+ def __init__(self, stage: int, paths: List[str]):
27
+ self.stage = stage
28
+ self.paths = paths
29
+ super().__init__(
30
+ f"merge conflict while merging stage {stage}: {', '.join(paths)}")
31
+
32
+
33
+ def group_id_for(stages: List[int]) -> str:
34
+ return "g" + "-".join(str(n) for n in sorted(stages))
35
+
36
+
37
+ def compute_eligibility(stage_map: List[Dict[str, Any]],
38
+ rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
39
+ """stage 별 PR 가능 여부와 차단 사유. 의존 폐포는 선택 집합의 속성이라
40
+ 여기서는 판정하지 않는다 (assemble 의 check_dependency_closure 가 담당)."""
41
+ done = consumers.latest_done_by_stage(rows)
42
+ verified = consumers.verified_accepted_stages(rows)
43
+ in_pr = consumers.pr_covered_stages(rows)
44
+ out = []
45
+ for s in stage_map:
46
+ n = s["stage_number"]
47
+ reasons = []
48
+ if n in in_pr:
49
+ reasons.append("already-in-pr")
50
+ else:
51
+ if n not in done:
52
+ reasons.append("not-done")
53
+ if n not in verified:
54
+ reasons.append("not-verified-accepted")
55
+ out.append({
56
+ "stage": n,
57
+ "depends_on": list(s["depends_on"]),
58
+ "eligible": not reasons,
59
+ "reasons": reasons,
60
+ "head_commit": (done.get(n) or {}).get("head_commit", ""),
61
+ })
62
+ return out
63
+
64
+
65
+ def check_dependency_closure(
66
+ selected: List[int],
67
+ stage_map: List[Dict[str, Any]],
68
+ done_by_stage: Dict[int, Dict[str, Any]],
69
+ is_merged_to_base: Callable[[str], bool],
70
+ ) -> List[Tuple[int, int]]:
71
+ """선택 집합의 의존 폐포 위반 목록 [(stage, 미충족 선행)].
72
+ 선행이 같은 그룹에 선택됐거나 이미 base 에 머지된 경우만 허용."""
73
+ sel = set(selected)
74
+ by_n = {s["stage_number"]: s for s in stage_map}
75
+ violations = []
76
+ for n in sorted(selected):
77
+ for d in by_n[n]["depends_on"]:
78
+ if d in sel:
79
+ continue
80
+ head = (done_by_stage.get(d) or {}).get("head_commit", "")
81
+ if not head or not is_merged_to_base(head):
82
+ violations.append((n, d))
83
+ return violations
84
+
85
+
86
+ def _run_git(args: List[str], cwd, check: bool = True) -> subprocess.CompletedProcess:
87
+ r = subprocess.run(["git", *args], cwd=str(cwd),
88
+ capture_output=True, text=True)
89
+ if check and r.returncode != 0:
90
+ raise HandoffError(f"git {' '.join(args)} failed: {r.stderr.strip()}")
91
+ return r
92
+
93
+
94
+ def _require_eligible(stage_map, rows, stages) -> Dict[int, Dict[str, Any]]:
95
+ elig = {e["stage"]: e for e in compute_eligibility(stage_map, rows)}
96
+ unknown = [n for n in stages if n not in elig]
97
+ if unknown:
98
+ raise HandoffError(f"stages not in Stage Map: {unknown}")
99
+ bad = {n: elig[n]["reasons"] for n in stages if elig[n]["reasons"]}
100
+ if bad:
101
+ raise HandoffError(f"stages not eligible: {bad}")
102
+ return elig
103
+
104
+
105
+ def _require_closure(stages, stage_map, done, project_root, base_branch) -> None:
106
+ def merged(commit: str) -> bool:
107
+ return _run_git(["merge-base", "--is-ancestor", commit,
108
+ f"origin/{base_branch}"], project_root,
109
+ check=False).returncode == 0
110
+ violations = check_dependency_closure(stages, stage_map, done, merged)
111
+ if violations:
112
+ lines = [
113
+ f"stage {n} depends on stage {d} which is neither selected nor "
114
+ f"merged into origin/{base_branch} — include stage {d} in the "
115
+ "group or PR-merge it first"
116
+ for n, d in violations
117
+ ]
118
+ raise HandoffError("dependency closure violated:\n" + "\n".join(lines))
119
+
120
+
121
+ def _reuse_existing(entry, stages, done, project_root) -> Dict[str, Any]:
122
+ head = _run_git(["rev-parse", entry.branch], project_root).stdout.strip()
123
+ for n in stages:
124
+ h = (done.get(n) or {}).get("head_commit", "")
125
+ if _run_git(["merge-base", "--is-ancestor", h, head], project_root,
126
+ check=False).returncode != 0:
127
+ raise HandoffError(
128
+ f"collector branch {entry.branch} exists but stage {n} head "
129
+ f"{h} is not merged into it — remove the branch/worktree and "
130
+ "the registry group key, then retry")
131
+ return {"ok": True, "reused": True, "group_id": group_id_for(stages),
132
+ "branch": entry.branch, "worktree_path": entry.worktree_path,
133
+ "head": head, "stages": sorted(stages), "merge_commits": []}
134
+
135
+
136
+ def _cleanup_group(project_root, wt_path, branch, project_id, task_group,
137
+ task_id, gid) -> None:
138
+ _run_git(["worktree", "remove", "--force", str(wt_path)], project_root,
139
+ check=False)
140
+ _run_git(["branch", "-D", branch], project_root, check=False)
141
+ worktree_registry.release(project_id, task_group, task_id, group_id=gid)
142
+
143
+
144
+ def _merge_stages(wt_path, stages, work_category, task_id, project_root,
145
+ project_id, task_group, gid, branch) -> List[str]:
146
+ merge_commits = []
147
+ for n in sorted(stages):
148
+ stage_branch = compute_branch_name(
149
+ work_category=work_category, task_id_segment=task_id,
150
+ stage_number=n)
151
+ r = _run_git(["merge", "--no-edit", stage_branch], wt_path, check=False)
152
+ if r.returncode != 0:
153
+ paths = _run_git(["diff", "--name-only", "--diff-filter=U"],
154
+ wt_path).stdout.split()
155
+ _run_git(["merge", "--abort"], wt_path, check=False)
156
+ _cleanup_group(project_root, wt_path, branch, project_id,
157
+ task_group, task_id, gid)
158
+ raise HandoffConflict(stage=n, paths=paths)
159
+ merge_commits.append(
160
+ _run_git(["rev-parse", "HEAD"], wt_path).stdout.strip())
161
+ return merge_commits
162
+
163
+
164
+ def assemble(*, project_root, plan_run_root, stage_map, stages, base_branch,
165
+ work_category, project_id, task_group, task_id) -> Dict[str, Any]:
166
+ """수집 브랜치를 만들고 선택 stage 브랜치들을 머지한다. 멱등."""
167
+ stages = sorted(set(stages))
168
+ rows = consumers.read_consumers(Path(plan_run_root))
169
+ _require_eligible(stage_map, rows, stages)
170
+ _run_git(["fetch", "origin", base_branch], project_root)
171
+ done = consumers.latest_done_by_stage(rows)
172
+ _require_closure(stages, stage_map, done, project_root, base_branch)
173
+
174
+ base_commit = worktree_registry.get_implementation_base(
175
+ project_id, task_group, task_id)
176
+ if not base_commit:
177
+ raise HandoffError(
178
+ "implementation_base_commit not recorded in worktree registry — "
179
+ "run at least one implementation stage first")
180
+
181
+ gid = group_id_for(stages)
182
+ existing = worktree_registry.lookup(
183
+ project_id, task_group, task_id, group_id=gid)
184
+ if existing and existing.status == "active":
185
+ return _reuse_existing(existing, stages, done, project_root)
186
+
187
+ branch = compute_branch_name(work_category=work_category,
188
+ task_id_segment=task_id, group_id=gid)
189
+ wt_path = compute_worktree_path(
190
+ project_id=project_id, task_group_segment=task_group,
191
+ task_id_segment=task_id, group_id=gid)
192
+ worktree_registry.reserve(
193
+ project_id=project_id, task_group=task_group, task_id=task_id,
194
+ worktree_path=str(wt_path), branch=branch, base_ref=base_commit,
195
+ phase="release-handoff", group_id=gid, stages=stages)
196
+ try:
197
+ _run_git(["worktree", "add", "-b", branch, str(wt_path), base_commit],
198
+ project_root)
199
+ merge_commits = _merge_stages(wt_path, stages, work_category, task_id,
200
+ project_root, project_id, task_group, gid,
201
+ branch)
202
+ except HandoffConflict:
203
+ raise # _merge_stages 가 이미 _cleanup_group 으로 예약·worktree 를 되돌림
204
+ except Exception:
205
+ # worktree add 실패 등 비-충돌 경로 — 예약이 orphan 으로 남지 않도록 역전
206
+ _cleanup_group(project_root, wt_path, branch, project_id, task_group,
207
+ task_id, gid)
208
+ raise
209
+ head = merge_commits[-1] if merge_commits else base_commit
210
+ return {"ok": True, "reused": False, "group_id": gid, "branch": branch,
211
+ "worktree_path": str(wt_path), "head": head, "stages": stages,
212
+ "merge_commits": merge_commits}
213
+
214
+
215
+ def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
216
+ done = consumers.latest_done_by_stage(rows)
217
+ row = done.get(stage)
218
+ if not row:
219
+ raise HandoffError(
220
+ f"stage {stage} has no done row in consumers.jsonl — "
221
+ "finish the implementation stage first")
222
+ return row.get("impl_task_key", "")
223
+
224
+
225
+ def record_verified(*, plan_run_root, stage: int, report_path: str,
226
+ data_json) -> Dict[str, Any]:
227
+ """단독-stage accepted 만 기록. data.json 의 taskType/scope/verdict 를 검증해
228
+ lead 가 임의 보고서를 verified 로 올리는 것을 막는다."""
229
+ try:
230
+ data = json.loads(Path(data_json).read_text(encoding="utf-8"))
231
+ except (OSError, json.JSONDecodeError) as exc:
232
+ raise HandoffError(f"cannot read final-report data.json: {exc}")
233
+ if (data.get("header") or {}).get("taskType") != "final-verification":
234
+ raise HandoffError("data.json is not a final-verification report")
235
+ scope = data.get("verificationScope")
236
+ if scope != "single-stage":
237
+ raise HandoffError(
238
+ f"record-verified requires verificationScope single-stage, "
239
+ f"got {scope!r}")
240
+ token = ((data.get("finalVerdict") or {}).get("verdictToken")
241
+ or "").strip().lower()
242
+ if token != "accepted":
243
+ raise HandoffError(f"verdict token must be `accepted`, got {token!r}")
244
+ rows = consumers.read_consumers(Path(plan_run_root))
245
+ key = _impl_task_key_for(rows, stage)
246
+ consumers.append_verified(Path(plan_run_root), impl_task_key=key,
247
+ stage=stage, verdict="accepted",
248
+ report_path=report_path)
249
+ return {"ok": True, "stage": stage, "report_path": report_path}
250
+
251
+
252
+ def record_pr(*, plan_run_root, stages: List[int], branch: str,
253
+ url: str) -> Dict[str, Any]:
254
+ if not stages:
255
+ raise HandoffError("record-pr requires at least one stage")
256
+ rows = consumers.read_consumers(Path(plan_run_root))
257
+ key = _impl_task_key_for(rows, sorted(stages)[0])
258
+ consumers.append_pr(Path(plan_run_root), impl_task_key=key,
259
+ stages=stages, branch=branch, url=url)
260
+ return {"ok": True, "stages": sorted(stages), "branch": branch, "url": url}
261
+
262
+
263
+ def _parse_stages_csv(raw: str) -> List[int]:
264
+ try:
265
+ out = sorted({int(x) for x in raw.split(",") if x.strip()})
266
+ except ValueError:
267
+ raise HandoffError(f"--stages must be a comma-separated int list, got {raw!r}")
268
+ if not out:
269
+ raise HandoffError("--stages must select at least one stage")
270
+ return out
271
+
272
+
273
+ def main(argv: Optional[list] = None) -> int:
274
+ import argparse
275
+
276
+ from .run import _parse_stage_map_into_ctx
277
+
278
+ p = argparse.ArgumentParser(prog="okstra handoff")
279
+ sub = p.add_subparsers(dest="cmd", required=True)
280
+
281
+ def common(sp, *, plan=True):
282
+ if plan:
283
+ sp.add_argument("--plan-run-root", required=True)
284
+
285
+ sp = sub.add_parser("eligible")
286
+ common(sp)
287
+ sp.add_argument("--approved-plan", required=True)
288
+
289
+ sp = sub.add_parser("assemble")
290
+ common(sp)
291
+ sp.add_argument("--approved-plan", required=True)
292
+ sp.add_argument("--project-root", default=".")
293
+ sp.add_argument("--project-id", required=True)
294
+ sp.add_argument("--task-group", required=True)
295
+ sp.add_argument("--task-id", required=True)
296
+ sp.add_argument("--work-category", required=True)
297
+ sp.add_argument("--stages", required=True)
298
+ sp.add_argument("--base", required=True)
299
+
300
+ sp = sub.add_parser("record-verified")
301
+ common(sp)
302
+ sp.add_argument("--stage", type=int, required=True)
303
+ sp.add_argument("--report-path", required=True)
304
+ sp.add_argument("--data-json", required=True)
305
+
306
+ sp = sub.add_parser("record-pr")
307
+ common(sp)
308
+ sp.add_argument("--stages", required=True)
309
+ sp.add_argument("--branch", required=True)
310
+ sp.add_argument("--url", required=True)
311
+
312
+ a = p.parse_args(argv)
313
+ try:
314
+ if a.cmd == "eligible":
315
+ stage_map = _parse_stage_map_into_ctx(a.approved_plan)
316
+ rows = consumers.read_consumers(Path(a.plan_run_root))
317
+ out = {"stages": compute_eligibility(stage_map, rows)}
318
+ elif a.cmd == "assemble":
319
+ out = assemble(
320
+ project_root=Path(a.project_root).resolve(),
321
+ plan_run_root=Path(a.plan_run_root),
322
+ stage_map=_parse_stage_map_into_ctx(a.approved_plan),
323
+ stages=_parse_stages_csv(a.stages), base_branch=a.base,
324
+ work_category=a.work_category, project_id=a.project_id,
325
+ task_group=a.task_group, task_id=a.task_id)
326
+ elif a.cmd == "record-verified":
327
+ out = record_verified(plan_run_root=Path(a.plan_run_root),
328
+ stage=a.stage, report_path=a.report_path,
329
+ data_json=a.data_json)
330
+ else:
331
+ out = record_pr(plan_run_root=Path(a.plan_run_root),
332
+ stages=_parse_stages_csv(a.stages),
333
+ branch=a.branch, url=a.url)
334
+ except HandoffConflict as exc:
335
+ print(json.dumps({"error": str(exc), "stage": exc.stage,
336
+ "conflicts": exc.paths}, ensure_ascii=False))
337
+ return 2
338
+ except HandoffError as exc:
339
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False))
340
+ return 1
341
+ print(json.dumps(out, ensure_ascii=False, indent=2))
342
+ return 0
343
+
344
+
345
+ if __name__ == "__main__":
346
+ import sys as _sys
347
+
348
+ _sys.exit(main())