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.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/kr/architecture/storage-model.md +273 -0
- package/docs/kr/architecture.md +6 -277
- package/docs/kr/cli.md +1 -1
- package/docs/project-structure-overview.md +26 -22
- package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
- package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
- package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
- package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
- package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
- package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
- package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
- package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +1 -1
- package/runtime/bin/lib/okstra/interactive.sh +22 -4
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
- package/runtime/bin/okstra-render-final-report.py +4 -15
- package/runtime/bin/okstra.sh +5 -2
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +10 -4
- package/runtime/python/okstra_ctl/__init__.py +3 -2
- package/runtime/python/okstra_ctl/backfill.py +12 -21
- package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
- package/runtime/python/okstra_ctl/conformance.py +21 -3
- package/runtime/python/okstra_ctl/consumers.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
- package/runtime/python/okstra_ctl/doctor.py +29 -12
- package/runtime/python/okstra_ctl/error_log_core.py +72 -0
- package/runtime/python/okstra_ctl/error_report.py +8 -58
- package/runtime/python/okstra_ctl/error_zip.py +319 -0
- package/runtime/python/okstra_ctl/fanout.py +5 -2
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
- package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
- package/runtime/python/okstra_ctl/handoff.py +125 -26
- package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
- package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
- package/runtime/python/okstra_ctl/index.py +34 -91
- package/runtime/python/okstra_ctl/jsonl.py +29 -18
- package/runtime/python/okstra_ctl/listing.py +30 -5
- package/runtime/python/okstra_ctl/md_table.py +1 -1
- package/runtime/python/okstra_ctl/migrate.py +17 -7
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/recap.py +12 -3
- package/runtime/python/okstra_ctl/reconcile.py +13 -11
- package/runtime/python/okstra_ctl/render.py +49 -12
- package/runtime/python/okstra_ctl/render_final_report.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +47 -6
- package/runtime/python/okstra_ctl/run.py +128 -100
- package/runtime/python/okstra_ctl/run_index_row.py +118 -0
- package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
- package/runtime/python/okstra_ctl/sequence.py +2 -2
- package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
- package/runtime/python/okstra_ctl/stage_targets.py +2 -8
- package/runtime/python/okstra_ctl/task_target.py +4 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
- package/runtime/python/okstra_ctl/wizard.py +27 -12
- package/runtime/python/okstra_ctl/workflow.py +9 -1
- package/runtime/python/okstra_ctl/worktree.py +93 -4
- package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
- package/runtime/python/okstra_token_usage/collect.py +43 -38
- package/runtime/skills/okstra-brief/SKILL.md +43 -12
- package/runtime/skills/okstra-inspect/SKILL.md +44 -1
- package/runtime/templates/reports/final-report.template.md +2 -2
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/validators/forbidden_actions.py +8 -1
- package/runtime/validators/validate-brief.py +13 -2
- package/runtime/validators/validate-run.py +27 -6
- package/runtime/validators/validate-schedule.py +1 -2
- package/runtime/validators/validate_improvement_report.py +14 -0
- package/src/cli-registry.mjs +45 -31
- package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
- package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
- package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
- package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
- package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
- package/src/commands/execute/integrate-stages.mjs +25 -0
- package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
- package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
- package/src/{run.mjs → commands/execute/run.mjs} +4 -4
- package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
- package/src/{team.mjs → commands/execute/team.mjs} +3 -3
- package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
- package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
- package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
- package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
- package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
- package/src/commands/inspect/error-zip.mjs +25 -0
- package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
- package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
- package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
- package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
- package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
- package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
- package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
- package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
- package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
- package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
- package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
- package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
- package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
- package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
- package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
- package/src/lib/paths.mjs +60 -0
- package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
- package/src/{version.mjs → lib/version.mjs} +2 -2
- /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
- /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
- /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
- /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
- /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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 번호 집합.
|
|
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
|
|
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(
|
|
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
|
|
988
|
+
if stages is None:
|
|
989
|
+
stages = _parse_stage_objects(state)
|
|
978
990
|
if not stages:
|
|
979
991
|
return False
|
|
980
|
-
done
|
|
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
|
|
3285
|
-
# 추천
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|