okstra 0.96.1 → 0.97.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 (121) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -1
  4. package/docs/contributor-change-matrix.md +1 -1
  5. package/docs/kr/architecture/storage-model.md +273 -0
  6. package/docs/kr/architecture.md +6 -277
  7. package/docs/kr/cli.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
  10. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
  11. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
  12. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
  14. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
  15. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
  16. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
  17. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
  18. package/docs/task-process/release-handoff.md +3 -3
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/bin/lib/okstra/cli.sh +1 -1
  22. package/runtime/bin/lib/okstra/interactive.sh +22 -4
  23. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
  24. package/runtime/bin/okstra-render-final-report.py +4 -15
  25. package/runtime/bin/okstra.sh +5 -2
  26. package/runtime/prompts/launch.template.md +1 -0
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/final-verification.md +1 -0
  30. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  31. package/runtime/prompts/profiles/release-handoff.md +10 -4
  32. package/runtime/python/okstra_ctl/__init__.py +3 -2
  33. package/runtime/python/okstra_ctl/backfill.py +12 -21
  34. package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
  35. package/runtime/python/okstra_ctl/conformance.py +21 -3
  36. package/runtime/python/okstra_ctl/consumers.py +7 -1
  37. package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
  38. package/runtime/python/okstra_ctl/doctor.py +29 -12
  39. package/runtime/python/okstra_ctl/error_log_core.py +72 -0
  40. package/runtime/python/okstra_ctl/error_report.py +8 -58
  41. package/runtime/python/okstra_ctl/error_zip.py +319 -0
  42. package/runtime/python/okstra_ctl/fanout.py +5 -2
  43. package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
  44. package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
  45. package/runtime/python/okstra_ctl/handoff.py +125 -26
  46. package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
  47. package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
  48. package/runtime/python/okstra_ctl/index.py +34 -91
  49. package/runtime/python/okstra_ctl/jsonl.py +29 -18
  50. package/runtime/python/okstra_ctl/listing.py +30 -5
  51. package/runtime/python/okstra_ctl/md_table.py +1 -1
  52. package/runtime/python/okstra_ctl/migrate.py +17 -7
  53. package/runtime/python/okstra_ctl/paths.py +1 -1
  54. package/runtime/python/okstra_ctl/recap.py +12 -3
  55. package/runtime/python/okstra_ctl/reconcile.py +13 -11
  56. package/runtime/python/okstra_ctl/render.py +49 -12
  57. package/runtime/python/okstra_ctl/render_final_report.py +4 -0
  58. package/runtime/python/okstra_ctl/report_views.py +47 -6
  59. package/runtime/python/okstra_ctl/run.py +128 -100
  60. package/runtime/python/okstra_ctl/run_index_row.py +118 -0
  61. package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
  62. package/runtime/python/okstra_ctl/sequence.py +2 -2
  63. package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
  64. package/runtime/python/okstra_ctl/stage_targets.py +2 -8
  65. package/runtime/python/okstra_ctl/task_target.py +4 -1
  66. package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
  67. package/runtime/python/okstra_ctl/wizard.py +27 -12
  68. package/runtime/python/okstra_ctl/workflow.py +9 -1
  69. package/runtime/python/okstra_ctl/worktree.py +93 -4
  70. package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
  71. package/runtime/python/okstra_token_usage/collect.py +43 -38
  72. package/runtime/skills/okstra-brief/SKILL.md +43 -12
  73. package/runtime/skills/okstra-inspect/SKILL.md +44 -1
  74. package/runtime/templates/reports/final-report.template.md +2 -2
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/validators/forbidden_actions.py +8 -1
  77. package/runtime/validators/validate-brief.py +13 -2
  78. package/runtime/validators/validate-run.py +27 -6
  79. package/runtime/validators/validate-schedule.py +1 -2
  80. package/runtime/validators/validate_improvement_report.py +14 -0
  81. package/src/cli-registry.mjs +45 -31
  82. package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
  83. package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
  84. package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
  85. package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
  86. package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
  87. package/src/commands/execute/integrate-stages.mjs +25 -0
  88. package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
  89. package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
  90. package/src/{run.mjs → commands/execute/run.mjs} +4 -4
  91. package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
  92. package/src/{team.mjs → commands/execute/team.mjs} +3 -3
  93. package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
  94. package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
  95. package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
  96. package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
  97. package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
  98. package/src/commands/inspect/error-zip.mjs +25 -0
  99. package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
  100. package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
  101. package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
  102. package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
  103. package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
  104. package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
  105. package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
  106. package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
  107. package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
  108. package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
  109. package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
  110. package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
  111. package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
  112. package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
  113. package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
  114. package/src/lib/paths.mjs +60 -0
  115. package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
  116. package/src/{version.mjs → lib/version.mjs} +2 -2
  117. /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
  118. /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
  119. /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
  120. /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
  121. /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
