okstra 0.67.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.
- package/bin/okstra +7 -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 +5 -4
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +1 -1
- 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 +88 -27
- package/runtime/python/okstra_ctl/wizard.py +25 -4
- package/runtime/python/okstra_ctl/worktree.py +10 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +1 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +49 -9
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
|
@@ -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())
|
|
@@ -1641,8 +1641,11 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1641
1641
|
# - release-handoff (and any other phase that renders with no workers
|
|
1642
1642
|
# selected) is single-lead and MUST NOT call `TeamCreate`. Emit a
|
|
1643
1643
|
# short notice instead of the BLOCKING gate.
|
|
1644
|
+
# - concurrent implementation runs skip team creation to avoid racing the
|
|
1645
|
+
# shared `~/.claude/teams/` config.
|
|
1644
1646
|
# - All other phases keep the full team-creation contract.
|
|
1645
1647
|
task_type = ctx.get("TASK_TYPE", "")
|
|
1648
|
+
concurrent_stages = str(ctx.get("CONCURRENT_RUN_STAGES", "") or "").strip()
|
|
1646
1649
|
if task_type == "release-handoff" or not selected:
|
|
1647
1650
|
team_creation_gate_block = (
|
|
1648
1651
|
"## Single-Lead Phase (no team creation)\n"
|
|
@@ -1655,7 +1658,34 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1655
1658
|
"report). Do NOT call `TeamCreate` or dispatch any sub-agent\n"
|
|
1656
1659
|
"from this run — that would be a contract violation."
|
|
1657
1660
|
)
|
|
1661
|
+
elif task_type == "implementation" and concurrent_stages:
|
|
1662
|
+
team_creation_gate_block = (
|
|
1663
|
+
"## Concurrent-run: no-team background (BLOCKING)\n"
|
|
1664
|
+
"\n"
|
|
1665
|
+
"A concurrent implementation run of the same task-key holds stage(s)\n"
|
|
1666
|
+
f"`{concurrent_stages}` right now. Creating a team would race the shared\n"
|
|
1667
|
+
"`~/.claude/teams/` config and can make another stage's `config.json`\n"
|
|
1668
|
+
"unreadable. This run therefore does NOT create a team.\n"
|
|
1669
|
+
"\n"
|
|
1670
|
+
"Required actions, in order:\n"
|
|
1671
|
+
"\n"
|
|
1672
|
+
"1. Do NOT call `TeamCreate`, and do NOT dispatch any worker with\n"
|
|
1673
|
+
" `Agent(... team_name: ...)`.\n"
|
|
1674
|
+
"2. Before any dispatch, record in team-state:\n"
|
|
1675
|
+
' `teamCreate: { attempted: false, status: "skipped",'
|
|
1676
|
+
f' reason: "concurrent-run", concurrentStages: [{concurrent_stages}] }}`.\n'
|
|
1677
|
+
"3. Dispatch every worker with `run_in_background: true` and NO\n"
|
|
1678
|
+
" `team_name` (the Phase 5 fallback). Worker completion is detected by\n"
|
|
1679
|
+
" result-file polling, so analysis output is equivalent — only the\n"
|
|
1680
|
+
" Teams split-pane view is lost."
|
|
1681
|
+
)
|
|
1658
1682
|
else:
|
|
1683
|
+
team_name = f'okstra-{ctx.get("TASK_KEY", "")}'
|
|
1684
|
+
stage = str(ctx.get("EFFECTIVE_STAGES", "") or "").strip()
|
|
1685
|
+
if task_type == "implementation" and stage:
|
|
1686
|
+
# stage 격리 run 은 stage 별 team — 같은 task 의 다른 stage 가 남긴
|
|
1687
|
+
# team 과 이름이 충돌하지 않는다(worktree branch `-s<N>` 접미사와 동형).
|
|
1688
|
+
team_name = f"{team_name}-s{stage}"
|
|
1659
1689
|
team_creation_gate_block = (
|
|
1660
1690
|
"## Team Creation Gate (BLOCKING)\n"
|
|
1661
1691
|
"\n"
|
|
@@ -1671,14 +1701,26 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1671
1701
|
"\n"
|
|
1672
1702
|
"1. Invoke the `okstra-team-contract` skill and verify the selected worker\n"
|
|
1673
1703
|
" roster against `task-manifest.json`'s `resultContract.requiredWorkerRoles`.\n"
|
|
1674
|
-
f'2. Call `TeamCreate(team_name: "
|
|
1704
|
+
f'2. Call `TeamCreate(team_name: "{team_name}", description: ...)`.\n'
|
|
1675
1705
|
"3. Record the outcome in team-state under\n"
|
|
1676
1706
|
' `teamCreate: { attempted: true, status: "ok" | "error", error?: <msg> }`\n'
|
|
1677
|
-
" BEFORE any `Agent(...)` worker
|
|
1707
|
+
" AND record the exact name as `teamName` BEFORE any `Agent(...)` worker\n"
|
|
1708
|
+
" dispatch.\n"
|
|
1678
1709
|
"4. Only after `teamCreate` is persisted may you dispatch workers — with\n"
|
|
1679
1710
|
" `team_name` on success, or with `run_in_background: true` and no\n"
|
|
1680
1711
|
' `team_name` ONLY when `teamCreate.status == "error"` was recorded.\n'
|
|
1681
1712
|
"\n"
|
|
1713
|
+
'If `TeamCreate` fails with "team already exists" (stale leftover from an\n'
|
|
1714
|
+
"earlier attempt): call `TeamList`; if the team is listed in this session,\n"
|
|
1715
|
+
"`TeamDelete` it and retry step 2 once. If it is NOT listed, do NOT remove\n"
|
|
1716
|
+
"`~/.claude/teams/...` / `~/.claude/tasks/...` on your own initiative —\n"
|
|
1717
|
+
"shell deletion of harness-internal state is destructive and `rm -rf` is\n"
|
|
1718
|
+
"commonly denied by user permission rules. Instead ask the user via\n"
|
|
1719
|
+
"AskUserQuestion (recommended option: quarantine), and on approval move\n"
|
|
1720
|
+
f"`~/.claude/teams/{team_name}` and `~/.claude/tasks/{team_name}` into\n"
|
|
1721
|
+
"`~/.okstra/trash/<UTC-timestamp>/` with `mv` (reversible, no delete),\n"
|
|
1722
|
+
"then retry step 2 once.\n"
|
|
1723
|
+
"\n"
|
|
1682
1724
|
'If the Agent tool rejects a dispatch with `"team must be created first"` /\n'
|
|
1683
1725
|
'`"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct\n'
|
|
1684
1726
|
"response is to go back to step 2 — NOT to strip `team_name` and retry."
|