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.
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 +1 -1
  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 +25 -4
  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 +43 -3
  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
@@ -462,12 +462,41 @@ def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
462
462
  return r.returncode == 0
463
463
 
464
464
 
465
+ def _check_multi_dep_merged(project_root, plan_run_root, latest,
466
+ pred_commits: dict, candidate_base: str,
467
+ stage_n: int) -> None:
468
+ """모든 선행 done commit 이 candidate 에 (내용 기준으로라도) 머지됐는지
469
+ 검증. patch-equivalent 면 보정 row 를 자동 기록(다음 run 은 ancestor 로
470
+ 바로 통과). 아니면 회복 안내를 담아 PrepareError."""
471
+ from .git_reconcile import content_merged, _record_reconciled
472
+ for d, head in pred_commits.items():
473
+ if _commit_is_ancestor(project_root, head, candidate_base):
474
+ continue
475
+ match = content_merged(project_root, head, candidate_base)
476
+ if match.status in ("ancestor", "patch-equivalent"):
477
+ if plan_run_root is not None:
478
+ _record_reconciled(
479
+ plan_run_root,
480
+ impl_task_key=(latest.get(d) or {}).get("impl_task_key", ""),
481
+ stage=d, new_commit=match.matched_commit,
482
+ replaced=head, reason="auto-patch-id")
483
+ continue
484
+ raise PrepareError(
485
+ f"multi-dependency stage {stage_n}: predecessor stage {d} "
486
+ f"({head[:8]}) is not merged into the task worktree "
487
+ f"({candidate_base[:8]}). Merge stage branches "
488
+ f"(e.g. the `-s{d}` branches) into the task worktree "
489
+ "(or into main, then refresh the worktree) and retry."
490
+ )
491
+
492
+
465
493
  def _resolve_stage_base_commit(
466
494
  stage: dict,
467
495
  consumer_done_rows: list,
468
496
  anchor_base_commit: str,
469
497
  candidate_base: str = "",
470
498
  project_root=None,
499
+ plan_run_root=None,
471
500
  ) -> str:
472
501
  """Pick the git base commit a stage's isolated worktree branches from.
473
502
 
@@ -479,18 +508,15 @@ def _resolve_stage_base_commit(
479
508
  candidate 를 반환(사용자가 선행을 머지함). 아니면 PrepareError.
480
509
 
481
510
  Raises PrepareError on missing predecessor / anchor / unmerged predecessor."""
511
+ from .consumers import latest_done_by_stage
512
+ latest = latest_done_by_stage(consumer_done_rows)
482
513
  deps = stage.get("depends_on") or []
483
514
  if len(deps) >= 2:
484
515
  n = stage["stage_number"]
485
516
  # 1) 모든 선행의 done head_commit 수집
486
517
  pred_commits = {}
487
518
  for d in deps:
488
- head = next(
489
- (r.get("head_commit") for r in consumer_done_rows
490
- if r.get("stage") == d and r.get("status") == "done"
491
- and r.get("head_commit")),
492
- None,
493
- )
519
+ head = (latest.get(d) or {}).get("head_commit")
494
520
  if not head:
495
521
  raise PrepareError(
496
522
  f"predecessor stage {d} has no done row with head_commit "
@@ -503,16 +529,10 @@ def _resolve_stage_base_commit(
503
529
  f"candidate base missing for multi-dependency stage {n}; "
504
530
  "task-key worktree HEAD could not be resolved"
505
531
  )
506
- # 3) 모든 선행 done 이 candidate ancestor 인지 (=사용자가 머지함)
507
- for d, head in pred_commits.items():
508
- if not _commit_is_ancestor(project_root, head, candidate_base):
509
- raise PrepareError(
510
- f"multi-dependency stage {n}: predecessor stage {d} "
511
- f"({head[:8]}) is not merged into the task worktree "
512
- f"({candidate_base[:8]}). Merge stage branches "
513
- f"(e.g. the `-s{d}` branches) into the task worktree "
514
- "(or into main, then refresh the worktree) and retry."
515
- )
532
+ # 3) 모든 선행 done 이 candidate (ancestor 또는 patch-equivalent )
533
+ # 머지됐는지 patch-equivalent 보정 row 를 자동 기록한다.
534
+ _check_multi_dep_merged(project_root, plan_run_root, latest,
535
+ pred_commits, candidate_base, n)
516
536
  return candidate_base
517
537
  if not deps:
518
538
  if not anchor_base_commit:
@@ -524,11 +544,9 @@ def _resolve_stage_base_commit(
524
544
  return anchor_base_commit
525
545
  # 단일 의존
526
546
  pred = deps[0]
527
- for row in consumer_done_rows:
528
- if row.get("stage") == pred and row.get("status") == "done":
529
- head = row.get("head_commit") or ""
530
- if head:
531
- return head
547
+ head = (latest.get(pred) or {}).get("head_commit") or ""
548
+ if head:
549
+ return head
532
550
  raise PrepareError(
533
551
  f"predecessor stage {pred} has no done row with head_commit in "
534
552
  "consumers.jsonl; cannot derive base for stage "
@@ -553,7 +571,8 @@ def _resolve_whole_task_target(
553
571
  ) -> "_FVTarget":
554
572
  """전체-task 검증 target. 모든 Stage Map stage 가 done + HEAD 에 머지 +
555
573
  worktree clean 이어야 한다. 위반 시 PrepareError."""
556
- done_by_stage = {r["stage"]: r for r in done_rows}
574
+ from .consumers import latest_done_by_stage
575
+ done_by_stage = latest_done_by_stage(done_rows)
557
576
  for s in stage_map:
558
577
  n = s["stage_number"]
559
578
  if n not in done_by_stage:
@@ -587,8 +606,9 @@ def _resolve_single_stage_target(
587
606
  ) -> "_FVTarget":
588
607
  """단독-stage 검증 target. stage N 만 done + stage worktree 존재 + clean.
589
608
  다른 stage 의 done/머지 여부와 무관. 위반 시 PrepareError."""
609
+ from .consumers import latest_done_by_stage
590
610
  n = int(requested_stage)
591
- done_by_stage = {r["stage"]: r for r in done_rows}
611
+ done_by_stage = latest_done_by_stage(done_rows)
592
612
  if n not in done_by_stage:
593
613
  raise PrepareError(
594
614
  f"final-verification(single-stage): stage {n} not done — "
@@ -1290,6 +1310,7 @@ class StageSelection:
1290
1310
  worktree_status: str
1291
1311
  worktree_note: str
1292
1312
  started_head_commit: str
1313
+ concurrent_stages: list = field(default_factory=list)
1293
1314
 
1294
1315
 
1295
1316
  def _select_and_provision_implementation_stage(
@@ -1315,6 +1336,7 @@ def _select_and_provision_implementation_stage(
1315
1336
  # carry sidecars are the SSOT for stage completion; recover any `done` rows
1316
1337
  # the lead failed to append before the dependency gate reads them.
1317
1338
  backfill_done_from_carry(plan_run_root)
1339
+ _auto_reconcile_best_effort(inp, plan_run_root)
1318
1340
  consumed = read_consumers(plan_run_root)
1319
1341
  done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
1320
1342
  started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
@@ -1327,6 +1349,7 @@ def _select_and_provision_implementation_stage(
1327
1349
  started_stages=started_stages, reserved_stages=reserved_stages,
1328
1350
  )
1329
1351
  selected = batch[0]
1352
+ concurrent_stages = sorted(reserved_stages - {selected})
1330
1353
 
1331
1354
  # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
1332
1355
  # stage 격리도 동일하게 degrade — worktree 없이 project HEAD 만 기록.
@@ -1340,6 +1363,7 @@ def _select_and_provision_implementation_stage(
1340
1363
  worktree_status=executor_worktree_status,
1341
1364
  worktree_note="",
1342
1365
  started_head_commit=head,
1366
+ concurrent_stages=concurrent_stages,
1343
1367
  )
1344
1368
 
1345
1369
  # anchor base commit 1회 고정 (task-key worktree HEAD 기준)
@@ -1360,6 +1384,7 @@ def _select_and_provision_implementation_stage(
1360
1384
  stage_base = _resolve_stage_base_commit(
1361
1385
  selected_stage, consumer_done_rows, anchor_base_commit=anchor,
1362
1386
  candidate_base=head_sha, project_root=Path(inp.project_root),
1387
+ plan_run_root=plan_run_root,
1363
1388
  )
1364
1389
  try:
1365
1390
  prov = _worktree.provision_stage_worktree(
@@ -1372,7 +1397,12 @@ def _select_and_provision_implementation_stage(
1372
1397
  base_commit=stage_base,
1373
1398
  )
1374
1399
  except RuntimeError as exc:
1375
- raise PrepareError(f"stage worktree provisioning failed: {exc}") from exc
1400
+ from .git_reconcile import guidance
1401
+ hint = guidance(plan_run_root=plan_run_root, project_id=inp.project_id,
1402
+ task_group=inp.task_group, task_id=inp.task_id,
1403
+ work_category=inp.work_category)
1404
+ raise PrepareError(
1405
+ f"stage worktree provisioning failed: {exc}\n{hint}") from exc
1376
1406
 
1377
1407
  return StageSelection(
1378
1408
  stage=selected,
@@ -1382,6 +1412,7 @@ def _select_and_provision_implementation_stage(
1382
1412
  worktree_status=prov.status,
1383
1413
  worktree_note=prov.note,
1384
1414
  started_head_commit=prov.base_ref,
1415
+ concurrent_stages=concurrent_stages,
1385
1416
  )
1386
1417
 
1387
1418
 
@@ -1400,6 +1431,7 @@ def _apply_implementation_stage(
1400
1431
  ctx["effective_stages"] = [sel.stage]
1401
1432
  csv = str(sel.stage)
1402
1433
  ctx["EFFECTIVE_STAGES"] = csv
1434
+ ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
1403
1435
  ctx["STAGE_BATCH_DIRECTIVE"] = (
1404
1436
  f"- **Stage for this implementation run:** `{csv}`. "
1405
1437
  "Execute exactly this Stage Map stage — this is the authoritative scope. "
@@ -1436,6 +1468,20 @@ def _git_out(cwd, *args) -> str:
1436
1468
  return r.stdout.strip() if r.returncode == 0 else ""
1437
1469
 
1438
1470
 
1471
+ def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
1472
+ try:
1473
+ from .git_reconcile import auto_reconcile
1474
+ for it in auto_reconcile(
1475
+ project_root=Path(inp.project_root), plan_run_root=plan_run_root,
1476
+ project_id=inp.project_id, task_group=inp.task_group,
1477
+ task_id=inp.task_id, work_category=inp.work_category):
1478
+ print(f"git-reconcile: stage {it.stage} done commit "
1479
+ f"{it.recorded[:8]} -> {it.suggested_commit[:8]} "
1480
+ "(patch-equivalent)", file=sys.stdout)
1481
+ except Exception as exc: # 화해는 부가 기능 — 실패 시 기존 gate 가 판정
1482
+ print(f"git-reconcile skipped: {exc}", file=sys.stderr)
1483
+
1484
+
1439
1485
  def _is_ancestor(cwd, commit, head) -> bool:
1440
1486
  if not commit or not head:
1441
1487
  return False
@@ -1464,6 +1510,7 @@ def _reserve_final_verification_target(
1464
1510
  # carry sidecars are the SSOT for stage completion — recover missing `done`
1465
1511
  # rows before the whole-task gate checks every stage.
1466
1512
  backfill_done_from_carry(plan_run_root)
1513
+ _auto_reconcile_best_effort(inp, plan_run_root)
1467
1514
  done_rows = [r for r in read_consumers(plan_run_root)
1468
1515
  if r.get("status") == "done"]
1469
1516
 
@@ -1485,8 +1532,9 @@ def _reserve_final_verification_target(
1485
1532
  anchor = _reg.get_implementation_base(
1486
1533
  inp.project_id, inp.task_group, inp.task_id) or ""
1487
1534
  head = _git_out(wt_path, "rev-parse", "HEAD")
1488
- merged = {r["stage"]: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1489
- for r in done_rows}
1535
+ from .consumers import latest_done_by_stage
1536
+ merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1537
+ for s, r in latest_done_by_stage(done_rows).items()}
1490
1538
  target = _resolve_whole_task_target(
1491
1539
  stage_map=ctx_stage_map, done_rows=done_rows, anchor_base=anchor,
1492
1540
  task_worktree_path=wt_path, task_head=head,
@@ -1959,10 +2007,19 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1959
2007
  if not inp.render_only:
1960
2008
  _provision_settings_symlink(inp)
1961
2009
 
2010
+ concurrent_run: dict = {}
2011
+ if impl_stage_selection is not None and impl_stage_selection.concurrent_stages:
2012
+ concurrent_run = {
2013
+ "detected": True,
2014
+ "active_stages": impl_stage_selection.concurrent_stages,
2015
+ }
1962
2016
  return PrepareOutputs(
1963
2017
  ctx=ctx,
1964
2018
  prompt_text=prompt_text,
1965
- extras={"profile_content": profile_content},
2019
+ extras={
2020
+ "profile_content": profile_content,
2021
+ "concurrent_run": concurrent_run,
2022
+ },
1966
2023
  )
1967
2024
 
1968
2025
 
@@ -2132,6 +2189,10 @@ def main(argv: list[str]) -> int:
2132
2189
  print(f"okstra instruction-set: {ctx['INSTRUCTION_SET_PATH']}")
2133
2190
  print(f"okstra reference expectations: {ctx['REFERENCE_EXPECTATIONS_FILE']}")
2134
2191
  print(f"okstra final report template: {ctx['FINAL_REPORT_TEMPLATE_PATH']}")
2192
+ cr = out.extras.get("concurrent_run", {})
2193
+ if cr.get("detected"):
2194
+ stages_csv = ",".join(str(s) for s in cr["active_stages"])
2195
+ print(f"okstra concurrent-run stages: {stages_csv}")
2135
2196
  if inputs.render_only:
2136
2197
  print()
2137
2198
  print(out.prompt_text, end="")
@@ -261,6 +261,7 @@ S_BRANCH_CONFIRM = "branch_confirm"
261
261
  S_CONFIRM = "confirm"
262
262
  S_EDIT_TARGET = "edit_target"
263
263
  S_DONE = "done"
264
+ S_ABORTED = "aborted"
264
265
 
265
266
  # ---- 멀티탭 배치 프롬프트 그룹 (방출 계층 전용) ----
266
267
  # 그룹 id 는 S_* 가 아니므로 prompts JSON SOT / step-id 동기화 검사 대상이 아니다.
@@ -362,6 +363,8 @@ class WizardState:
362
363
  branch_confirmed: Optional[bool] = None
363
364
  confirmed: Optional[bool] = None
364
365
  edit_target: str = ""
366
+ # terminal: user picked 중단 — no further prompt ever applies
367
+ aborted: bool = False
365
368
 
366
369
  # bookkeeping
367
370
  answered: list[str] = field(default_factory=list)
@@ -384,7 +387,7 @@ class Option:
384
387
  @dataclass
385
388
  class Prompt:
386
389
  step: str
387
- kind: str # "pick" | "text" | "done"
390
+ kind: str # "pick" | "text" | "pick_group" | "done" | "aborted"
388
391
  label: str = ""
389
392
  options: list[Option] = field(default_factory=list)
390
393
  help: str = ""
@@ -2201,6 +2204,7 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
2201
2204
  options = [_opt("proceed", opts["proceed"])]
2202
2205
  if decision.status == "new":
2203
2206
  options.append(_opt("edit", opts["edit"]))
2207
+ options.append(_opt("abort", opts["abort"]))
2204
2208
  return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
2205
2209
  options=options, echo_template=t["echo_template"])
2206
2210
 
@@ -2210,8 +2214,13 @@ def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
2210
2214
  _reset_from(state, S_BASE_REF_PICK)
2211
2215
  state.branch_confirmed = None
2212
2216
  return "branch-confirm: edit"
2217
+ if value == "abort":
2218
+ state.aborted = True
2219
+ return "branch-confirm: abort"
2213
2220
  if value != "proceed":
2214
- raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
2221
+ raise WizardError(
2222
+ f"expected 'proceed' / 'edit' / 'abort', got: {value!r}"
2223
+ )
2215
2224
  state.branch_confirmed = True
2216
2225
  return "branch-confirm: proceed"
2217
2226
 
@@ -2685,6 +2694,8 @@ def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
2685
2694
 
2686
2695
 
2687
2696
  def next_prompt(state: WizardState) -> Prompt:
2697
+ if state.aborted:
2698
+ return Prompt(step=S_ABORTED, kind="aborted")
2688
2699
  if state.confirmed:
2689
2700
  return Prompt(step=S_DONE, kind="done")
2690
2701
  for step in STEPS:
@@ -2731,7 +2742,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2731
2742
  validation failure (caller may re-prompt).
2732
2743
  """
2733
2744
  prompt = next_prompt(state)
2734
- if prompt.kind == "done":
2745
+ if prompt.kind in ("done", "aborted"):
2735
2746
  return {"echo": "", "next": prompt.to_json()}
2736
2747
  if prompt.kind == "pick_group":
2737
2748
  return _submit_group(state, prompt, value)
@@ -2745,6 +2756,10 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2745
2756
 
2746
2757
  def render_args(state: WizardState) -> dict[str, str]:
2747
2758
  """Convert finalized state into ``okstra render-bundle`` argument map."""
2759
+ if state.aborted:
2760
+ raise WizardError(
2761
+ "wizard was aborted by the user — render-args is unavailable"
2762
+ )
2748
2763
  workers = state.workers_override.strip()
2749
2764
  if state.task_type == "implementation":
2750
2765
  workers = "" # profile-default roster is mandatory for impl
@@ -2928,7 +2943,13 @@ def _cli(argv: list[str]) -> int:
2928
2943
 
2929
2944
  if args.cmd == "render-args":
2930
2945
  state = load_state_file(state_path)
2931
- print(json.dumps({"ok": True, "args": render_args(state)},
2946
+ try:
2947
+ rendered = render_args(state)
2948
+ except WizardError as exc:
2949
+ print(json.dumps({"ok": False, "error": str(exc)},
2950
+ ensure_ascii=False, indent=2))
2951
+ return 0
2952
+ print(json.dumps({"ok": True, "args": rendered},
2932
2953
  ensure_ascii=False, indent=2))
2933
2954
  return 0
2934
2955
 
@@ -492,11 +492,14 @@ def compute_worktree_path(
492
492
  task_group_segment: str,
493
493
  task_id_segment: str,
494
494
  stage_number: Optional[int] = None,
495
+ group_id: Optional[str] = None,
495
496
  ) -> Path:
496
497
  """Pure path computation. One worktree dir per task-key, or per
497
498
  `<task-key>/stage-<N>` when stage_number is given (implementation
498
499
  stage isolation). Uses `OKSTRA_HOME` when set (test hook), else
499
500
  `~/.okstra`."""
501
+ if stage_number is not None and group_id is not None:
502
+ raise ValueError("stage_number and group_id are mutually exclusive")
500
503
  okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
501
504
  base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
502
505
  path = (
@@ -507,6 +510,8 @@ def compute_worktree_path(
507
510
  )
508
511
  if stage_number is not None:
509
512
  path = path / f"stage-{stage_number}"
513
+ if group_id is not None:
514
+ path = path / f"group-{group_id}"
510
515
  return path
511
516
 
512
517
 
@@ -515,12 +520,17 @@ def compute_branch_name(
515
520
  work_category: str,
516
521
  task_id_segment: str,
517
522
  stage_number: Optional[int] = None,
523
+ group_id: Optional[str] = None,
518
524
  ) -> str:
519
525
  """One branch per task-key, or `<prefix>-<task-id>-s<N>` for an
520
526
  implementation stage worktree."""
527
+ if stage_number is not None and group_id is not None:
528
+ raise ValueError("stage_number and group_id are mutually exclusive")
521
529
  name = f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
522
530
  if stage_number is not None:
523
531
  name = f"{name}-s{stage_number}"
532
+ if group_id is not None:
533
+ name = f"{name}-{group_id}"
524
534
  return name
525
535
 
526
536
 
@@ -46,13 +46,17 @@ def _okstra_worktrees_dir() -> Path:
46
46
  def task_key(
47
47
  project_id: str, task_group: str, task_id: str,
48
48
  stage_number: Optional[int] = None,
49
+ group_id: Optional[str] = None,
49
50
  ) -> str:
50
- """Canonical task-key. With stage_number, returns the stage-scoped
51
- key `<proj>/<group>/<task>#stage-<N>` used to reserve a per-stage
52
- worktree independently of the task-key entry."""
51
+ """Canonical task-key. stage_number `#stage-<N>` (per-stage worktree),
52
+ group_id → `#group-<id>` (stage-group collector worktree). 둘은 상호배타."""
53
+ if stage_number is not None and group_id is not None:
54
+ raise ValueError("stage_number and group_id are mutually exclusive")
53
55
  base = f"{project_id}/{task_group}/{task_id}"
54
56
  if stage_number is not None:
55
57
  return f"{base}#stage-{stage_number}"
58
+ if group_id is not None:
59
+ return f"{base}#group-{group_id}"
56
60
  return base
57
61
 
58
62
 
@@ -70,6 +74,7 @@ class WorktreeEntry:
70
74
  status: str = "active" # "active" | "released"
71
75
  stage: Optional[int] = None
72
76
  implementation_base_commit: str = ""
77
+ stages: Optional[list] = None
73
78
 
74
79
 
75
80
  @contextlib.contextmanager
@@ -119,8 +124,9 @@ def _save(data: dict) -> None:
119
124
  def lookup(
120
125
  project_id: str, task_group: str, task_id: str,
121
126
  stage_number: Optional[int] = None,
127
+ group_id: Optional[str] = None,
122
128
  ) -> Optional[WorktreeEntry]:
123
- key = task_key(project_id, task_group, task_id, stage_number)
129
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
124
130
  with _registry_lock():
125
131
  data = _load()
126
132
  row = data["tasks"].get(key)
@@ -139,18 +145,20 @@ def reserve(
139
145
  base_ref: str,
140
146
  phase: str = "",
141
147
  stage_number: Optional[int] = None,
148
+ group_id: Optional[str] = None,
149
+ stages: Optional[list] = None,
142
150
  ) -> WorktreeEntry:
143
151
  """Atomically insert a new entry. Raises RuntimeError if the
144
152
  task-key already exists or the branch is already owned by a
145
153
  different task-key. Callers should `lookup()` first when re-entry
146
154
  is expected.
147
155
  """
148
- key = task_key(project_id, task_group, task_id, stage_number)
156
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
149
157
  now = time.strftime("%Y-%m-%dT%H:%M:%S%z") or time.strftime("%Y-%m-%dT%H:%M:%S")
150
158
  with _registry_lock():
151
159
  data = _load()
152
- if key in data["tasks"]:
153
- existing = data["tasks"][key]
160
+ existing = data["tasks"].get(key)
161
+ if existing and existing.get("status") != "released":
154
162
  raise RuntimeError(
155
163
  f"task-key already has a worktree registered: {key} → "
156
164
  f"{existing['worktree_path']} (branch {existing['branch']}). "
@@ -174,6 +182,7 @@ def reserve(
174
182
  "last_phase": phase,
175
183
  "status": "active",
176
184
  "stage": stage_number,
185
+ "stages": stages,
177
186
  }
178
187
  data["tasks"][key] = row
179
188
  data["branches"][branch] = key
@@ -218,6 +227,24 @@ def set_implementation_base(
218
227
  return commit
219
228
 
220
229
 
230
+ def reset_implementation_base(
231
+ project_id: str, task_group: str, task_id: str, commit: str,
232
+ ) -> str:
233
+ """anchor 를 의식적으로 재고정한다. 유일한 호출자는 git-reconcile 의
234
+ `--reset-anchor` — prepare 경로는 절대 anchor 를 움직이지 않는다."""
235
+ key = task_key(project_id, task_group, task_id)
236
+ with _registry_lock():
237
+ data = _load()
238
+ row = data["tasks"].get(key)
239
+ if row is None:
240
+ raise RuntimeError(
241
+ f"no task-key entry to reset implementation base: {key}"
242
+ )
243
+ row["implementation_base_commit"] = commit
244
+ _save(data)
245
+ return commit
246
+
247
+
221
248
  def get_implementation_base(
222
249
  project_id: str, task_group: str, task_id: str,
223
250
  ) -> Optional[str]:
@@ -262,13 +289,17 @@ def list_active_stage_numbers(
262
289
  return out
263
290
 
264
291
 
265
- def release(project_id: str, task_group: str, task_id: str) -> Optional[WorktreeEntry]:
292
+ def release(
293
+ project_id: str, task_group: str, task_id: str,
294
+ stage_number: Optional[int] = None,
295
+ group_id: Optional[str] = None,
296
+ ) -> Optional[WorktreeEntry]:
266
297
  """Mark the entry as `released` (worktree dir intact — preservation
267
298
  is the project's policy). The branch index is freed so future
268
299
  reservations of the same branch name are not blocked.
269
300
  Returns the prior entry, or None when not found.
270
301
  """
271
- key = task_key(project_id, task_group, task_id)
302
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
272
303
  with _registry_lock():
273
304
  data = _load()
274
305
  row = data["tasks"].get(key)
@@ -155,6 +155,6 @@ Information to be obtained after executing this skill:
155
155
  - Reference list of config files/deployment manifests and task-level expected values
156
156
  - Current run status and presence of existing worker results
157
157
  - Current run prompt history contract for attempted workers
158
- - Candidate `teamName` for Phase 3 hand-off: `okstra-<task-key>` (with task-key slugified per Step 1's slug rule)
158
+ - Candidate `teamName` for Phase 3 hand-off: `okstra-<task-key>` (with task-key slugified per Step 1's slug rule); implementation stage runs append `-s<N>` — the launch prompt's Team Creation Gate block carries the final name verbatim
159
159
  - Current Claude `lead.sessionId` (the in-flight Claude Code session) — required by `okstra-team-contract` when registering the lead in `team-state.json`
160
160
  - Resume command path: from `task-manifest.json` → `latestResumeCommandPath` (fallback: latest `runs/<task-type>/sessions/claude-resume-*.sh` by mtime). Never reconstruct the filename — the `<seq>` counter is category-local and may diverge from `manifests/`.
@@ -264,7 +264,7 @@ Agent(
264
264
  prompt: "<re-verification prompt with findings batch>",
265
265
  name: "<role-slug>-reverify-r<N>",
266
266
  subagent_type: "<same as initial execution>",
267
- team_name: "okstra-<task-key>",
267
+ team_name: "<teamName recorded in team-state>",
268
268
  model: "<same as initial execution>",
269
269
  mode: "auto"
270
270
  )
@@ -34,7 +34,7 @@ Agent(
34
34
  prompt: "<report-writer prompt: see this skill + Required reading clause + Available MCP Servers section>",
35
35
  name: "report-writer",
36
36
  subagent_type: "report-writer-worker",
37
- team_name: "okstra-<task-key>", # omit if team is not alive — see Resume-safe dispatch
37
+ team_name: "<teamName recorded in team-state>", # omit if team is not alive — see Resume-safe dispatch
38
38
  model: "<family token of Report writer worker's modelExecutionValue>", # opus/sonnet/haiku — NOT hardcoded; see below
39
39
  mode: "auto"
40
40
  )
@@ -68,7 +68,7 @@ The prompt MUST include, in this order at the top:
68
68
 
69
69
  A resumed lead session can ALWAYS dispatch a fresh Report writer worker. The Agent tool does not require a previously created Team to be alive:
70
70
 
71
- - If `TeamCreate` for `okstra-<task-key>` still succeeds (or the team is still listed), include `team_name` in the dispatch.
71
+ - If `TeamCreate` for the team-state `teamName` still succeeds (or the team is still listed), include `team_name` in the dispatch.
72
72
  - If `TeamCreate` reports the name is taken or the team is gone, omit `team_name` from the dispatch — the worker still runs as a background subagent and its session is still recoverable by `agentName: "report-writer"` in `okstra-token-usage.py`.
73
73
  - Do NOT skip dispatch because of any team-related error. Record the team status in team-state and proceed without `team_name`.
74
74
 
@@ -45,8 +45,9 @@ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag
45
45
  - `kind: "pick_group"` → render a SINGLE `AskUserQuestion` whose questions array maps 1:1 to the wizard's `questions[]`. For each entry use `questions[].label`, `questions[].options[].label`, and `multiSelect: questions[].multi`. Collect the user's chosen `options[].value` per tab, build a JSON object keyed by each `questions[].step`, and submit it as a single literal `--answer '{"lead_model":"opus","claude_model":"default",...}'`. A tab the user leaves at its default still gets its `"default"`/`""` value in the JSON. Never split a `pick_group` into multiple `AskUserQuestion` calls — the wizard already capped it at 4 tabs and emits any remainder as the next prompt.
46
46
  - `kind: "text"` → write `label` as a plain text message and consume the user's NEXT message as the answer.
47
47
  - `kind: "done"` → input collection finished; move to Step 5.
48
+ - `kind: "aborted"` → the user picked 중단; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
48
49
 
49
- The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed.
50
+ The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed. Its options always include `중단` (abort); `base-ref 다시 고르기` (edit) appears only when a new worktree would be created.
50
51
 
51
52
  Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry as its own selectable `AskUserQuestion` choice, including entries that carry a `(default)` / `(recommended)` suffix. Do NOT collapse a multi-option pick into a "recommended + 직접 입력 / Other" shortlist: the wizard's `options[]` array IS the complete, authoritative choice set. Example: the `executor` step always emits `claude` / `codex` / `gemini` — show all three, never just `claude`. The run-prompt recommendation rule (1–2 추천 + 직접 입력) applies ONLY to prompts this skill authors itself (e.g. the conformance-waiver picker), never to wizard-provided `options[]`. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
52
53
 
@@ -92,7 +93,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
92
93
 
93
94
  ## Step 3: Run the prompt loop
94
95
 
95
- Repeat until `next.kind == "done"`:
96
+ Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
96
97
 
97
98
  1. **Render** the prompt according to `kind` (and `multi` for pick):
98
99
  - `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
@@ -178,7 +179,7 @@ okstra render-bundle \
178
179
  --pr-template-path "<args.pr-template-path>"
179
180
  ```
180
181
 
181
- `render-bundle` auto-supplies `--workspace-root` and forces `--render-only`. Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full rendered lead prompt. Parse the labelled lines for `TASK_ROOT` and `INSTRUCTION_SET_PATH`.
182
+ `render-bundle` auto-supplies `--workspace-root` and forces `--render-only`. Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full rendered lead prompt. Parse the labelled lines for `TASK_ROOT` and `INSTRUCTION_SET_PATH`. Also watch for an optional `okstra concurrent-run stages:` label line — present only when a concurrent run is detected (see "동시-run 감지 분기" below).
182
183
 
183
184
  The python function underneath is mutex-protected (`~/.okstra/.locks/<task-key>.lock`), writes `run-context-*.json` + `run-inputs-*.json` + all manifests + discovery files, and registers the run in `~/.okstra/recent.jsonl` with status `prepared`.
184
185
 
@@ -196,6 +197,45 @@ This is **never** a lead/worker self-exemption — only the user may waive. Offe
196
197
 
197
198
  When the user picks a waiver, append `--qa-waiver "<stageKey>:<reason>"` to the `render-bundle` invocation above. Omit the flag entirely otherwise (do **not** pass `--qa-waiver ""`). A malformed value or unknown `<stageKey>` aborts `render-bundle` with a `PrepareError`.
198
199
 
200
+ ### 동시-run 감지 분기 (concurrent-run)
201
+
202
+ `render-bundle` stdout 에 `okstra concurrent-run stages: <stages>` 라벨 라인이
203
+ 있으면(같은 task-key 의 다른 implementation run 이 `<stages>` 를 점유 중), launch
204
+ 프롬프트는 이미 "Concurrent-run: no-team background" 게이트로 렌더돼 있다. 이 라인이
205
+ 없으면 동시-run 이 아니므로 이 분기를 건너뛴다. 라인이 있으면 dispatch 전에
206
+ 사용자에게 3-옵션 recommendation picker 를 제시한다 (run-prompt recommendation 규칙:
207
+ 1–2 추천 + 직접 입력; 이 picker 는 스킬이 author 하는 것이라 wizard `options[]`
208
+ 제약과 무관):
209
+
210
+ 1. (추천) 이대로 no-team background 로 진행 — 이미 렌더된 bundle 을 그대로 사용한다.
211
+ team 을 만들지 않아 `~/.claude/teams/` race 를 회피한다(Teams split-pane 관찰성만 포기).
212
+ 2. 대기 — 지금 dispatch 를 보류한다. stage worktree·run-context 는 보존되므로,
213
+ 점유 중인 다른 run 종료 후 같은 stage 를 resume 으로 재개하면 그때는 정상 team
214
+ 경로다. resume 명령(`okstra-inspect` history → resume)을 사용자에게 출력한다.
215
+ 3. 직접 입력.
216
+
217
+ ### Stale git SHA recovery (git-reconcile gate)
218
+
219
+ `render-bundle` 이 `Recorded stage SHAs no longer match the git history` 를 포함한
220
+ `PrepareError` 로 실패하면, okstra 밖에서 git 히스토리가 바뀐 것이다(rebase /
221
+ squash / 리뷰 반영 amend / branch 삭제). 절대 registry/consumers 를 손으로
222
+ 고치지 말고 다음 순서로 회복한다:
223
+
224
+ 1. 에러 메시지에 인쇄된 `okstra git-reconcile … --check --json` 명령을 그대로 실행해
225
+ stale 리포트를 얻는다. (patch-id 로 내용 동일성이 증명되는 항목은 prepare
226
+ 가 이미 자동 화해했으므로, 여기 남는 것은 confirm 항목뿐이다.)
227
+ 2. confirm 항목별로 사용자에게 3-옵션 picker 를 제시한다:
228
+ - **`stage-<N>` branch 의 현재 tip 으로 재기록 (추천)** — 리뷰 반영 등
229
+ 의도된 수정이 그 branch 에 있을 때.
230
+ - **다른 ref 직접 입력** — 사용자가 commit/branch/tag 를 직접 지정.
231
+ - **중단** — 회복하지 않고 run 을 멈춘다.
232
+ 3. 선택된 ref 로 `okstra git-reconcile … --apply --stage <N> --use-ref <ref>`
233
+ 를 실행한 뒤, 실패했던 `render-bundle` 을 동일 인자로 재시도한다.
234
+
235
+ anchor(`implementation_base_commit`)가 unresolvable 로 보고되면 같은 명령의
236
+ `--reset-anchor <ref>` 를 사용자 확인 후 실행한다. picker 없이 confirm 항목을
237
+ 보정하는 것은 금지 — 런타임도 `--use-ref` 없는 confirm 보정을 거부한다.
238
+
199
239
  ## Step 6: Take over as Claude lead
200
240
 
201
241
  Read `<INSTRUCTION_SET_PATH>/claude-execution-prompt.md` verbatim and enter `Claude lead` mode. The lead prompt now points to compact intake artifacts first (`active-run-context`, `analysis-profile.md`, and `analysis-packet.md`); full source files such as `analysis-material.md`, `reference-expectations.md`, and `final-report-template.md` are lazy/fallback inputs. Follow the rendered prompt order, do not preempt it.
@@ -54,7 +54,7 @@ Only workers selected from `recommendedWorkers` in `task-manifest.json` and `res
54
54
 
55
55
  ## Operating Rules
56
56
 
57
- 0. **TeamCreate ordering (BLOCKING).** Before issuing any `Agent` dispatch that includes `team_name`, Lead MUST have called `TeamCreate(team_name: "okstra-<task-key>", ...)` in this run and recorded the outcome in team-state as `teamCreate: { attempted: true, status: "ok"|"error", error?: <message> }`. If the Agent tool rejects a dispatch with `"team must be created first or call without team_name"` / `"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct response is to go back to Phase 3 and call `TeamCreate` — NOT to strip `team_name` and retry. The no-`team_name` Phase 5 fallback is only legal when `teamCreate.status == "error"` is already recorded; otherwise stripping `team_name` silently degrades the run to in-process background dispatch and loses the Teams split-pane behavior. See [okstra agent SKILL.md Phase 3](../../agents/SKILL.md) for the full team-creation sequence.
57
+ 0. **TeamCreate ordering (BLOCKING).** Before issuing any `Agent` dispatch that includes `team_name`, Lead MUST have called `TeamCreate` with the exact team name from the launch prompt's Team Creation Gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`) in this run and recorded the outcome in team-state as `teamCreate: { attempted: true, status: "ok"|"error", error?: <message> }` plus the name as `teamName`. On a "team already exists" failure, follow the stale-team recovery in [okstra agent SKILL.md Phase 3 step 2-1](../../agents/SKILL.md) — never shell-delete `~/.claude/teams/...` on your own initiative. If the Agent tool rejects a dispatch with `"team must be created first or call without team_name"` / `"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct response is to go back to Phase 3 and call `TeamCreate` — NOT to strip `team_name` and retry. The no-`team_name` Phase 5 fallback is legal when `teamCreate.status == "error"` is already recorded, OR when the launch prompt's concurrent-run gate recorded `status: "skipped", reason: "concurrent-run"`; otherwise stripping `team_name` silently degrades the run to in-process background dispatch and loses the Teams split-pane behavior. See [okstra agent SKILL.md Phase 3](../../agents/SKILL.md) for the full team-creation sequence.
58
58
  1. `Claude lead` is responsible for orchestration, convergence supervision, and final-report review/approval. It never overrides worker analysis results, and it never authors the final-report file when `Report writer worker` is in the roster.
59
59
  2. `Report writer worker` is NOT an analysis worker. It is excluded from Phase 4/5 (initial analysis) and Phase 5.5 (convergence re-verification). It is spawned only in Phase 6 and is the **author** of the final-report file at `runs/<task-type>/reports/final-report-<task-type>-<seq>.md`.
60
60
  3. When `Report writer worker` is in the roster, Lead MUST dispatch it in Phase 6. The only legal lead-authored fallback is when a dispatch was attempted and recorded a terminal status of `error` / `timeout` / `not-run` with a concrete logged reason. Speculative reasons such as "session resume constraint" or "team is no longer alive" are NOT valid — Lead can always dispatch a fresh subagent (omit `team_name` if the team is gone).
@@ -406,7 +406,7 @@ okstra token-usage /abs/path/to/run/state/team-state-<task-type>-<seq>.json --wr
406
406
  `okstra token-usage` is a thin Node-side wrapper around the python helper installed at `~/.okstra/bin/okstra-token-usage.py`. Calling the python script directly with `python3 "$HOME/..."` is forbidden — the `$HOME` expansion breaks the literal-token permission match and forces a confirmation prompt every call.
407
407
 
408
408
  The script reads:
409
- - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
409
+ - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by the recorded team-state `teamName`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
410
410
  - `~/.codex/sessions/Y/M/D/rollout-*.jsonl` for the underlying Codex CLI session (matched by `cwd` and timestamp window of the wrapper subagent). Last `event_msg.token_count.total_token_usage.total_tokens` is the session total.
411
411
  - `~/.gemini/tmp/<project>/chats/session-*.json` for the underlying Gemini CLI session. Sum of per-message `tokens.total`.
412
412