okstra 0.75.0 → 0.77.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/README.kr.md +5 -3
- package/README.md +5 -3
- package/bin/okstra +10 -2
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +20 -4
- package/docs/pr-template-usage.md +3 -3
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/gemini-worker.md +1 -1
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -4
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +21 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -6
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +12 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
- package/runtime/python/okstra_ctl/lead_events.py +129 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/pr_template.py +1 -1
- package/runtime/python/okstra_ctl/render.py +160 -29
- package/runtime/python/okstra_ctl/run.py +78 -15
- package/runtime/python/okstra_ctl/wizard.py +23 -9
- package/runtime/python/okstra_token_usage/codex.py +12 -7
- package/runtime/python/okstra_token_usage/collect.py +243 -54
- package/runtime/python/okstra_token_usage/gemini.py +12 -7
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/validators/validate-run.py +56 -22
- package/runtime/validators/validate_session_conformance.py +121 -4
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +40 -4
- package/src/install.mjs +122 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +26 -0
- package/src/uninstall.mjs +26 -4
- /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
|
@@ -39,6 +39,7 @@ from .material import (
|
|
|
39
39
|
resolve_related_tasks,
|
|
40
40
|
)
|
|
41
41
|
from .final_report_schema import load_schema
|
|
42
|
+
from .lead_events import LeadEvent, append_lead_event
|
|
42
43
|
from .models import ModelAssignment, resolve_model_metadata
|
|
43
44
|
from .schema_excerpt import build_schema_excerpt
|
|
44
45
|
from .path_resolve import relative_to_project_root, resolve_user_file
|
|
@@ -266,6 +267,17 @@ class PrepareError(Exception):
|
|
|
266
267
|
"""surface to caller — task bundle prepare failed."""
|
|
267
268
|
|
|
268
269
|
|
|
270
|
+
_ALLOWED_LEAD_RUNTIMES = ("claude-code", "codex")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _normalize_lead_runtime(value: str) -> str:
|
|
274
|
+
runtime = (value or "claude-code").strip()
|
|
275
|
+
if runtime not in _ALLOWED_LEAD_RUNTIMES:
|
|
276
|
+
allowed = ", ".join(_ALLOWED_LEAD_RUNTIMES)
|
|
277
|
+
raise PrepareError(f"unsupported lead runtime: {runtime} (allowed: {allowed})")
|
|
278
|
+
return runtime
|
|
279
|
+
|
|
280
|
+
|
|
269
281
|
@dataclass
|
|
270
282
|
class PrepareInputs:
|
|
271
283
|
workspace_root: Path
|
|
@@ -282,6 +294,7 @@ class PrepareInputs:
|
|
|
282
294
|
codex_model: str = ""
|
|
283
295
|
gemini_model: str = ""
|
|
284
296
|
report_writer_model: str = ""
|
|
297
|
+
lead_runtime: str = "claude-code"
|
|
285
298
|
executor: str = ""
|
|
286
299
|
critic: str = ""
|
|
287
300
|
related_tasks_raw: str = ""
|
|
@@ -932,6 +945,29 @@ def _brief_sha256(path: Path) -> str:
|
|
|
932
945
|
return ""
|
|
933
946
|
|
|
934
947
|
|
|
948
|
+
def _record_codex_render_only_event(inp: PrepareInputs, ctx: dict) -> None:
|
|
949
|
+
if inp.lead_runtime != "codex" or not inp.render_only:
|
|
950
|
+
return
|
|
951
|
+
append_lead_event(
|
|
952
|
+
Path(ctx["LEAD_EVENTS_PATH"]),
|
|
953
|
+
LeadEvent(
|
|
954
|
+
event_type="bundle-prepared",
|
|
955
|
+
lead_runtime="codex",
|
|
956
|
+
task_key=ctx["TASK_KEY"],
|
|
957
|
+
task_type=ctx["TASK_TYPE"],
|
|
958
|
+
run_seq=ctx["RUN_MANIFESTS_SEQ"],
|
|
959
|
+
timestamp=ctx["RUN_TIMESTAMP_ISO"],
|
|
960
|
+
details={
|
|
961
|
+
"dispatchMode": "render-only",
|
|
962
|
+
"workerDispatch": "not-started",
|
|
963
|
+
"runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
|
|
964
|
+
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
965
|
+
"promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
|
|
966
|
+
},
|
|
967
|
+
),
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
935
971
|
def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
|
|
936
972
|
"""rerun 충실 재현을 위한 canonical argv 재구성."""
|
|
937
973
|
workers = inp.workers_override or ctx.get("RECOMMENDED_ANALYSERS", "")
|
|
@@ -951,6 +987,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
|
|
|
951
987
|
("--codex-model", inp.codex_model or ctx.get("CODEX_WORKER_MODEL", "")),
|
|
952
988
|
("--gemini-model", inp.gemini_model or ctx.get("GEMINI_WORKER_MODEL", "")),
|
|
953
989
|
("--report-writer-model", inp.report_writer_model or ctx.get("REPORT_WRITER_MODEL", "")),
|
|
990
|
+
("--lead-runtime", inp.lead_runtime if inp.lead_runtime != "claude-code" else ""),
|
|
954
991
|
("--executor", inp.executor or ctx.get("EXECUTOR_PROVIDER", "")),
|
|
955
992
|
("--critic", inp.critic or ctx.get("CRITIC_CHOICE", "")),
|
|
956
993
|
("--related-tasks", inp.related_tasks_raw),
|
|
@@ -2013,6 +2050,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2013
2050
|
"""Produce a complete okstra task bundle on disk. See module docstring."""
|
|
2014
2051
|
workspace_root = Path(inp.workspace_root)
|
|
2015
2052
|
project_root = Path(inp.project_root)
|
|
2053
|
+
lead_runtime = _normalize_lead_runtime(inp.lead_runtime)
|
|
2054
|
+
if lead_runtime != "claude-code" and not inp.render_only:
|
|
2055
|
+
raise PrepareError(
|
|
2056
|
+
f"lead runtime `{lead_runtime}` is currently render-only; "
|
|
2057
|
+
"use --render-only until the Codex dispatch adapter is implemented."
|
|
2058
|
+
)
|
|
2016
2059
|
|
|
2017
2060
|
# ---- input validation + asset resolution + roster/model 해소 ----
|
|
2018
2061
|
# 각 단계는 명시적 입력/반환을 갖는 헬퍼로 분리되어 있다 (상단 phase 헬퍼
|
|
@@ -2197,6 +2240,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2197
2240
|
ctx.update({
|
|
2198
2241
|
"ANALYSIS_PROFILE": inp.task_type,
|
|
2199
2242
|
"RECOMMENDED_ANALYSERS": selected_reviewers,
|
|
2243
|
+
"LEAD_RUNTIME": lead_runtime,
|
|
2200
2244
|
"PR_TEMPLATE_PATH": pr_template_path_str,
|
|
2201
2245
|
"PR_TEMPLATE_SOURCE": pr_template_source,
|
|
2202
2246
|
**handoff_tokens,
|
|
@@ -2235,6 +2279,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2235
2279
|
"OKSTRA_VERSION": installed_version(),
|
|
2236
2280
|
**workflow_state,
|
|
2237
2281
|
})
|
|
2282
|
+
if lead_runtime == "codex":
|
|
2283
|
+
ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
|
|
2284
|
+
ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
|
|
2285
|
+
ctx["CLAUDE_RESUME_COMMAND_FILENAME"] = ""
|
|
2238
2286
|
if inp.task_type == "final-verification":
|
|
2239
2287
|
_reserve_final_verification_target(inp, ctx, ctx_stage_map)
|
|
2240
2288
|
|
|
@@ -2244,21 +2292,23 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2244
2292
|
cleanup_obsolete_generated_docs(
|
|
2245
2293
|
project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
|
|
2246
2294
|
)
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2295
|
+
if lead_runtime == "claude-code":
|
|
2296
|
+
# Always materialise the resume command script for Claude Code. Even in
|
|
2297
|
+
# --render-only preparation flows the user (or a later non-interactive
|
|
2298
|
+
# runner) may invoke it manually; deferring its creation until
|
|
2299
|
+
# interactive launch leaves runs/<phase>/sessions/ empty and the
|
|
2300
|
+
# manifest pointing at a path that does not exist. Codex runs resume via
|
|
2301
|
+
# artifacts/checkpoints instead, so they intentionally leave this empty.
|
|
2302
|
+
write_claude_resume_command_file(
|
|
2303
|
+
resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
|
|
2304
|
+
project_root=project_root,
|
|
2305
|
+
claude_session_id=claude_session_id,
|
|
2306
|
+
task_key=ctx["TASK_KEY"],
|
|
2307
|
+
task_type=ctx["TASK_TYPE"],
|
|
2308
|
+
phase_state=ctx["CURRENT_RUN_STATUS"],
|
|
2309
|
+
worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
|
|
2310
|
+
prompt_seq=ctx["RUN_PROMPTS_SEQ"],
|
|
2311
|
+
)
|
|
2262
2312
|
|
|
2263
2313
|
# ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
|
|
2264
2314
|
# instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
|
|
@@ -2280,6 +2330,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2280
2330
|
|
|
2281
2331
|
# ---- final status + manifest/discovery renders ----
|
|
2282
2332
|
_finalize_status_and_render_manifests(inp, ctx, task_index_template)
|
|
2333
|
+
_record_codex_render_only_event(inp, ctx)
|
|
2283
2334
|
|
|
2284
2335
|
# ---- central index ----
|
|
2285
2336
|
initial_status = "prepared" if inp.render_only else "running"
|
|
@@ -2354,6 +2405,17 @@ def main(argv: list[str]) -> int:
|
|
|
2354
2405
|
p.add_argument("--codex-model", default="")
|
|
2355
2406
|
p.add_argument("--gemini-model", default="")
|
|
2356
2407
|
p.add_argument("--report-writer-model", default="")
|
|
2408
|
+
p.add_argument(
|
|
2409
|
+
"--lead-runtime",
|
|
2410
|
+
default="claude-code",
|
|
2411
|
+
choices=list(_ALLOWED_LEAD_RUNTIMES),
|
|
2412
|
+
dest="lead_runtime",
|
|
2413
|
+
help=(
|
|
2414
|
+
"Lead runtime adapter. Default: claude-code. `codex` is accepted "
|
|
2415
|
+
"for render-only artifact preparation while the dispatch adapter "
|
|
2416
|
+
"is implemented."
|
|
2417
|
+
),
|
|
2418
|
+
)
|
|
2357
2419
|
p.add_argument("--executor", default="")
|
|
2358
2420
|
p.add_argument("--critic", default="")
|
|
2359
2421
|
p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
|
|
@@ -2490,6 +2552,7 @@ def main(argv: list[str]) -> int:
|
|
|
2490
2552
|
codex_model=args.codex_model,
|
|
2491
2553
|
gemini_model=args.gemini_model,
|
|
2492
2554
|
report_writer_model=args.report_writer_model,
|
|
2555
|
+
lead_runtime=args.lead_runtime,
|
|
2493
2556
|
executor=args.executor,
|
|
2494
2557
|
critic=args.critic,
|
|
2495
2558
|
related_tasks_raw=args.related_tasks_raw,
|
|
@@ -635,7 +635,12 @@ def _stage_plan_for_confirmation(
|
|
|
635
635
|
state.html_approval_sidecar = str(sidecar_path)
|
|
636
636
|
state.html_approval_option = record.implementation_option
|
|
637
637
|
t = _p(state.workspace_root, "approve_plan_confirm", path=str(p))
|
|
638
|
-
|
|
638
|
+
variants = t["echo_variants"]
|
|
639
|
+
key = ("selected_final_verification"
|
|
640
|
+
if state.task_type == "final-verification"
|
|
641
|
+
and variants.get("selected_final_verification")
|
|
642
|
+
else "selected")
|
|
643
|
+
msg = variants[key].format(path=p)
|
|
639
644
|
return f"{msg} {suffix}".rstrip() if suffix else msg
|
|
640
645
|
|
|
641
646
|
|
|
@@ -770,8 +775,10 @@ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
|
|
|
770
775
|
if raw is None:
|
|
771
776
|
raise WizardError(f"unknown wizard step_id: {step_id!r}")
|
|
772
777
|
label_template = raw.get("label", "")
|
|
778
|
+
fv_label_template = raw.get("label_final_verification", "")
|
|
773
779
|
try:
|
|
774
780
|
label = label_template.format(**vars)
|
|
781
|
+
label_final_verification = fv_label_template.format(**vars)
|
|
775
782
|
except KeyError as exc:
|
|
776
783
|
missing = exc.args[0] if exc.args else "<unknown>"
|
|
777
784
|
raise WizardError(
|
|
@@ -779,8 +786,10 @@ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
|
|
|
779
786
|
) from exc
|
|
780
787
|
return {
|
|
781
788
|
"label": label,
|
|
789
|
+
"label_final_verification": label_final_verification,
|
|
782
790
|
"echo_template": raw.get("echo_template", ""),
|
|
783
791
|
"options": raw.get("options", {}),
|
|
792
|
+
"options_final_verification": raw.get("options_final_verification", {}),
|
|
784
793
|
"options_html_approval": raw.get("options_html_approval", {}),
|
|
785
794
|
"html_approval_note": raw.get("html_approval_note", ""),
|
|
786
795
|
"html_approval_note_default_option": raw.get(
|
|
@@ -1626,6 +1635,8 @@ def _build_approved_plan_pick(state: WizardState) -> Prompt:
|
|
|
1626
1635
|
default = reports[0] if reports else None
|
|
1627
1636
|
t = _p(state.workspace_root, "approved_plan_pick",
|
|
1628
1637
|
default=str(default) if default is not None else "")
|
|
1638
|
+
is_fv = state.task_type == "final-verification"
|
|
1639
|
+
label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
|
|
1629
1640
|
other_report_label = t["labels"]["other_report"]
|
|
1630
1641
|
options: list[Option] = []
|
|
1631
1642
|
if default is not None:
|
|
@@ -1637,7 +1648,7 @@ def _build_approved_plan_pick(state: WizardState) -> Prompt:
|
|
|
1637
1648
|
options.append(_opt(PICK_OTHER, t["options"][PICK_OTHER]))
|
|
1638
1649
|
return Prompt(
|
|
1639
1650
|
step=S_APPROVED_PLAN_PICK, kind="pick",
|
|
1640
|
-
label=
|
|
1651
|
+
label=label, options=options,
|
|
1641
1652
|
echo_template=t["echo_template"],
|
|
1642
1653
|
)
|
|
1643
1654
|
|
|
@@ -1679,8 +1690,9 @@ def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
|
|
|
1679
1690
|
def _build_approve_plan_confirm(state: WizardState) -> Prompt:
|
|
1680
1691
|
t = _p(state.workspace_root, "approve_plan_confirm",
|
|
1681
1692
|
path=state.approve_plan_candidate)
|
|
1682
|
-
|
|
1683
|
-
|
|
1693
|
+
is_fv = state.task_type == "final-verification"
|
|
1694
|
+
label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
|
|
1695
|
+
options_map = (t["options_final_verification"] or t["options"]) if is_fv else t["options"]
|
|
1684
1696
|
if state.html_approval_sidecar:
|
|
1685
1697
|
label += t["html_approval_note"].format(
|
|
1686
1698
|
sidecar=state.html_approval_sidecar,
|
|
@@ -1728,7 +1740,12 @@ def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str
|
|
|
1728
1740
|
if not fully_approved:
|
|
1729
1741
|
raise WizardError(
|
|
1730
1742
|
t["errors"]["still_unapproved"].format(path=resolved))
|
|
1731
|
-
|
|
1743
|
+
variants = t["echo_variants"]
|
|
1744
|
+
approved_key = ("approved_final_verification"
|
|
1745
|
+
if state.task_type == "final-verification"
|
|
1746
|
+
and variants.get("approved_final_verification")
|
|
1747
|
+
else "approved")
|
|
1748
|
+
echo = variants[approved_key].format(path=resolved)
|
|
1732
1749
|
if apply_option:
|
|
1733
1750
|
_apply_cli_implementation_option(
|
|
1734
1751
|
str(resolved), state.html_approval_option)
|
|
@@ -1748,10 +1765,7 @@ def _build_stage_pick(state: WizardState) -> Prompt:
|
|
|
1748
1765
|
t = _p(state.workspace_root, "stage_pick")
|
|
1749
1766
|
stages = _parse_stage_objects(state)
|
|
1750
1767
|
is_fv = state.task_type == "final-verification"
|
|
1751
|
-
label = (
|
|
1752
|
-
t.get("label_final_verification", t["label"])
|
|
1753
|
-
if is_fv else t["label"]
|
|
1754
|
-
)
|
|
1768
|
+
label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
|
|
1755
1769
|
done = _done_stage_numbers(state) if is_fv else set()
|
|
1756
1770
|
options = []
|
|
1757
1771
|
if _stage_auto_allowed(state):
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from .jsonl_io import iter_jsonl
|
|
7
|
-
from .paths import CODEX_SESSIONS
|
|
7
|
+
from .paths import CODEX_SESSIONS, ts_in_window
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def codex_session_total(jsonl_path: Path) -> dict:
|
|
@@ -47,9 +47,15 @@ def codex_session_total(jsonl_path: Path) -> dict:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None:
|
|
50
|
-
"""Find the codex rollout jsonl
|
|
50
|
+
"""Find the latest codex rollout jsonl in the requested window."""
|
|
51
|
+
sessions = find_codex_sessions(cwd, started_at, ended_at)
|
|
52
|
+
return sessions[-1] if sessions else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_codex_sessions(cwd: Path, started_at: str, ended_at: str) -> list[Path]:
|
|
56
|
+
"""Find codex rollout jsonls whose meta.cwd matches the window."""
|
|
51
57
|
if not CODEX_SESSIONS.is_dir() or not started_at or not ended_at:
|
|
52
|
-
return
|
|
58
|
+
return []
|
|
53
59
|
target_cwd = str(cwd)
|
|
54
60
|
candidates: list[tuple[str, Path]] = []
|
|
55
61
|
for p in CODEX_SESSIONS.rglob("rollout-*.jsonl"):
|
|
@@ -70,11 +76,10 @@ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None
|
|
|
70
76
|
if payload.get("cwd") != target_cwd:
|
|
71
77
|
continue
|
|
72
78
|
ts = payload.get("timestamp") or rec.get("timestamp") or ""
|
|
73
|
-
if not (
|
|
79
|
+
if not ts_in_window(ts, started_at, ended_at):
|
|
74
80
|
continue
|
|
75
81
|
candidates.append((ts, p))
|
|
76
82
|
if not candidates:
|
|
77
|
-
return
|
|
83
|
+
return []
|
|
78
84
|
candidates.sort()
|
|
79
|
-
return candidates
|
|
80
|
-
|
|
85
|
+
return [path for _ts, path in candidates]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
from okstra_project.dirs import OKSTRA_RELATIVE
|
|
8
9
|
|
|
9
10
|
from .blocks import na_block, usage_block
|
|
@@ -12,8 +13,8 @@ from .claude import (
|
|
|
12
13
|
find_claude_agent_sessions,
|
|
13
14
|
find_claude_team_sessions,
|
|
14
15
|
)
|
|
15
|
-
from .codex import codex_session_total, find_codex_session
|
|
16
|
-
from .gemini import find_gemini_session, gemini_session_total
|
|
16
|
+
from .codex import codex_session_total, find_codex_session, find_codex_sessions
|
|
17
|
+
from .gemini import find_gemini_session, find_gemini_sessions, gemini_session_total
|
|
17
18
|
from .paths import claude_project_dir, utc_now
|
|
18
19
|
from .pricing import codex_cost_usd, gemini_cost_usd
|
|
19
20
|
|
|
@@ -64,6 +65,8 @@ def _aggregate_totals(items: list[dict]) -> dict:
|
|
|
64
65
|
"totalTokens": 0, "inputTokens": 0, "outputTokens": 0,
|
|
65
66
|
"cacheCreationTokens": 0, "cacheCreation5mTokens": 0, "cacheCreation1hTokens": 0,
|
|
66
67
|
"cacheReadTokens": 0,
|
|
68
|
+
"cachedInputTokens": 0, "reasoningOutputTokens": 0,
|
|
69
|
+
"cachedTokens": 0, "thoughtsTokens": 0, "toolTokens": 0,
|
|
67
70
|
"toolUses": 0, "durationMs": 0,
|
|
68
71
|
"agentName": None, "model": None,
|
|
69
72
|
"startedAt": None, "endedAt": None,
|
|
@@ -71,7 +74,8 @@ def _aggregate_totals(items: list[dict]) -> dict:
|
|
|
71
74
|
for t in items:
|
|
72
75
|
for k in ("totalTokens", "inputTokens", "outputTokens",
|
|
73
76
|
"cacheCreationTokens", "cacheCreation5mTokens", "cacheCreation1hTokens",
|
|
74
|
-
"cacheReadTokens", "
|
|
77
|
+
"cacheReadTokens", "cachedInputTokens", "reasoningOutputTokens",
|
|
78
|
+
"cachedTokens", "thoughtsTokens", "toolTokens", "toolUses"):
|
|
75
79
|
aggregate[k] += t.get(k, 0) or 0
|
|
76
80
|
if aggregate["agentName"] is None and t.get("agentName"):
|
|
77
81
|
aggregate["agentName"] = t["agentName"]
|
|
@@ -207,6 +211,233 @@ def resolve_team_name(state: dict) -> str:
|
|
|
207
211
|
return team_name
|
|
208
212
|
|
|
209
213
|
|
|
214
|
+
def _resolve_project_path(project_root: Path, raw_path: str) -> Path | None:
|
|
215
|
+
if not raw_path:
|
|
216
|
+
return None
|
|
217
|
+
path = Path(raw_path)
|
|
218
|
+
return path if path.is_absolute() else project_root / path
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_lead_event_records(project_root: Path, state: dict) -> list[dict]:
|
|
222
|
+
path = _resolve_project_path(project_root, state.get("leadEventsPath", ""))
|
|
223
|
+
if path is None or not path.is_file():
|
|
224
|
+
return []
|
|
225
|
+
records = []
|
|
226
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
227
|
+
if not line.strip():
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
record = json.loads(line)
|
|
231
|
+
except json.JSONDecodeError:
|
|
232
|
+
continue
|
|
233
|
+
if isinstance(record, dict):
|
|
234
|
+
records.append(record)
|
|
235
|
+
return records
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _codex_worker_windows(project_root: Path, state: dict) -> dict[str, list[tuple[str, str]]]:
|
|
239
|
+
active: dict[tuple[str, str], str] = {}
|
|
240
|
+
windows: dict[str, list[tuple[str, str]]] = {}
|
|
241
|
+
for event in _load_lead_event_records(project_root, state):
|
|
242
|
+
details = event.get("details") or {}
|
|
243
|
+
if not isinstance(details, dict):
|
|
244
|
+
continue
|
|
245
|
+
worker_id = str(details.get("workerId") or "").strip()
|
|
246
|
+
if not worker_id:
|
|
247
|
+
continue
|
|
248
|
+
attempt = str(details.get("attempt") or "")
|
|
249
|
+
key = (worker_id, attempt)
|
|
250
|
+
event_type = event.get("eventType")
|
|
251
|
+
timestamp = str(event.get("timestamp") or "").strip()
|
|
252
|
+
if not timestamp:
|
|
253
|
+
continue
|
|
254
|
+
if event_type == "worker-dispatched":
|
|
255
|
+
active[key] = timestamp
|
|
256
|
+
elif event_type in {"worker-result-collected", "worker-failed"}:
|
|
257
|
+
started_at = active.pop(key, "")
|
|
258
|
+
if started_at:
|
|
259
|
+
windows.setdefault(worker_id, []).append((started_at, timestamp))
|
|
260
|
+
running_workers = {
|
|
261
|
+
str(worker.get("workerId") or "").strip()
|
|
262
|
+
for worker in state.get("workers", [])
|
|
263
|
+
if isinstance(worker, dict) and worker.get("status") == "running"
|
|
264
|
+
}
|
|
265
|
+
if running_workers:
|
|
266
|
+
open_until = utc_now()
|
|
267
|
+
for (worker_id, _attempt), started_at in active.items():
|
|
268
|
+
if worker_id in running_workers:
|
|
269
|
+
windows.setdefault(worker_id, []).append((started_at, open_until))
|
|
270
|
+
return windows
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _codex_runtime_provider(worker: dict) -> str:
|
|
274
|
+
worker_id = str(worker.get("workerId") or "").strip()
|
|
275
|
+
agent = str(worker.get("agent") or "").strip()
|
|
276
|
+
if worker_id == "report-writer":
|
|
277
|
+
return "codex"
|
|
278
|
+
if agent in {"codex", "gemini"}:
|
|
279
|
+
return agent
|
|
280
|
+
return ""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cli_sessions_for_windows(
|
|
284
|
+
provider: str,
|
|
285
|
+
project_root: Path,
|
|
286
|
+
windows: list[tuple[str, str]],
|
|
287
|
+
) -> list[Path]:
|
|
288
|
+
sessions: list[Path] = []
|
|
289
|
+
seen: set[Path] = set()
|
|
290
|
+
for started_at, ended_at in windows:
|
|
291
|
+
if provider == "codex":
|
|
292
|
+
matches = find_codex_sessions(project_root, started_at, ended_at)
|
|
293
|
+
elif provider == "gemini":
|
|
294
|
+
matches = find_gemini_sessions(project_root, started_at, ended_at)
|
|
295
|
+
else:
|
|
296
|
+
matches = []
|
|
297
|
+
for path in matches:
|
|
298
|
+
if path not in seen:
|
|
299
|
+
seen.add(path)
|
|
300
|
+
sessions.append(path)
|
|
301
|
+
return sessions
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _cli_session_totals(provider: str, session_paths: list[Path]) -> list[dict]:
|
|
305
|
+
totals = []
|
|
306
|
+
for session_path in session_paths:
|
|
307
|
+
if provider == "codex":
|
|
308
|
+
total = codex_session_total(session_path)
|
|
309
|
+
else:
|
|
310
|
+
total = gemini_session_total(session_path)
|
|
311
|
+
if total.get("available"):
|
|
312
|
+
totals.append(total)
|
|
313
|
+
return totals
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _cli_usage_block(provider: str, totals: dict, session_paths: list[Path]) -> dict:
|
|
317
|
+
block = usage_block(totals, source=f"{provider}-cli")
|
|
318
|
+
block["cliTotalTokens"] = totals.get("totalTokens", 0) or 0
|
|
319
|
+
block["cliSessionPaths"] = [str(path) for path in session_paths]
|
|
320
|
+
if totals.get("model"):
|
|
321
|
+
block["model"] = totals["model"]
|
|
322
|
+
block["cliModel"] = totals["model"]
|
|
323
|
+
if provider == "codex":
|
|
324
|
+
cost = codex_cost_usd(
|
|
325
|
+
totals.get("model"),
|
|
326
|
+
totals.get("inputTokens", 0) or 0,
|
|
327
|
+
totals.get("cachedInputTokens", 0) or 0,
|
|
328
|
+
totals.get("outputTokens", 0) or 0,
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
cost = gemini_cost_usd(
|
|
332
|
+
totals.get("model"),
|
|
333
|
+
totals.get("inputTokens", 0) or 0,
|
|
334
|
+
totals.get("outputTokens", 0) or 0,
|
|
335
|
+
)
|
|
336
|
+
if cost is not None:
|
|
337
|
+
block["cliEstimatedCostUsd"] = cost
|
|
338
|
+
return block
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _collect_codex_runtime_usage(state: dict, project_root: Path) -> dict:
|
|
342
|
+
windows_by_worker = _codex_worker_windows(project_root, state)
|
|
343
|
+
state["leadUsage"] = na_block(
|
|
344
|
+
"Codex lead token accounting is not available yet; worker CLI usage is collected from provider logs."
|
|
345
|
+
)
|
|
346
|
+
for worker in state.get("workers", []):
|
|
347
|
+
if not isinstance(worker, dict):
|
|
348
|
+
continue
|
|
349
|
+
provider = _codex_runtime_provider(worker)
|
|
350
|
+
if not provider:
|
|
351
|
+
worker["usage"] = na_block(
|
|
352
|
+
f"worker provider is not CLI-backed in Codex runtime: {worker.get('agent') or worker.get('workerId')}"
|
|
353
|
+
)
|
|
354
|
+
continue
|
|
355
|
+
worker_id = str(worker.get("workerId") or "").strip()
|
|
356
|
+
windows = windows_by_worker.get(worker_id, [])
|
|
357
|
+
if not windows:
|
|
358
|
+
worker["usage"] = na_block(
|
|
359
|
+
f"no lead-events dispatch window found for workerId={worker_id}"
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
session_paths = _cli_sessions_for_windows(provider, project_root, windows)
|
|
363
|
+
totals = _cli_session_totals(provider, session_paths)
|
|
364
|
+
if not totals:
|
|
365
|
+
worker["usage"] = na_block(
|
|
366
|
+
f"no {provider} CLI session found in lead-events dispatch window"
|
|
367
|
+
)
|
|
368
|
+
continue
|
|
369
|
+
worker["usage"] = _cli_usage_block(
|
|
370
|
+
provider,
|
|
371
|
+
_aggregate_totals(totals),
|
|
372
|
+
session_paths,
|
|
373
|
+
)
|
|
374
|
+
_populate_usage_summary(state, team_name=resolve_team_name(state), sessions_found=0)
|
|
375
|
+
return state
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _populate_usage_summary(
|
|
379
|
+
state: dict,
|
|
380
|
+
*,
|
|
381
|
+
team_name: str,
|
|
382
|
+
sessions_found: int,
|
|
383
|
+
unattributed_sessions: list[str] | None = None,
|
|
384
|
+
unattributed_usage: dict[str, Any] | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
workers = state.get("workers", [])
|
|
387
|
+
lead = state.get("leadUsage") or {}
|
|
388
|
+
lead_total = lead.get("totalTokens", 0) or 0
|
|
389
|
+
lead_billable = lead.get("billableEquivalentTokens", 0) or 0
|
|
390
|
+
lead_cost = lead.get("estimatedCostUsd", 0) or 0
|
|
391
|
+
worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
|
|
392
|
+
worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
|
|
393
|
+
worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
|
|
394
|
+
cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
|
|
395
|
+
if unattributed_usage is not None:
|
|
396
|
+
worker_total += unattributed_usage.get("totalTokens", 0) or 0
|
|
397
|
+
worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
|
|
398
|
+
worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
|
|
399
|
+
|
|
400
|
+
unmatched_models: list[str] = []
|
|
401
|
+
if lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
|
|
402
|
+
unmatched_models.append(lead["model"])
|
|
403
|
+
for w in workers:
|
|
404
|
+
u = w.get("usage") or {}
|
|
405
|
+
if (
|
|
406
|
+
u.get("source") not in {"codex-cli", "gemini-cli"}
|
|
407
|
+
and u.get("model")
|
|
408
|
+
and u.get("estimatedCostUsd") is None
|
|
409
|
+
and (u.get("totalTokens") or 0) > 0
|
|
410
|
+
):
|
|
411
|
+
unmatched_models.append(u["model"])
|
|
412
|
+
if u.get("cliModel") and u.get("cliEstimatedCostUsd") is None and (u.get("cliTotalTokens") or 0) > 0:
|
|
413
|
+
unmatched_models.append(u["cliModel"])
|
|
414
|
+
state["usageSummary"] = {
|
|
415
|
+
"leadTotalTokens": lead_total,
|
|
416
|
+
"workerTotalTokens": worker_total,
|
|
417
|
+
"grandTotalTokens": lead_total + worker_total,
|
|
418
|
+
"leadBillableEquivalentTokens": lead_billable,
|
|
419
|
+
"workerBillableEquivalentTokens": worker_billable,
|
|
420
|
+
"grandBillableEquivalentTokens": lead_billable + worker_billable,
|
|
421
|
+
"estimatedCostUsd": {
|
|
422
|
+
"lead": round(lead_cost, 4),
|
|
423
|
+
"claudeWorkers": round(worker_cost, 4),
|
|
424
|
+
"cliWorkers": round(cli_cost, 4),
|
|
425
|
+
"grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
|
|
426
|
+
},
|
|
427
|
+
"collectedAt": utc_now(),
|
|
428
|
+
"teamName": team_name,
|
|
429
|
+
"sessionsFound": sessions_found,
|
|
430
|
+
"unmatchedModels": sorted(set(unmatched_models)),
|
|
431
|
+
"unattributedTeamSessions": unattributed_sessions or [],
|
|
432
|
+
"unattributedWorkerUsage": unattributed_usage,
|
|
433
|
+
"definitions": {
|
|
434
|
+
"totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
|
|
435
|
+
"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.",
|
|
436
|
+
"estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers Codex/Gemini CLI calls billed under those providers.",
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
210
441
|
def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
211
442
|
incremental: bool = True) -> dict:
|
|
212
443
|
# incremental: 세션 jsonl 스캔에 byte cursor 캐시 사용 (P6). 캐시는 윈도우
|
|
@@ -218,6 +449,8 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
|
218
449
|
task_key = state.get("taskKey", "")
|
|
219
450
|
team_name = resolve_team_name(state)
|
|
220
451
|
lead_sid = (state.get("lead") or {}).get("sessionId")
|
|
452
|
+
if state.get("leadRuntime") == "codex":
|
|
453
|
+
return _collect_codex_runtime_usage(state, cwd)
|
|
221
454
|
|
|
222
455
|
# 1) Claude sessions (lead + claude-side workers). Cache totals at scan
|
|
223
456
|
# time so we don't re-read the jsonl when a worker matches multiple
|
|
@@ -372,56 +605,13 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
|
372
605
|
"cannot be mapped to a specific workerId."
|
|
373
606
|
)
|
|
374
607
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
|
|
383
|
-
worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
|
|
384
|
-
cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
|
|
385
|
-
if unattributed_usage is not None:
|
|
386
|
-
worker_total += unattributed_usage.get("totalTokens", 0) or 0
|
|
387
|
-
worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
|
|
388
|
-
worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
|
|
389
|
-
|
|
390
|
-
# Surface models whose pricing lookup failed so the silent-zero case is visible.
|
|
391
|
-
unmatched_models: list[str] = []
|
|
392
|
-
if lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
|
|
393
|
-
unmatched_models.append(lead["model"])
|
|
394
|
-
for w in workers:
|
|
395
|
-
u = w.get("usage") or {}
|
|
396
|
-
if u.get("model") and u.get("estimatedCostUsd") is None and (u.get("totalTokens") or 0) > 0:
|
|
397
|
-
unmatched_models.append(u["model"])
|
|
398
|
-
if u.get("cliModel") and u.get("cliEstimatedCostUsd") is None and (u.get("cliTotalTokens") or 0) > 0:
|
|
399
|
-
unmatched_models.append(u["cliModel"])
|
|
400
|
-
state["usageSummary"] = {
|
|
401
|
-
"leadTotalTokens": lead_total,
|
|
402
|
-
"workerTotalTokens": worker_total,
|
|
403
|
-
"grandTotalTokens": lead_total + worker_total,
|
|
404
|
-
"leadBillableEquivalentTokens": lead_billable,
|
|
405
|
-
"workerBillableEquivalentTokens": worker_billable,
|
|
406
|
-
"grandBillableEquivalentTokens": lead_billable + worker_billable,
|
|
407
|
-
"estimatedCostUsd": {
|
|
408
|
-
"lead": round(lead_cost, 4),
|
|
409
|
-
"claudeWorkers": round(worker_cost, 4),
|
|
410
|
-
"cliWorkers": round(cli_cost, 4),
|
|
411
|
-
"grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
|
|
412
|
-
},
|
|
413
|
-
"collectedAt": utc_now(),
|
|
414
|
-
"teamName": team_name,
|
|
415
|
-
"sessionsFound": len(claude_sessions),
|
|
416
|
-
"unmatchedModels": sorted(set(unmatched_models)),
|
|
417
|
-
"unattributedTeamSessions": unattributed_sessions,
|
|
418
|
-
"unattributedWorkerUsage": unattributed_usage,
|
|
419
|
-
"definitions": {
|
|
420
|
-
"totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
|
|
421
|
-
"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.",
|
|
422
|
-
"estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers Codex/Gemini CLI calls billed under those providers.",
|
|
423
|
-
},
|
|
424
|
-
}
|
|
608
|
+
_populate_usage_summary(
|
|
609
|
+
state,
|
|
610
|
+
team_name=team_name,
|
|
611
|
+
sessions_found=len(claude_sessions),
|
|
612
|
+
unattributed_sessions=unattributed_sessions,
|
|
613
|
+
unattributed_usage=unattributed_usage,
|
|
614
|
+
)
|
|
425
615
|
return state
|
|
426
616
|
|
|
427
617
|
|
|
@@ -435,4 +625,3 @@ def _infer_project_root(team_state_path: Path, state: dict) -> Path:
|
|
|
435
625
|
return p
|
|
436
626
|
p = p.parent
|
|
437
627
|
raise SystemExit(f"could not infer project root from {team_state_path}")
|
|
438
|
-
|