@@ -0,0 +1,193 @@
1
+ """whole-task stage 통합(머지) + worktree teardown 코어.
2
+
3
+ Phase A: stage done commit 을 task 브랜치에 위상순 --no-ff 머지(이미 머지면
4
+ skip, 충돌이면 해당 머지만 abort 후 IntegrateError). Phase B: 머지/skip 된
5
+ stage 의 worktree·registry 키·stage 브랜치를 정리(dirty 면 skip).
6
+ 설계: docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .worktree import _git
15
+
16
+
17
+ class IntegrateError(Exception):
18
+ """stage 머지 충돌 등으로 통합을 중단해야 함."""
19
+
20
+
21
+ @dataclass
22
+ class IntegrateResult:
23
+ merged: list[int] = field(default_factory=list)
24
+ already_merged: list[int] = field(default_factory=list)
25
+ torn_down: list[int] = field(default_factory=list)
26
+ teardown_skipped: list[tuple[int, str]] = field(default_factory=list)
27
+ warnings: list[str] = field(default_factory=list)
28
+
29
+
30
+ def _merge_stage(task_wt: str, stage_n: int, branch: str, done_commit: str) -> str:
31
+ """한 stage 의 done commit 을 task 브랜치에 머지. 'merged'|'already_merged'.
32
+ 충돌 시 abort 후 IntegrateError.
33
+
34
+ already_merged 판정과 실제 머지 대상을 모두 done_commit 으로 통일한다 —
35
+ consumers 의 done.head_commit 은 reconcile 로 `-sN` 브랜치 tip 과 다른
36
+ commit 일 수 있어, ancestor 는 done_commit 으로 보고 머지는 브랜치 ref 로
37
+ 하면 tip 이 아직 안 머지됐는데 skip 하거나 이미 머지된 변경을 중복 재도입할
38
+ 수 있다. branch 는 정리(teardown) 식별용으로만 쓰고 머지 대상은 아니다."""
39
+ from .worktree import MergeError, is_ancestor, merge_branch
40
+
41
+ head = _git(task_wt, "rev-parse", "HEAD").stdout.strip()
42
+ if is_ancestor(task_wt, done_commit, head):
43
+ return "already_merged"
44
+ try:
45
+ conflicts = merge_branch(task_wt, done_commit, no_ff=True)
46
+ except MergeError as exc:
47
+ raise IntegrateError(
48
+ f"stage {stage_n} ({branch}) 머지 실패(내용 충돌 아님) — abort 함. "
49
+ f"{exc}. 원인 해소 후 재시도."
50
+ ) from exc
51
+ if conflicts is not None:
52
+ raise IntegrateError(
53
+ f"stage {stage_n} ({branch}) 머지 충돌 — abort 함. "
54
+ f"충돌 파일: {', '.join(conflicts) or '(unknown)'}. 수동 머지로 해소 후 재시도."
55
+ )
56
+ return "merged"
57
+
58
+
59
+ def integrate_stages(
60
+ *, project_id: str, task_group: str, task_id: str,
61
+ task_worktree_path: str, stage_map: list[dict[str, Any]],
62
+ done_rows: list[dict[str, Any]],
63
+ ) -> IntegrateResult:
64
+ """whole-task 통합: Phase A 머지 → Phase B teardown."""
65
+ from .consumers import latest_done_by_stage
66
+
67
+ res = IntegrateResult()
68
+ if not task_worktree_path or not Path(task_worktree_path).exists():
69
+ return res # non-git / degraded — no-op
70
+ done = latest_done_by_stage(done_rows)
71
+ ordered = sorted((s["stage_number"] for s in stage_map))
72
+
73
+ for n in ordered:
74
+ row = done.get(n)
75
+ if not row or not row.get("head_commit"):
76
+ continue # 미완 stage 는 merged 게이트가 별도로 잡음
77
+ branch = _stage_branch_name(project_id, task_group, task_id, n)
78
+ if branch is None:
79
+ res.warnings.append(f"stage {n}: registry 에 stage-key 없음 — 머지 skip")
80
+ continue
81
+ try:
82
+ outcome = _merge_stage(task_worktree_path, n, branch,
83
+ row["head_commit"])
84
+ except IntegrateError as exc:
85
+ # 앞선 stage 머지는 이미 task 브랜치에 남는다(롤백 없음, 재시도 시
86
+ # already_merged 로 skip). 부분 통합 상태를 명시해 사용자가 인지하게 한다.
87
+ if res.merged:
88
+ raise IntegrateError(
89
+ f"{exc} 이미 task 브랜치에 머지된 stage: {res.merged} "
90
+ "— 재시도 시 자동 skip 됩니다.") from exc
91
+ raise
92
+ (res.merged if outcome == "merged" else res.already_merged).append(n)
93
+
94
+ _teardown_all(project_id, task_group, task_id, task_worktree_path, res)
95
+ return res
96
+
97
+
98
+ def _stage_branch_name(project_id, task_group, task_id, stage_n) -> str | None:
99
+ from . import worktree_registry
100
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage_n)
101
+ if not row:
102
+ return None
103
+ return row.get("branch") or None
104
+
105
+
106
+ def _teardown_all(project_id, task_group, task_id, task_wt: str,
107
+ res: IntegrateResult) -> None:
108
+ from . import worktree_registry
109
+
110
+ for n in [*res.merged, *res.already_merged]:
111
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, n)
112
+ outcome, detail = _teardown_stage(project_id, task_group, task_id,
113
+ task_wt, n, row)
114
+ if outcome == "torn_down":
115
+ res.torn_down.append(n)
116
+ elif outcome == "skipped":
117
+ res.teardown_skipped.append((n, detail))
118
+ if outcome == "warn":
119
+ res.warnings.append(detail)
120
+
121
+
122
+ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, str] | None:
123
+ """clean stage worktree 를 물리 제거. dirty/실패면 (outcome, detail), 성공이면 None."""
124
+ from .worktree import is_dirty_excluding_okstra, remove_worktree_force
125
+
126
+ if is_dirty_excluding_okstra(wt):
127
+ return ("skipped", "dirty — 미커밋 변경 보존")
128
+ rm = remove_worktree_force(task_wt, wt)
129
+ if rm.returncode != 0:
130
+ return ("warn", f"stage {stage_n} worktree remove 실패: {rm.stderr.strip()}")
131
+ return None
132
+
133
+
134
+ def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
135
+ """clean stage worktree 를 제거 + registry release + branch -d.
136
+ branch -d 가 성공한 뒤에만 branch 슬롯을 free 한다 — 물리 브랜치가
137
+ 살아있는 채로 슬롯이 풀려 동시 run 이 같은 이름을 재예약하는 윈도우를
138
+ 막기 위함. 반환 ('torn_down'|'skipped'|'warn', detail)."""
139
+ from . import worktree_registry
140
+
141
+ wt = (row or {}).get("worktree_path") or ""
142
+ branch = (row or {}).get("branch") or ""
143
+ if wt and Path(wt).exists():
144
+ outcome = _remove_clean_worktree(task_wt, wt, stage_n)
145
+ if outcome is not None:
146
+ return outcome
147
+ worktree_registry.release_status(project_id, task_group, task_id, stage_number=stage_n)
148
+ if branch:
149
+ bd = _git(task_wt, "branch", "-d", branch)
150
+ if bd.returncode != 0:
151
+ return ("warn", f"stage {stage_n} branch -d {branch} 실패(미머지?): "
152
+ f"{bd.stderr.strip()}")
153
+ worktree_registry.free_branch_slot(project_id, task_group, task_id,
154
+ stage_number=stage_n)
155
+ return ("torn_down", "")
156
+
157
+
158
+ def main(argv: list[str] | None = None) -> int:
159
+ import argparse
160
+ import json
161
+ import sys
162
+
163
+ from .consumers import read_consumers
164
+
165
+ p = argparse.ArgumentParser(prog="okstra integrate-stages")
166
+ p.add_argument("--project-id", required=True)
167
+ p.add_argument("--task-group", required=True)
168
+ p.add_argument("--task-id", required=True)
169
+ p.add_argument("--plan-run-root", required=True)
170
+ p.add_argument("--task-worktree-path", required=True)
171
+ p.add_argument("--stage-map-json", default="[]",
172
+ help="[{stage_number, depends_on}] JSON")
173
+ a = p.parse_args(argv)
174
+ # done 필터는 integrate_stages → latest_done_by_stage 가 단독 책임(SSOT).
175
+ try:
176
+ res = integrate_stages(
177
+ project_id=a.project_id, task_group=a.task_group,
178
+ task_id=a.task_id, task_worktree_path=a.task_worktree_path,
179
+ stage_map=json.loads(a.stage_map_json),
180
+ done_rows=read_consumers(Path(a.plan_run_root)),
181
+ )
182
+ except IntegrateError as exc:
183
+ print(f"integrate-stages: {exc}", file=sys.stderr)
184
+ return 2
185
+ print(f"merged={res.merged} already={res.already_merged} "
186
+ f"torn_down={res.torn_down} skipped={res.teardown_skipped}")
187
+ for w in res.warnings:
188
+ print(f"warning: {w}")
189
+ return 0
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())
@@ -7,7 +7,6 @@ the stage lifecycle rules behind one interface instead of leaking raw
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
- import subprocess
11
10
  from dataclasses import dataclass
12
11
  from pathlib import Path
13
12
  from typing import Any
@@ -92,13 +91,8 @@ def resolve_effective_stages(
92
91
 
93
92
  def commit_is_ancestor(project_root: Path, ancestor: str, descendant: str) -> bool:
94
93
  """True iff ``ancestor`` is an ancestor of ``descendant`` in git history."""
95
- result = subprocess.run(
96
- ["git", "merge-base", "--is-ancestor", ancestor, descendant],
97
- cwd=str(project_root),
98
- capture_output=True,
99
- text=True,
100
- )
101
- return result.returncode == 0
94
+ from .worktree import is_ancestor
95
+ return is_ancestor(project_root, ancestor, descendant)
102
96
 
103
97
 
104
98
  def check_multi_dep_merged(
@@ -44,7 +44,10 @@ def infer_project_root(task_root: Path) -> Path:
44
44
  parts = task_root.parts
45
45
  if ".okstra" not in parts:
46
46
  return task_root
47
- idx = parts.index(".okstra")
47
+ # The okstra subtree (.okstra/tasks/...) introduces the deepest ".okstra"
48
+ # segment; a project checked out under a path that itself contains
49
+ # ".okstra" must not resolve to that outer segment.
50
+ idx = len(parts) - 1 - parts[::-1].index(".okstra")
48
51
  return Path(*parts[:idx]).resolve()
49
52
 
50
53
 
@@ -112,7 +112,12 @@ def main(argv: list[str]) -> int:
112
112
  print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
113
113
  return 1
114
114
 
115
- config = json.loads(cfg_path.read_text())
115
+ try:
116
+ config = json.loads(cfg_path.read_text())
117
+ except (OSError, json.JSONDecodeError) as exc:
118
+ print(f"team-reconcile: cannot read team config at {cfg_path}: {exc}",
119
+ file=sys.stderr)
120
+ return 1
116
121
  available, live = live_pane_ids()
117
122
  if not available:
118
123
  print("team-reconcile: tmux unavailable — skipped (no flip)")
@@ -43,7 +43,6 @@ from okstra_ctl.run import (
43
43
  _load_final_report_data_if_present,
44
44
  _reject_blocking_plan_body_gate,
45
45
  _set_data_json_approved_true_if_present,
46
- _validate_data_json_approval_consistency,
47
46
  recommended_role_models,
48
47
  )
49
48
  from okstra_ctl.user_response import (
@@ -956,28 +955,42 @@ def _parse_stage_objects(state: WizardState) -> list:
956
955
 
957
956
  def _done_stage_numbers(state: WizardState) -> set:
958
957
  """approved plan 을 소비한 implementation run 들의 consumers.jsonl 에서
