okstra 0.76.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.
Files changed (40) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +10 -2
  4. package/docs/kr/architecture.md +1 -1
  5. package/docs/kr/cli.md +20 -4
  6. package/docs/pr-template-usage.md +3 -3
  7. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  8. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/lib/okstra/cli.sh +5 -1
  12. package/runtime/bin/lib/okstra/globals.sh +1 -0
  13. package/runtime/bin/lib/okstra/usage.sh +6 -4
  14. package/runtime/bin/okstra.sh +1 -0
  15. package/runtime/prompts/launch.template.md +4 -13
  16. package/runtime/prompts/profiles/error-analysis.md +6 -0
  17. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  18. package/runtime/prompts/profiles/release-handoff.md +2 -2
  19. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
  21. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  22. package/runtime/python/okstra_ctl/paths.py +3 -0
  23. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  24. package/runtime/python/okstra_ctl/render.py +160 -29
  25. package/runtime/python/okstra_ctl/run.py +78 -15
  26. package/runtime/python/okstra_token_usage/codex.py +12 -7
  27. package/runtime/python/okstra_token_usage/collect.py +243 -54
  28. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  29. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  30. package/runtime/validators/validate-run.py +56 -22
  31. package/runtime/validators/validate_session_conformance.py +121 -4
  32. package/src/codex-dispatch.mjs +70 -0
  33. package/src/codex-run.mjs +68 -0
  34. package/src/doctor.mjs +40 -4
  35. package/src/install.mjs +122 -26
  36. package/src/paths.mjs +17 -3
  37. package/src/render-bundle.mjs +2 -0
  38. package/src/runtime-manifest.mjs +26 -0
  39. package/src/uninstall.mjs +26 -4
  40. /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
- # Always materialise the resume command script. Even in --render-only
2248
- # preparation flows the user (or a later non-interactive runner) may
2249
- # invoke it manually; deferring its creation until interactive launch
2250
- # leaves runs/<phase>/sessions/ empty and the manifest pointing at a
2251
- # path that does not exist.
2252
- write_claude_resume_command_file(
2253
- resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
2254
- project_root=project_root,
2255
- claude_session_id=claude_session_id,
2256
- task_key=ctx["TASK_KEY"],
2257
- task_type=ctx["TASK_TYPE"],
2258
- phase_state=ctx["CURRENT_RUN_STATUS"],
2259
- worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
2260
- prompt_seq=ctx["RUN_PROMPTS_SEQ"],
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,
@@ -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 whose meta.cwd matches and timestamp falls in window."""
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 None
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 (started_at <= ts <= ended_at):
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 None
83
+ return []
78
84
  candidates.sort()
79
- return candidates[-1][1]
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", "toolUses"):
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
- # Aggregate summary.
376
- lead = state.get("leadUsage") or {}
377
- workers = state.get("workers", [])
378
- lead_total = lead.get("totalTokens", 0) or 0
379
- lead_billable = lead.get("billableEquivalentTokens", 0) or 0
380
- lead_cost = lead.get("estimatedCostUsd", 0) or 0
381
- worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
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
-
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  from pathlib import Path
6
- from .paths import GEMINI_TMP
6
+ from .paths import GEMINI_TMP, ts_in_window
7
7
 
8
8
 
9
9
  def gemini_session_total(json_path: Path) -> dict:
@@ -40,9 +40,15 @@ def gemini_session_total(json_path: Path) -> dict:
40
40
 
41
41
 
42
42
  def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> Path | None:
43
- """Find the gemini chat session whose first user message references the project."""
43
+ """Find the latest gemini chat session in the requested window."""
44
+ sessions = find_gemini_sessions(project_root, started_at, ended_at)
45
+ return sessions[-1] if sessions else None
46
+
47
+
48
+ def find_gemini_sessions(project_root: Path, started_at: str, ended_at: str) -> list[Path]:
49
+ """Find gemini chat sessions whose first user message references the project."""
44
50
  if not GEMINI_TMP.is_dir() or not started_at or not ended_at:
45
- return None
51
+ return []
46
52
  project_str = str(project_root)
47
53
  candidates: list[tuple[str, Path]] = []
48
54
  for p in GEMINI_TMP.glob("*/chats/session-*.json"):
@@ -51,7 +57,7 @@ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> P
51
57
  except (OSError, json.JSONDecodeError):
52
58
  continue
53
59
  start = data.get("startTime") or ""
54
- if not (started_at <= start <= ended_at):
60
+ if not ts_in_window(start, started_at, ended_at):
55
61
  continue
56
62
  # Match by content: first user message should mention project root or task path.
57
63
  msgs = data.get("messages") or []
@@ -71,7 +77,6 @@ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> P
71
77
  candidates.append((start, p))
72
78
  break
73
79
  if not candidates:
74
- return None
80
+ return []
75
81
  candidates.sort()
76
- return candidates[-1][1]
77
-
82
+ return [path for _start, path in candidates]
@@ -251,7 +251,7 @@ back it up as `.bak.<timestamp>` rather than overwriting silently.
251
251
 
252
252
  `release-handoff` fills the PR body from a template. By default it uses the
253
253
  bundled skill template at
254
- `~/.claude/skills/okstra-run/templates/pr-body.template.md`. Most projects
254
+ `~/.claude/skills/templates/prd/pr-body.template.md`. Most projects
255
255
  want their own — e.g. the repo's `.github/PULL_REQUEST_TEMPLATE.md` — to
256
256
  keep PRs consistent with what the team already merges manually.
257
257