okstra 0.67.0 → 0.69.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 (50) hide show
  1. package/bin/okstra +25 -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 +8 -7
  13. package/runtime/agents/workers/claude-worker.md +1 -1
  14. package/runtime/agents/workers/codex-worker.md +3 -3
  15. package/runtime/agents/workers/gemini-worker.md +3 -3
  16. package/runtime/agents/workers/report-writer-worker.md +2 -2
  17. package/runtime/prompts/launch.template.md +2 -2
  18. package/runtime/prompts/profiles/_common-contract.md +6 -6
  19. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  20. package/runtime/prompts/profiles/_implementation-executor.md +3 -1
  21. package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
  22. package/runtime/prompts/profiles/final-verification.md +3 -2
  23. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  24. package/runtime/prompts/profiles/release-handoff.md +12 -5
  25. package/runtime/prompts/wizard/prompts.ko.json +5 -5
  26. package/runtime/python/okstra_ctl/conformance.py +17 -0
  27. package/runtime/python/okstra_ctl/consumers.py +72 -5
  28. package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
  29. package/runtime/python/okstra_ctl/handoff.py +348 -0
  30. package/runtime/python/okstra_ctl/render.py +44 -2
  31. package/runtime/python/okstra_ctl/run.py +175 -44
  32. package/runtime/python/okstra_ctl/wizard.py +89 -22
  33. package/runtime/python/okstra_ctl/worktree.py +28 -0
  34. package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
  35. package/runtime/python/okstra_token_usage/collect.py +27 -0
  36. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  37. package/runtime/skills/okstra-convergence/SKILL.md +3 -3
  38. package/runtime/skills/okstra-report-writer/SKILL.md +8 -8
  39. package/runtime/skills/okstra-run/SKILL.md +43 -3
  40. package/runtime/skills/okstra-team-contract/SKILL.md +7 -7
  41. package/runtime/validators/validate-run.py +51 -11
  42. package/src/_python-helper.mjs +52 -0
  43. package/src/error-log.mjs +19 -0
  44. package/src/git-reconcile.mjs +31 -0
  45. package/src/handoff.mjs +30 -0
  46. package/src/inject-report-index.mjs +22 -0
  47. package/src/render-final-report.mjs +22 -0
  48. package/src/render-views.mjs +9 -48
  49. package/src/spawn-followups.mjs +23 -0
  50. package/src/token-usage.mjs +3 -34
@@ -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 = ""
@@ -760,6 +763,22 @@ def _resolve_reuse_worktree(state: WizardState) -> bool:
760
763
  return bool(entry and entry.status == "active")
761
764
 
762
765
 
766
+ def _base_ref_required(state: WizardState) -> bool:
767
+ return state.task_type != "final-verification" and state.reuse_worktree is False
768
+
769
+
770
+ def _base_ref_ready(state: WizardState) -> bool:
771
+ return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
772
+
773
+
774
+ def _branch_confirm_required(state: WizardState) -> bool:
775
+ return state.task_type != "final-verification"
776
+
777
+
778
+ def _stage_auto_allowed(state: WizardState) -> bool:
779
+ return state.task_type == "implementation"
780
+
781
+
763
782
  def _existing_task_brief(project_root: Path, task_key: str) -> str:
764
783
  """Read taskBriefPath from manifest for an existing task. Empty if none."""
765
784
  root = find_task_root(project_root, task_key)
@@ -1492,12 +1511,14 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1492
1511
  stages, _errs = mod._parse_stage_map(plan_text)
1493
1512
  finally:
1494
1513
  _sys.modules.pop("_ip_stage_v_wizard", None)