959
- done 처리된 stage 번호 집합. git 호출 없음 파일 읽기만(prepare 동일 SSOT)."""
958
+ done 처리된 stage 번호 집합. 순수 읽기carry backfill 하지 않는다.
959
+
960
+ backfill_done_from_carry 는 consumers.jsonl 에 done 행을 append 하고
961
+ 그 부수효과로 worktree-registry stage 점유를 해제한다. 이 함수는 stage
962
+ picker 표시와 progress 추정(_remaining_screens 가 매 step 반복 호출)에서만
963
+ 쓰이므로, 화면을 그리는 것만으로 동시 실행 중인 run 의 점유를 풀면 안 된다.
964
+ carry 기반 done 보정은 prepare 게이트(run.py)가 단일 진입점으로 수행한다."""
960
965
  if not state.approved_plan_path:
961
966
  return set()
962
- from .consumers import (read_consumers, backfill_done_from_carry,
963
- latest_done_by_stage)
967
+ from .consumers import read_consumers, latest_done_by_stage
964
968
  plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
965
- backfill_done_from_carry(plan_run_root)
966
969
  rows = read_consumers(plan_run_root)
967
970
  return set(latest_done_by_stage(rows).keys())
968
971
 
969
972
 
970
- def _whole_task_allowed(state: WizardState) -> bool:
973
+ def _whole_task_allowed(
974
+ state: WizardState,
975
+ *,
976
+ stages: Optional[list] = None,
977
+ done: Optional[set] = None,
978
+ ) -> bool:
971
979
  """final-verification 이고 Stage Map 의 모든 stage 가 done 일 때만 True.
972
- 위저드는 done 만 본다 — 머지/clean/active 는 prepare 게이트가 강제한다."""
980
+ 위저드는 done 만 본다 — 머지/clean/active 는 prepare 게이트가 강제한다.
981
+
982
+ `stages`/`done` 를 넘기면 재파싱(validator exec_module)·재읽기를 생략한다 —
983
+ 이미 둘을 계산한 `_build_stage_pick` 의 hot path 중복을 없애기 위한 seam."""
973
984
  if state.task_type != "final-verification":
974
985
  return False
975
986
  if not state.approved_plan_path:
976
987
  return False
977
- stages = _parse_stage_objects(state)
988
+ if stages is None:
989
+ stages = _parse_stage_objects(state)
978
990
  if not stages:
979
991
  return False
980
- done = _done_stage_numbers(state)
992
+ if done is None:
993
+ done = _done_stage_numbers(state)
981
994
  return all(s.stage_number in done for s in stages)
982
995
 
983
996
 
@@ -1801,7 +1814,7 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1801
1814
  options = []
1802
1815
  if _stage_auto_allowed(state):
1803
1816
  options.append(_opt("auto", t["options"]["auto"]))
1804
- if _whole_task_allowed(state):
1817
+ if _whole_task_allowed(state, stages=stages, done=done):
1805
1818
  options.append(_opt(WHOLE_TASK_STAGE, t["options"]["whole_task"]))
1806
1819
  for s in stages:
1807
1820
  depends = ",".join(map(str, s.depends_on)) or "(none)"
@@ -3281,8 +3294,10 @@ def _sim_advance(state: WizardState, prompt: Prompt) -> None:
3281
3294
  STEP_BY_ID[prompt.step].submit(state, _sim_answer(prompt))
3282
3295
  if prompt.step not in state.answered:
3283
3296
  state.answered.append(prompt.step)
3284
- except Exception:
3285
- # 추천 경로가 막히는 드문 text 분기: answered 마킹만으로 전진시킨다.
3297
+ except WizardError:
3298
+ # 추천 기본답이 검증에서 막히는 드문 text 분기(WizardError)만 전진 처리한다.
3299
+ # KeyError/AttributeError 등 실제 버그는 삼키지 않고 그대로 전파시켜
3300
+ # progress 라벨이 그럴듯하게 틀리는 대신 테스트/실행에서 시끄럽게 실패한다.
3286
3301
  members = prompt.questions if prompt.kind == "pick_group" else [prompt]
3287
3302
  for p in members:
3288
3303
  if p.step not in state.answered:
@@ -98,7 +98,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
98
98
  "release-handoff": {
99
99
  "allowed": (
100
100
  " - entering this phase only when the cited final-verification report's `Verdict Token` is exactly `accepted`\n"
101
- " - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `local only`, `push + PR`, or `skip` (end the run)\n"
101
+ " - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `local checkout`, `push + PR`, or `skip` (end the run)\n"
102
102
  " - asking the user to pick a PR base branch from `staging` | `preprod` | `prod` | `main` | `dev` | a user-supplied branch name\n"
103
103
  " - drafting PR title and PR body **inline as the Claude lead** (no drafter worker, no `Agent` dispatch); the lead reviews its own draft with the user before any mutating git / gh command runs\n"
104
104
  " - read-only git inspection: `git status`, `git diff`, `git log`, `git rev-parse`\n"
@@ -129,6 +129,14 @@ def load_phase_forbidden(workspace_root: Path) -> dict[str, str]:
129
129
  """
