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.
- package/bin/okstra +25 -0
- package/docs/kr/architecture.md +17 -1
- package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
- package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
- package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
- package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
- package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
- package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +8 -7
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/gemini-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +5 -5
- package/runtime/python/okstra_ctl/conformance.py +17 -0
- package/runtime/python/okstra_ctl/consumers.py +72 -5
- package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
- package/runtime/python/okstra_ctl/handoff.py +348 -0
- package/runtime/python/okstra_ctl/render.py +44 -2
- package/runtime/python/okstra_ctl/run.py +175 -44
- package/runtime/python/okstra_ctl/wizard.py +89 -22
- package/runtime/python/okstra_ctl/worktree.py +28 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/python/okstra_token_usage/collect.py +27 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +8 -8
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -7
- package/runtime/validators/validate-run.py +51 -11
- package/src/_python-helper.mjs +52 -0
- package/src/error-log.mjs +19 -0
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
- package/src/inject-report-index.mjs +22 -0
- package/src/render-final-report.mjs +22 -0
- package/src/render-views.mjs +9 -48
- package/src/spawn-followups.mjs +23 -0
- 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())
|