1495
- auto_label = (
1496
- t["options"].get("auto_final_verification", t["options"]["auto"])
1514
+ label = (
1515
+ t.get("label_final_verification", t["label"])
1497
1516
  if state.task_type == "final-verification"
1498
- else t["options"]["auto"]
1517
+ else t["label"]
1499
1518
  )
1500
- options = [_opt("auto", auto_label)]
1519
+ options = []
1520
+ if _stage_auto_allowed(state):
1521
+ options.append(_opt("auto", t["options"]["auto"]))
1501
1522
  for s in stages:
1502
1523
  depends = ",".join(map(str, s.depends_on)) or "(none)"
1503
1524
  options.append(_opt(
@@ -1506,7 +1527,7 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1506
1527
  ))
1507
1528
  return Prompt(
1508
1529
  step=S_STAGE_PICK, kind="pick",
1509
- label=t["label"],
1530
+ label=label,
1510
1531
  options=options,
1511
1532
  echo_template=t["echo_template"],
1512
1533
  )
@@ -1515,7 +1536,12 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1515
1536
  def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1516
1537
  if not answer:
1517
1538
  raise WizardError("value required")
1518
- if answer != "auto":
1539
+ if answer == "auto":
1540
+ if not _stage_auto_allowed(state):
1541
+ raise WizardError(
1542
+ "final-verification requires an explicit stage number"
1543
+ )
1544
+ else:
1519
1545
  try:
1520
1546
  int(answer)
1521
1547
  except ValueError:
@@ -2201,6 +2227,7 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
2201
2227
  options = [_opt("proceed", opts["proceed"])]
2202
2228
  if decision.status == "new":
2203
2229
  options.append(_opt("edit", opts["edit"]))
2230
+ options.append(_opt("abort", opts["abort"]))
2204
2231
  return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
2205
2232
  options=options, echo_template=t["echo_template"])
2206
2233
 
@@ -2210,8 +2237,13 @@ def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
2210
2237
  _reset_from(state, S_BASE_REF_PICK)
2211
2238
  state.branch_confirmed = None
2212
2239
  return "branch-confirm: edit"
2240
+ if value == "abort":
2241
+ state.aborted = True
2242
+ return "branch-confirm: abort"
2213
2243
  if value != "proceed":
2214
- raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
2244
+ raise WizardError(
2245
+ f"expected 'proceed' / 'edit' / 'abort', got: {value!r}"
2246
+ )
2215
2247
  state.branch_confirmed = True
2216
2248
  return "branch-confirm: proceed"
2217
2249
 
@@ -2343,7 +2375,7 @@ STEPS: list[Step] = [
2343
2375
  owns=("keep_existing_brief",)),
