okstra 0.67.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/okstra +7 -0
- package/docs/kr/architecture.md +17 -1
- package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
- package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
- package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
- package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
- package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
- package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +1 -1
- package/runtime/python/okstra_ctl/consumers.py +72 -5
- package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
- package/runtime/python/okstra_ctl/handoff.py +348 -0
- package/runtime/python/okstra_ctl/render.py +44 -2
- package/runtime/python/okstra_ctl/run.py +88 -27
- package/runtime/python/okstra_ctl/wizard.py +25 -4
- package/runtime/python/okstra_ctl/worktree.py +10 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +1 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +49 -9
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
|
@@ -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 =
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
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={
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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)
|
|
@@ -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: "
|
|
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: "
|
|
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
|
|
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(
|
|
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
|
|
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
|
|