okstra 0.66.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 +14 -4
  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 +141 -36
  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 +45 -5
  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
@@ -3,10 +3,10 @@
3
3
  "locale": "ko",
4
4
  "steps": {
5
5
  "task_pick": {
6
- "label": "어느 task?",
6
+ "label": "어느 task? (남은 작업 최신순 추천)",
7
7
  "echo_template": "task: {value}",
8
8
  "options": {
9
- "__new__": "Start a brand-new task",
9
+ "__new__": "직접 입력 (새 작업 또는 목록에 없는 task)",
10
10
  "_LATEST_SUFFIX": " (latest)"
11
11
  }
12
12
  },
@@ -80,9 +80,19 @@
80
80
  "label": "Task type?",
81
81
  "echo_template": "task-type: {value}",
82
82
  "options": {
83
- "_RECOMMENDED_SUFFIX": " (recommended)"
83
+ "_RECOMMENDED_SUFFIX": " (recommended)",
84
+ "_RERUN_SUFFIX": " (현재 phase 재실행)",
85
+ "_NEXT_SUFFIX": " (다음 단계)",
86
+ "__free_input__": "직접 입력"
87
+ },
88
+ "echo_variants": {
89
+ "free_input": "task-type: (직접 입력)"
84
90
  }
85
91
  },
92
+ "task_type_text": {
93
+ "label": "Task type? (입력 가능: requirements-discovery, improvement-discovery, error-analysis, implementation-planning, implementation, final-verification, release-handoff)",
94
+ "echo_template": "task-type: {value}"
95
+ },
86
96
  "brief_keep": {
87
97
  "label": "기존 brief 경로 [{existing_brief_path}] 를 유지할까요?",
88
98
  "echo_template": "brief: {value}",
@@ -129,7 +139,7 @@
129
139
  "in_worktree": "현재 worktree(`{path}`)에서 그대로 진행합니다(이미 non-main worktree) — 진행할까요?",
130
140
  "not_git": "git 저장소가 아니므로 `{path}` 에서 직접 진행합니다 — 진행할까요?"
131
141
  },
132
- "options": { "proceed": "진행", "edit": "base-ref 다시 고르기" },
142
+ "options": { "proceed": "진행", "edit": "base-ref 다시 고르기", "abort": "중단" },
133
143
  "echo_template": "branch-confirm: {value}"
134
144
  },
135
145
  "base_ref_text": {
@@ -2,7 +2,8 @@
2
2
 
3
3
  A row's identity for idempotency is the tuple
4
4
  (impl_task_key, stage, status)
5
- so the same (started / done) record is never duplicated."""
5
+ so the same (started / done) record is never duplicated.
6
+ force_reappend=True 인 보정 append 만 같은 tuple 을 다른 head_commit 으로 재기록할 수 있다."""
6
7
 
7
8
  from __future__ import annotations
8
9
 
@@ -33,8 +34,19 @@ def read_consumers(plan_run_root: Path) -> List[Dict[str, Any]]:
33
34
  return out
34
35
 
35
36
 
37
+ def latest_done_by_stage(rows: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
38
+ """stage → 마지막 done row. 보정(reconciled) row 가 같은 stage 에
39
+ 재-append 되므로 done 읽기의 유일한 의미는 last-wins 다."""
40
+ out: Dict[int, Dict[str, Any]] = {}
41
+ for r in rows:
42
+ if r.get("status") == "done" and isinstance(r.get("stage"), int):
43
+ out[r["stage"]] = r
44
+ return out
45
+
46
+
36
47
  def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
37
- status: str, **fields: Any) -> None:
48
+ status: str, force_reappend: bool = False,
49
+ **fields: Any) -> None:
38
50
  if status not in ("started", "done"):
39
51
  raise ValueError(f"status must be 'started' or 'done', got: {status!r}")
40
52
  with consumers_mutex(plan_run_root):
@@ -43,15 +55,70 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
43
55
  if (row.get("impl_task_key") == impl_task_key
44
56
  and row.get("stage") == stage
45
57
  and row.get("status") == status):
46
- return # idempotent
58
+ if not force_reappend:
59
+ return # idempotent
60
+ if row.get("head_commit") == fields.get("head_commit"):
61
+ return # 동일 보정의 중복 재-append 방지
47
62
  record: Dict[str, Any] = {
48
63
  "impl_task_key": impl_task_key,
49
64
  "stage": stage,
50
65
  "status": status,
51
66
  **fields,
52
67
  }
53
- with _path(plan_run_root).open("a", encoding="utf-8") as f:
54
- f.write(json.dumps(record, ensure_ascii=False) + "\n")
68
+ _append_row(plan_run_root, record)
69
+
70
+
71
+ def _append_row(plan_run_root: Path, record: Dict[str, Any]) -> None:
72
+ with _path(plan_run_root).open("a", encoding="utf-8") as f:
73
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
74
+
75
+
76
+ def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
77
+ verdict: str, report_path: str) -> None:
78
+ """단독-stage final-verification 결과 기록. 같은 report_path 재기록은 멱등,
79
+ 다른 report_path 는 재검증으로 append 한다 (읽기는 last-wins)."""
80
+ with consumers_mutex(plan_run_root):
81
+ for row in read_consumers(plan_run_root):
82
+ if (row.get("impl_task_key") == impl_task_key
83
+ and row.get("stage") == stage
84
+ and row.get("status") == "verified"
85
+ and row.get("report_path") == report_path):
86
+ return
87
+ _append_row(plan_run_root, {
88
+ "impl_task_key": impl_task_key, "stage": stage,
89
+ "status": "verified", "verdict": verdict,
90
+ "report_path": report_path,
91
+ })
92
+
93
+
94
+ def append_pr(plan_run_root: Path, *, impl_task_key: str, stages: List[int],
95
+ branch: str, url: str) -> None:
96
+ """handoff 의 PR 생성/재사용 기록. 같은 branch 의 pr 행이 이미 있으면 멱등."""
97
+ with consumers_mutex(plan_run_root):
98
+ for row in read_consumers(plan_run_root):
99
+ if row.get("status") == "pr" and row.get("branch") == branch:
100
+ return
101
+ _append_row(plan_run_root, {
102
+ "impl_task_key": impl_task_key, "stages": sorted(stages),
103
+ "status": "pr", "branch": branch, "url": url,
104
+ })
105
+
106
+
107
+ def verified_accepted_stages(rows: List[Dict[str, Any]]) -> set:
108
+ """stage → 마지막 verified 행의 verdict 가 accepted 인 stage 집합 (last-wins)."""
109
+ last: Dict[int, str] = {}
110
+ for r in rows:
111
+ if r.get("status") == "verified" and isinstance(r.get("stage"), int):
112
+ last[r["stage"]] = (r.get("verdict") or "").strip().lower()
113
+ return {n for n, v in last.items() if v == "accepted"}
114
+
115
+
116
+ def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
117
+ out: set = set()
118
+ for r in rows:
119
+ if r.get("status") == "pr":
120
+ out.update(n for n in (r.get("stages") or []) if isinstance(n, int))
121
+ return out
55
122
 
56
123
 
57
124
  # --- carry-as-SSOT done recovery ---------------------------------------------
@@ -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())