2344
2376
  Step(S_BASE_REF_PICK,
2345
2377
  applies=lambda s: (S_TASK_TYPE in s.answered
2346
- and s.reuse_worktree is False
2378
+ and _base_ref_required(s)
2347
2379
  and S_BASE_REF_PICK not in s.answered
2348
2380
  and bool(s.brief_path)),
2349
2381
  build=_build_base_ref_pick, submit=_submit_base_ref_pick,
@@ -2358,8 +2390,7 @@ STEPS: list[Step] = [
2358
2390
  and not s.approved_plan_pending_text
2359
2391
  and S_APPROVED_PLAN_PICK not in s.answered
2360
2392
  and bool(s.brief_path)
2361
- and (s.reuse_worktree is True
2362
- or S_BASE_REF_PICK in s.answered)
2393
+ and _base_ref_ready(s)
2363
2394
  and not s.base_ref_pending_text
2364
2395
  and _latest_implementation_planning_report(s) is not None),
2365
2396
  build=_build_approved_plan_pick, submit=_submit_approved_plan_pick,
@@ -2368,8 +2399,7 @@ STEPS: list[Step] = [
2368
2399
  applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
2369
2400
  and not s.approved_plan_path
2370
2401
  and bool(s.brief_path)
2371
- and (s.reuse_worktree is True
2372
- or S_BASE_REF_PICK in s.answered)
2402
+ and _base_ref_ready(s)
2373
2403
  and not s.base_ref_pending_text
2374
2404
  and (s.approved_plan_pending_text
2375
2405
  or _latest_implementation_planning_report(s) is None)),
@@ -2539,11 +2569,16 @@ STEPS: list[Step] = [
2539
2569
  build=_build_pr_template_scope, submit=_submit_pr_template_scope,
2540
2570
  owns=("pr_template_scope",)),
2541
2571
  Step(S_BRANCH_CONFIRM,
2542
- applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is None,
2572
+ applies=lambda s: (_ready_for_confirm(s)
2573
+ and _branch_confirm_required(s)
2574
+ and s.branch_confirmed is None),
2543
2575
  build=_build_branch_confirm, submit=_submit_branch_confirm,
2544
2576
  owns=("branch_confirmed",)),
2545
2577
  Step(S_CONFIRM,
2546
- applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is True and s.confirmed is None,
2578
+ applies=lambda s: (_ready_for_confirm(s)
2579
+ and (not _branch_confirm_required(s)
2580
+ or s.branch_confirmed is True)
2581
+ and s.confirmed is None),
2547
2582
  build=_build_confirm, submit=_submit_confirm,
2548
2583
  owns=("confirmed", "edit_target")),
2549
2584
  Step(S_EDIT_TARGET,
@@ -2561,7 +2596,7 @@ def _identity_ready(s: WizardState) -> bool:
2561
2596
  return False
2562
2597
  if not s.brief_path:
2563
2598
  return False
2564
- if s.reuse_worktree is False and S_BASE_REF_PICK not in s.answered:
2599
+ if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
2565
2600
  return False
2566
2601
  if s.base_ref_pending_text:
2567
2602
  return False
@@ -2685,6 +2720,8 @@ def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
2685
2720
 
2686
2721
 
2687
2722
  def next_prompt(state: WizardState) -> Prompt:
2723
+ if state.aborted:
2724
+ return Prompt(step=S_ABORTED, kind="aborted")
2688
2725
  if state.confirmed:
2689
2726
  return Prompt(step=S_DONE, kind="done")
2690
2727
  for step in STEPS:
@@ -2731,7 +2768,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2731
2768
  validation failure (caller may re-prompt).
2732
2769
  """
2733
2770
  prompt = next_prompt(state)
2734
- if prompt.kind == "done":
2771
+ if prompt.kind in ("done", "aborted"):
2735
2772
  return {"echo": "", "next": prompt.to_json()}
2736
2773
  if prompt.kind == "pick_group":
2737
2774
  return _submit_group(state, prompt, value)
@@ -2745,10 +2782,28 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2745
2782
 
2746
2783
  def render_args(state: WizardState) -> dict[str, str]:
2747
2784
  """Convert finalized state into ``okstra render-bundle`` argument map."""
2785
+ if state.aborted:
2786
+ raise WizardError(
2787
+ "wizard was aborted by the user — render-args is unavailable"
2788
+ )
2748
2789
  workers = state.workers_override.strip()
2749
2790
  if state.task_type == "implementation":
2750
2791
  workers = "" # profile-default roster is mandatory for impl
2751
- base_ref = "" if state.reuse_worktree else state.base_ref
2792
+ base_ref = (
2793
+ ""
2794
+ if state.reuse_worktree or state.task_type == "final-verification"
2795
+ else state.base_ref
2796
+ )
2797
+ if state.task_type == "implementation":
2798
+ stage = state.selected_stage or "auto"
2799
+ elif state.task_type == "final-verification":
2800
+ if not state.selected_stage or state.selected_stage == "auto":
2801
+ raise WizardError(
2802
+ "final-verification requires an explicit stage number"
2803
+ )
2804
+ stage = state.selected_stage
2805
+ else:
2806
+ stage = ""
2752
2807
  pr_template = (
2753
2808
  state.pr_template_path
2754
2809
  if state.task_type == "release-handoff"
@@ -2764,7 +2819,7 @@ def render_args(state: WizardState) -> dict[str, str]:
2764
2819
  "executor": state.executor,
2765
2820
  "critic": state.critic,
2766
2821
  "approved-plan": state.approved_plan_path,
2767
- "stage": (state.selected_stage or "auto") if state.task_type in _STAGE_SCOPED_TASK_TYPES else "",
2822
+ "stage": stage,
2768
2823
  "base-ref": base_ref,
2769
2824
  "workers": workers,
2770
2825
  "directive": state.directive,
@@ -2786,7 +2841,9 @@ def confirmation_block(state: WizardState) -> str:
2786
2841
  lines.append(f" task-type : {state.task_type}")
2787
2842
  lines.append(f" task-key : {state.task_group}/{state.task_id}")
2788
2843
  lines.append(f" brief : {state.brief_path or '(none)'}")
2789
- if state.reuse_worktree:
2844
+ if state.task_type == "final-verification":
2845
+ lines.append(" base-ref : (selected stage worktree)")
2846
+ elif state.reuse_worktree:
2790
2847
  lines.append(" base-ref : (reusing existing worktree)")
2791
2848
  else:
2792
2849
  lines.append(f" base-ref : {state.base_ref}")
@@ -2814,7 +2871,11 @@ def confirmation_block(state: WizardState) -> str:
2814
2871
  lines.append(f" critic : {state.critic or '(off)'}")
2815
2872
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
2816
2873
  lines.append(f" approved-plan : {state.approved_plan_path}")
2817
- lines.append(f" stage : {state.selected_stage or 'auto'}")
2874
+ stage = (
2875
+ state.selected_stage
2876
+ or ("auto" if state.task_type == "implementation" else "(not selected)")
2877
+ )
2878
+ lines.append(f" stage : {stage}")
2818
2879
  if state.clarification_response_path:
2819
2880
  lines.append(f" clarification : {state.clarification_response_path}")
2820
2881
  if state.task_type == "release-handoff" and state.pr_template_path:
@@ -2928,7 +2989,13 @@ def _cli(argv: list[str]) -> int:
2928
2989
 
2929
2990
  if args.cmd == "render-args":
2930
2991
  state = load_state_file(state_path)
2931
- print(json.dumps({"ok": True, "args": render_args(state)},
2992
+ try:
2993
+ rendered = render_args(state)
2994
+ except WizardError as exc:
2995
+ print(json.dumps({"ok": False, "error": str(exc)},
2996
+ ensure_ascii=False, indent=2))
2997
+ return 0
2998
+ print(json.dumps({"ok": True, "args": rendered},
2932
2999
  ensure_ascii=False, indent=2))
2933
3000
  return 0
2934
3001
 
@@ -373,6 +373,24 @@ def _resolve_snapshot_files(project_root: Optional[Path] = None) -> tuple[str, .
373
373
  )
374
374
 
375
375
 
376
+ def okstra_clean_gate_excludes(project_root: Optional[Path] = None) -> tuple[str, ...]:
377
+ """Project-relative paths okstra owns and source clean gates should ignore."""
378
+ out: list[str] = []
379
+ seen: set[str] = set()
380
+ for rel in (
381
+ ".okstra",
382
+ *_resolve_sync_dirs(project_root),
383
+ *_resolve_sync_files(project_root),
384
+ *_resolve_snapshot_files(project_root),
385
+ ):
386
+ cleaned = rel.strip().removeprefix("./").rstrip("/")
387
+ if not cleaned or cleaned == "." or cleaned in seen:
388
+ continue
389
+ seen.add(cleaned)
390
+ out.append(cleaned)
391
+ return tuple(out)
392
+
393
+
376
394
  def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
377
395
  """Symlink each configured dir from `source_root` (the MAIN
378
396
  worktree) into the new worktree.
@@ -492,11 +510,14 @@ def compute_worktree_path(
492
510
  task_group_segment: str,
493
511
  task_id_segment: str,
494
512
  stage_number: Optional[int] = None,
513
+ group_id: Optional[str] = None,
495
514
  ) -> Path:
496
515
  """Pure path computation. One worktree dir per task-key, or per
497
516
  `<task-key>/stage-<N>` when stage_number is given (implementation
498
517
  stage isolation). Uses `OKSTRA_HOME` when set (test hook), else
499
518
  `~/.okstra`."""
519
+ if stage_number is not None and group_id is not None:
520
+ raise ValueError("stage_number and group_id are mutually exclusive")
500
521
  okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
501
522
  base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
502
523
  path = (
@@ -507,6 +528,8 @@ def compute_worktree_path(
507
528
  )
508
529
  if stage_number is not None:
509
530
  path = path / f"stage-{stage_number}"
531
+ if group_id is not None:
532
+ path = path / f"group-{group_id}"
510
533
  return path
511
534
 
512
535
 
@@ -515,12 +538,17 @@ def compute_branch_name(
515
538
  work_category: str,
516
539
  task_id_segment: str,
517
540
  stage_number: Optional[int] = None,
541
+ group_id: Optional[str] = None,
518
542
  ) -> str:
519
543
  """One branch per task-key, or `<prefix>-<task-id>-s<N>` for an
520
544
  implementation stage worktree."""
545
+ if stage_number is not None and group_id is not None:
546
+ raise ValueError("stage_number and group_id are mutually exclusive")
521
547
  name = f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
522
548
  if stage_number is not None:
523
549
  name = f"{name}-s{stage_number}"
550
+ if group_id is not None:
551
+ name = f"{name}-{group_id}"
524
552
  return name
525
553
 
526
554
 
@@ -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)
@@ -183,6 +183,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
183
183
  # silently (observed in dev-9692 error-analysis: claude/codex workers
184
184
  # dispatched without `name` → both unavailable, report-writer named → fine).
185
185
  unattributed_sessions: list[str] = []
186
+ unattributed_totals: list[dict] = []
186
187
  for sid, path in claude_sessions.items():
187
188
  if sid == lead_sid:
188
189
  lead_path = path
@@ -194,6 +195,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
194
195
  by_agent.setdefault(agent, []).append((sid, path, totals))
195
196
  else:
196
197
  unattributed_sessions.append(sid)
198
+ unattributed_totals.append(totals)
197
199
 
198
200
  # Lead.
199
201
  if lead_path is not None:
@@ -273,6 +275,26 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
273
275
  block["cliNote"] = f"{agent} CLI session found but no usage recorded (likely errored before completion)"
274
276
  worker["usage"] = block
275
277
 
278
+ # Fold team-tagged worker sessions that carry no agentName into the worker
279
+ # pool. They cannot be mapped to a specific workerId (so each named worker
280
+ # row above stays `unavailable`), but the tokens are real team-worker spend —
281
+ # most often an in-process teammate whose work is commingled in a team-tagged
282
+ # session the harness never tagged with `name`. Without this, the run-level
283
+ # Worker total reads 0 and the report validator hard-fails a legitimate run.
284
+ # Attribution is aggregate, not per-worker; usageSummary records it openly.
285
+ unattributed_usage = None
286
+ if unattributed_totals:
287
+ unattributed_usage = usage_block(
288
+ _aggregate_totals(unattributed_totals), source="claude-jsonl"
289
+ )
290
+ unattributed_usage["sessionIds"] = unattributed_sessions
291
+ unattributed_usage["note"] = (
292
+ "Team-tagged worker session(s) with no agentName (dispatched without "
293
+ "the Agent `name` arg, or an in-process teammate commingled with the "
294
+ "lead). Folded into the worker pool as an aggregate because they "
295
+ "cannot be mapped to a specific workerId."
296
+ )
297
+
276
298
  # Aggregate summary.
277
299
  lead = state.get("leadUsage") or {}
278
300
  workers = state.get("workers", [])
@@ -283,6 +305,10 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
283
305
  worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
284
306
  worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
285
307
  cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
308
+ if unattributed_usage is not None:
309
+ worker_total += unattributed_usage.get("totalTokens", 0) or 0
310
+ worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
311
+ worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
286
312
 
287
313
  # Surface models whose pricing lookup failed so the silent-zero case is visible.
288
314
  unmatched_models: list[str] = []
@@ -312,6 +338,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
312
338
  "sessionsFound": len(claude_sessions),
313
339
  "unmatchedModels": sorted(set(unmatched_models)),
314
340
  "unattributedTeamSessions": unattributed_sessions,
341
+ "unattributedWorkerUsage": unattributed_usage,
315
342
  "definitions": {
316
343
  "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
317
344
  "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
@@ -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/`.
@@ -169,7 +169,7 @@ A reverify dispatch that returns a **terminal non-result** (`timeout`, `error`,
169
169
  Rules:
170
170
 
171
171
  1. For each affected finding, append a `votes[W].verdict = "verification-error"` entry instead of `disagree`, plus the wrapper's captured exit reason in `votes[W].explanation`.
172
- 2. Record one event per failed dispatch via `python3 scripts/okstra-error-log.py append-observed --error-type cli-failure --agent <worker> ...` (the worker wrapper does this for Codex/Gemini; for Claude worker timeouts the lead does it).
172
+ 2. Record one event per failed dispatch via `okstra error-log append-observed --error-type cli-failure --agent <worker> ...` (the worker wrapper does this for Codex/Gemini; for Claude worker timeouts the lead does it).
173
173
  3. Add an entry to the round's `skippedWorkers[]` with `{worker: <W>, reason: "dispatch-non-result", terminalStatus: <timeout|error|not-run>}`.
174
174
  4. If at least one dispatch was issued AND all reverify dispatches in a round terminate as non-result (mirroring the pseudocode's `len(dispatches) > 0` guard), the round is treated as gate-closed: write `round2SkippedReason: "all-reverify-non-result"` (even if the round in question is round 1 — i.e. round 2 never runs because round 1 produced no usable votes), record one `contract-violation` event per non-result dispatch, and exit the WHILE loop.
175
175
  5. Section 6 (Specialization Lens) of a worker output is OUT of convergence scope per "Convergence scope" above — its absence is NEVER a `verification-error`.
@@ -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
  )
@@ -293,7 +293,7 @@ Assigned worker prompt history path: <Project Root>/<Prompt History Path>
293
293
  2. `team-state-<task-type>-<seq>.json` → `workers[].usage.cliModel` for that role (initial run's actual execution value)
294
294
  3. The `**Model:**` line of the initial Phase 4 prompt for that role (read from its persisted prompt-history file)
295
295
 
296
- If none of the three is available, **abort the reverify dispatch for that role** and record a `contract-violation` event via `okstra-error-log.py append-observed`. Do NOT guess, do NOT fall back to training-data defaults — for codex this would silently produce `o4-mini` instead of the assigned `gpt-5.5`-class model, which is a real bug class observed in production.
296
+ If none of the three is available, **abort the reverify dispatch for that role** and record a `contract-violation` event via `okstra error-log append-observed`. Do NOT guess, do NOT fall back to training-data defaults — for codex this would silently produce `o4-mini` instead of the assigned `gpt-5.5`-class model, which is a real bug class observed in production.
297
297
 
298
298
  For Codex/Gemini wrapper subagents, the `**Model:** <role>, <modelExecutionValue>` line is what their wrapper extracts to pass into the underlying CLI's `--model` flag. Omitting it forces the wrapper to fall back to its own training-data knowledge of the CLI's historical default.
299
299
 
@@ -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
  )
@@ -60,7 +60,7 @@ The prompt MUST include, in this order at the top:
60
60
  before the dispatch is constructed. The worker copies this verbatim
61
61
  into `data.json.meta.reportLanguage`.
62
62
  11. For implementation-planning runs: a literal block listing the 8 required English section headings the validator scans for (`Option Candidates`, `Trade-off`, `Recommended Option`, `Stepwise Execution Order`, `Dependency`, `Validation Checklist`, `Rollback`, `User Approval Request`). The writer must use these exact substrings as section headings (Korean translation in parentheses is allowed).
63
- 12. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "python3 scripts/okstra-render-final-report.py <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
63
+ 12. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "okstra render-final-report <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
64
64
 
65
65
  **Completion detection after dispatch (BLOCKING).** The `Agent(... team_name ...)` call returns `Spawned successfully` immediately; that ack is NOT completion. After dispatching the report-writer (async), Lead MUST detect its completion via the self-scheduled polling protocol in [okstra-team-contract](../okstra-team-contract/SKILL.md) "Worker-completion detection (self-scheduled polling)", polling for the appearance of the data.json (Result Path) and the worker-results file (Worker Result Path) — do NOT restate the algorithm here. Report-writer is a single worker, so the pending set has one entry; the SSOT protocol handles that naturally. Lead MUST NOT treat the `Spawned successfully` ack as completion and MUST NOT end its turn with a prose "waiting for the report" statement; that path stalls the run until the user manually nudges it.
66
66
 
@@ -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
 
@@ -78,7 +78,7 @@ Except for `release-handoff` (which is single-lead by design and never dispatche
78
78
 
79
79
  1. A Report writer worker dispatch was actually attempted (Agent call was issued).
80
80
  2. The attempt recorded a terminal status of `error`, `timeout`, or `not-run` with a concrete reason (tool error message, timeout duration, or external blocker).
81
- 3. The reason is logged via `okstra-error-log.py append-observed --error-type cli-failure ...` (or `tool-failure` if the failure was internal).
81
+ 3. The reason is logged via `okstra error-log append-observed --error-type cli-failure ...` (or `tool-failure` if the failure was internal).
82
82
 
83
83
  Speculative reasons such as "session resume constraint", "team object no longer exists", or "lead can do it faster" are NOT valid.
84
84
 
@@ -90,7 +90,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
90
90
  2. **Phase 7 step 1 — Token-usage collector with `--substitute-data`** (BLOCKING). One invocation aggregates `leadUsage` / `workers[].usage` / `usageSummary` into team-state AND populates `tokenUsage` + `executionStatus[].totalTokens` etc. in the data.json AND re-invokes the renderer so the sibling markdown carries the real numbers. Skipping the flag ships a markdown full of `--` cells.
91
91
 
92
92
  ```bash
93
- python3 scripts/okstra-token-usage.py \
93
+ okstra token-usage \
94
94
  <runDirectoryPath>/state/team-state-<task-type>-<seq>.json \
95
95
  --write --summary \
96
96
  --substitute-data <runDirectoryPath>/reports/final-report-<task-type>-<seq>.data.json
@@ -100,7 +100,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
100
100
  3. **Phase 7 step 1.5 — Render report views** (BLOCKING, conditional output). Always invoke the renderer; it decides whether an html sibling is warranted:
101
101
 
102
102
  ```bash
103
- python3 scripts/okstra-render-report-views.py \
103
+ okstra render-views \
104
104
  <runDirectoryPath>/reports/final-report-<task-type>-<seq>.md
105
105
  ```
106
106
 
@@ -112,7 +112,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
112
112
  4. **Phase 7 step 2 — Follow-up task spawner** (BLOCKING when Section 4 is non-empty). Turns the report's `## 4. Follow-up Tasks (후속 작업)` rows into `tasks/<task-group>/<new-task-id>/` stubs.
113
113
 
114
114
  ```bash
115
- python3 scripts/okstra-spawn-followups.py \
115
+ okstra spawn-followups \
116
116
  <runDirectoryPath>/reports/final-report-<task-type>-<seq>.data.json \
117
117
  --project-root <project_root> \
118
118
  --task-group <task-group> \
@@ -323,7 +323,7 @@ Persistence steps that must be performed in Phase 7:
323
323
  - [ ] 5. **Update task-index.md**: Refresh human-readable summary
324
324
  - [ ] 6. **Generate final status file**: `runs/<task-type>/status/final-<task-type>-<seq>.status` (if necessary)
325
325
  - [ ] 7. **Save convergence state**: `runs/<task-type>/state/convergence-<task-type>-<seq>.json` (when convergence is enabled)
326
- - [ ] 8. **Spawn follow-up task stubs**: run `scripts/okstra-spawn-followups.py` against the final-report per the canonical spawn rule defined in "Phase 7 follow-up task spawner" above. Do not restate the trigger condition here — that section is the single source of truth. The script is idempotent across reruns.
326
+ - [ ] 8. **Spawn follow-up task stubs**: run `okstra spawn-followups` against the final-report per the canonical spawn rule defined in "Phase 7 follow-up task spawner" above. Do not restate the trigger condition here — that section is the single source of truth. The script is idempotent across reruns.
327
327
  - [ ] 9. **Human HTML report** (conditional): `runs/<task-type>/reports/final-report-<task-type>-<seq>.html` — produced by Phase 7 step 1.5 **only when the report has ≥1 §5 `C-*` clarification row** (self-contained, embeds `Export user response` button). Clarification-free reports legitimately have no html sibling; do not treat its absence as a missing artifact.
328
328
 
329
329
  ### Response after Persistence