130
130
  path = Path(workspace_root) / "prompts" / "profiles" / "forbidden-actions.json"
131
131
  data = json.loads(path.read_text(encoding="utf-8"))
132
+ if "unknown" not in data:
133
+ # compute_workflow_state falls back to the "unknown" entry for any
134
+ # unmapped task_type; without it a missing/renamed key would surface as
135
+ # a KeyError deep in prompt assembly instead of here at the SSOT seam.
136
+ raise ValueError(
137
+ f"forbidden-actions SSOT at {path} is missing the required "
138
+ "'unknown' fallback entry"
139
+ )
132
140
  return {phase: render_forbidden(items) for phase, items in data.items()}
133
141
 
134
142
 
@@ -426,6 +426,89 @@ def okstra_clean_gate_excludes(project_root: Optional[Path] = None) -> tuple[str
426
426
  return tuple(out)
427
427
 
428
428
 
429
+ def nested_worktree_excludes(worktree_path) -> tuple[str, ...]:
430
+ """Relative paths of git worktrees nested under `worktree_path`.
431
+
432
+ implementation stage worktrees live inside the task worktree
433
+ (`<task-id>/stage-<N>/`), so the parent's `git status --short` reports
434
+ each as an untracked `?? stage-N/`. They are git-registered worktrees,
435
+ not user source changes, so clean gates must ignore them. Derived from
436
+ `git worktree list` rather than a hardcoded pattern so it tracks exactly
437
+ what git registered.
438
+ """
439
+ root = Path(worktree_path).resolve()
440
+ r = _git(root, "worktree", "list", "--porcelain")
441
+ if r.returncode != 0:
442
+ return ()
443
+ out: list[str] = []
444
+ for line in r.stdout.splitlines():
445
+ if not line.startswith("worktree "):
446
+ continue
447
+ wt = Path(line[len("worktree "):].strip()).resolve()
448
+ if wt == root:
449
+ continue
450
+ try:
451
+ rel = wt.relative_to(root)
452
+ except ValueError:
453
+ continue
454
+ out.append(str(rel))
455
+ return tuple(out)
456
+
457
+
458
+ def is_ancestor(cwd, commit: str, head: str) -> bool:
459
+ """True iff `commit` is an ancestor of `head` (both non-empty)."""
460
+ if not commit or not head:
461
+ return False
462
+ return _git(Path(cwd), "merge-base", "--is-ancestor", commit, head).returncode == 0
463
+
464
+
465
+ def is_dirty_excluding_okstra(cwd) -> bool:
466
+ """True iff the worktree has changes outside okstra-owned paths.
467
+
468
+ okstra-owned = `okstra_clean_gate_excludes` (e.g. `.okstra`, synced dirs)
469
+ plus `nested_worktree_excludes` (stage worktrees nested under `cwd`).
470
+ """
471
+ owned = (*okstra_clean_gate_excludes(Path(cwd)), *nested_worktree_excludes(cwd))
472
+ excludes = [f":(exclude){p}" for p in owned]
473
+ out = _git(Path(cwd), "status", "--short", "--", ".", *excludes).stdout
474
+ return bool(out.strip())
475
+
476
+
477
+ class MergeError(RuntimeError):
478
+ """`git merge` 가 내용 충돌이 아닌 사유로 실패했을 때(추적되지 않은 파일
479
+ 덮어쓰기, 진행 중인 머지 잔존, 없는 브랜치 등) 던진다. 충돌 파일이 0개인
480
+ 실패를 '충돌'로 둔갑시키지 않고 git stderr 를 그대로 노출해 호출자가 올바른
481
+ 복구 경로로 안내하도록 한다."""
482
+
483
+
484
+ def merge_branch(cwd, branch: str, *, no_ff: bool) -> list[str] | None:
485
+ """`branch` 를 cwd 의 현재 HEAD 에 머지한다. 성공이면 None, 내용 충돌이면
486
+ abort 후 충돌 파일 목록을 반환한다(최소 1개). 충돌이 아닌 머지 실패는
487
+ MergeError 로 던진다 — 충돌 0개를 충돌로 보고하지 않기 위함. 호출자가 충돌
488
+ 목록을 HandoffConflict/IntegrateError 등 적절한 예외로 변환한다 — 머지·
489
+ 충돌감지·abort 시퀀스의 단일 지점."""
490
+ args = ["merge", "--no-ff", "--no-edit", branch] if no_ff \
491
+ else ["merge", "--no-edit", branch]
492
+ res = _git(Path(cwd), *args)
493
+ if res.returncode == 0:
494
+ return None
495
+ conflicts = _git(Path(cwd), "diff", "--name-only",
496
+ "--diff-filter=U").stdout.split()
497
+ _git(Path(cwd), "merge", "--abort")
498
+ if not conflicts:
499
+ raise MergeError((res.stderr or res.stdout).strip()
500
+ or f"git merge {branch} failed (exit={res.returncode})")
501
+ return conflicts
502
+
503
+
504
+ def remove_worktree_force(cwd, worktree_path) -> subprocess.CompletedProcess:
505
+ """워크트리를 강제 제거한다. provision_*_worktree 가 워크트리에 .okstra·
506
+ synced 디렉터리/파일 symlink(untracked) 를 깔기 때문에 plain remove 는 그
507
+ 잔여물 때문에 git 이 거부한다 — dirty 게이트로 실사용자 변경을 막은 뒤
508
+ --force 로 okstra 잔여물만 정리하는 단일 지점."""
509
+ return _git(Path(cwd), "worktree", "remove", "--force", str(worktree_path))
510
+
511
+
429
512
  def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
430
513
  """Symlink each configured dir from `source_root` (the MAIN
431
514
  worktree) into the new worktree.
@@ -449,7 +532,10 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
449
532
  if dst.exists() or dst.is_symlink():
450
533
  continue
451
534
  dst.parent.mkdir(parents=True, exist_ok=True)
452
- os.symlink(src, dst)
535
+ try:
536
+ os.symlink(src, dst)
537
+ except FileExistsError:
538
+ continue
453
539
  notes.append(rel)
454
540
  return notes
455
541
 
@@ -472,7 +558,10 @@ def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
472
558
  if dst.exists() or dst.is_symlink():
473
559
  continue
474
560
  dst.parent.mkdir(parents=True, exist_ok=True)
475
- os.symlink(src, dst)
561
+ try:
562
+ os.symlink(src, dst)
563
+ except FileExistsError:
564
+ continue
476
565
  notes.append(rel)
477
566
  return notes
478
567
 
@@ -748,7 +837,7 @@ def provision_task_worktree(
748
837
  except RuntimeError:
749
838
  # Roll back the on-disk worktree so the next attempt is not
750
839
  # blocked by the lingering directory / branch.
751
- _git(main_root, "worktree", "remove", "--force", str(worktree_path))
840
+ remove_worktree_force(main_root, worktree_path)
752
841
  _git(main_root, "branch", "-D", branch)
753
842
  raise
754
843
 
@@ -872,7 +961,7 @@ def provision_stage_worktree(
872
961
  stage_number=stage_number,
873
962
  )
874
963
  except RuntimeError:
875
- _git(main_root, "worktree", "remove", "--force", str(worktree_path))
964
+ remove_worktree_force(main_root, worktree_path)
876
965
  _git(main_root, "branch", "-D", branch)
877
966
  raise
878
967
 
@@ -184,6 +184,11 @@ def reserve(
184
184
  "stage": stage_number,
185
185
  "stages": stages,
186
186
  }
187
+ # Re-reserving a released task-key must not drop the anchor: the
188
+ # implementation base commit is task-lifetime state, not per-reservation,
189
+ # so a release()/reserve() cycle (e.g. local_checkout) keeps it.
190
+ if existing and existing.get("implementation_base_commit"):
191
+ row["implementation_base_commit"] = existing["implementation_base_commit"]
187
192
  data["tasks"][key] = row
188
193
  data["branches"][branch] = key
189
194
  _save(data)
@@ -289,15 +294,17 @@ def list_active_stage_numbers(
289
294
  return out
290
295
 
291
296
 
292
- def release(
297
+ def release_status(
293
298
  project_id: str, task_group: str, task_id: str,
294
299
  stage_number: Optional[int] = None,
295
300
  group_id: Optional[str] = None,
296
301
  ) -> Optional[WorktreeEntry]:
297
- """Mark the entry as `released` (worktree dir intact preservation
298
- is the project's policy). The branch index is freed so future
299
- reservations of the same branch name are not blocked.
300
- Returns the prior entry, or None when not found.
302
+ """Mark the entry as `released` WITHOUT freeing its branch index slot.
303
+
304
+ Use when the physical branch still exists and its name must stay
305
+ reserved e.g. teardown defers the slot-free until `git branch -d`
306
+ actually succeeds, so the registry never advertises a free branch
307
+ name that is still alive on disk. Returns the prior entry, or None.
301
308
  """
302
309
  key = task_key(project_id, task_group, task_id, stage_number, group_id)
303
310
  with _registry_lock():
@@ -306,8 +313,50 @@ def release(
306
313
  if not row:
307
314
  return None
308
315
  row["status"] = "released"
309
- # Free the branch slot so reuse / new task can reclaim the name.
310
- if data["branches"].get(row["branch"]) == key:
311
- del data["branches"][row["branch"]]
312
316
  _save(data)
313
317
  return WorktreeEntry(task_key=key, **row)
318
+
319
+
320
+ def free_branch_slot(
321
+ project_id: str, task_group: str, task_id: str,
322
+ stage_number: Optional[int] = None,
323
+ group_id: Optional[str] = None,
324
+ ) -> bool:
325
+ """Free only the branch index slot for this key. Returns True when a
326
+ slot was freed.
327
+
328
+ Call this ONLY after the physical branch is deleted, so the invariant
329
+ "a branch name the registry marks free is always physically deleted"
330
+ holds even under concurrent runs on the same machine.
331
+ """
332
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
333
+ with _registry_lock():
334
+ data = _load()
335
+ row = data["tasks"].get(key)
336
+ if not row:
337
+ return False
338
+ branch = row.get("branch")
339
+ if branch and data["branches"].get(branch) == key:
340
+ del data["branches"][branch]
341
+ _save(data)
342
+ return True
343
+ return False
344
+
345
+
346
+ def release(
347
+ project_id: str, task_group: str, task_id: str,
348
+ stage_number: Optional[int] = None,
349
+ group_id: Optional[str] = None,
350
+ ) -> Optional[WorktreeEntry]:
351
+ """Mark the entry as `released` and free its branch slot (worktree dir
352
+ intact — preservation is the project's policy). Used by callers that
353
+ release at a point where the branch is gone or intentionally orphaned
354
+ (done-time reservation release, group cleanup). Teardown of a physical
355
+ branch must instead pair `release_status` + `free_branch_slot` so the
356
+ slot is freed only after `git branch -d` succeeds.
357
+ Returns the prior entry, or None when not found.
358
+ """
359
+ entry = release_status(project_id, task_group, task_id, stage_number, group_id)
360
+ if entry is not None:
361
+ free_branch_slot(project_id, task_group, task_id, stage_number, group_id)
362
+ return entry