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.
- package/bin/okstra +25 -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 +8 -7
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/gemini-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +5 -5
- package/runtime/python/okstra_ctl/conformance.py +17 -0
- 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 +175 -44
- package/runtime/python/okstra_ctl/wizard.py +89 -22
- package/runtime/python/okstra_ctl/worktree.py +28 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/python/okstra_token_usage/collect.py +27 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +8 -8
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -7
- package/runtime/validators/validate-run.py +51 -11
- package/src/_python-helper.mjs +52 -0
- package/src/error-log.mjs +19 -0
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
- package/src/inject-report-index.mjs +22 -0
- package/src/render-final-report.mjs +22 -0
- package/src/render-views.mjs +9 -48
- package/src/spawn-followups.mjs +23 -0
- 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
|
-
|
|
1496
|
-
t
|
|
1514
|
+
label = (
|
|
1515
|
+
t.get("label_final_verification", t["label"])
|
|
1497
1516
|
if state.task_type == "final-verification"
|
|
1498
|
-
else t["
|
|
1517
|
+
else t["label"]
|
|
1499
1518
|
)
|
|
1500
|
-
options = [
|
|
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=
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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 =
|
|
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":
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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(
|
|
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 `
|
|
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: "
|
|
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
|
|
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: "
|
|
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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|