okstra 0.25.1 → 0.27.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 (48) hide show
  1. package/README.kr.md +16 -0
  2. package/README.md +16 -0
  3. package/docs/kr/architecture.md +3 -7
  4. package/docs/kr/cli.md +47 -4
  5. package/docs/kr/performance-improvement-plan-v2.md +23 -0
  6. package/docs/kr/performance-improvement-plan.md +22 -0
  7. package/docs/superpowers/specs/2026-05-15-implementation-plan-verification-design.md +254 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +30 -2
  11. package/runtime/bin/okstra.sh +1 -1
  12. package/runtime/prompts/profiles/_common-contract.md +30 -1
  13. package/runtime/prompts/profiles/error-analysis.md +12 -0
  14. package/runtime/prompts/profiles/implementation-planning.md +23 -0
  15. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  16. package/runtime/python/lib/okstra/cli.sh +8 -7
  17. package/runtime/python/lib/okstra/globals.sh +3 -1
  18. package/runtime/python/lib/okstra/usage.sh +8 -4
  19. package/runtime/python/okstra_ctl/render.py +35 -0
  20. package/runtime/python/okstra_ctl/run.py +27 -6
  21. package/runtime/python/okstra_ctl/run_context.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +259 -10
  23. package/runtime/python/okstra_token_usage/blocks.py +5 -1
  24. package/runtime/python/okstra_token_usage/claude.py +16 -1
  25. package/runtime/python/okstra_token_usage/collect.py +17 -3
  26. package/runtime/python/okstra_token_usage/pricing.py +159 -24
  27. package/runtime/skills/okstra-brief/SKILL.md +532 -65
  28. package/runtime/skills/okstra-context-loader/SKILL.md +25 -11
  29. package/runtime/skills/okstra-convergence/SKILL.md +235 -8
  30. package/runtime/skills/okstra-history/SKILL.md +68 -37
  31. package/runtime/skills/okstra-logs/SKILL.md +26 -4
  32. package/runtime/skills/okstra-report-finder/SKILL.md +49 -22
  33. package/runtime/skills/okstra-report-writer/SKILL.md +59 -64
  34. package/runtime/skills/okstra-run/SKILL.md +53 -39
  35. package/runtime/skills/okstra-schedule/SKILL.md +51 -20
  36. package/runtime/skills/okstra-setup/SKILL.md +31 -12
  37. package/runtime/skills/okstra-status/SKILL.md +20 -8
  38. package/runtime/skills/okstra-team-contract/SKILL.md +27 -15
  39. package/runtime/skills/okstra-time-summary/SKILL.md +53 -16
  40. package/runtime/templates/reports/final-report.template.md +34 -0
  41. package/runtime/templates/reports/settings.template.json +7 -4
  42. package/runtime/validators/lib/fixtures.sh +10 -2
  43. package/runtime/validators/lib/validate-assets.sh +50 -24
  44. package/runtime/validators/validate-brief.py +385 -0
  45. package/runtime/validators/validate-brief.sh +35 -0
  46. package/runtime/validators/validate-run.py +71 -0
  47. package/runtime/validators/validate-workflow.sh +7 -33
  48. package/src/wizard.mjs +21 -5
@@ -84,6 +84,7 @@ S_BRIEF_KEEP = "brief_keep"
84
84
  S_BRIEF_PATH = "brief_path"
85
85
  S_BASE_REF_PICK = "base_ref_pick"
86
86
  S_BASE_REF_TEXT = "base_ref_text"
87
+ S_APPROVED_PLAN_PICK = "approved_plan_pick"
87
88
  S_APPROVED_PLAN = "approved_plan"
88
89
  S_EXECUTOR = "executor"
89
90
  S_DEFAULTS_OR_CUSTOM = "defaults_or_custom"
@@ -94,9 +95,13 @@ S_CLAUDE_MODEL = "claude_model"
94
95
  S_CODEX_MODEL = "codex_model"
95
96
  S_GEMINI_MODEL = "gemini_model"
96
97
  S_REPORT_WRITER_MODEL = "report_writer_model"
98
+ S_DIRECTIVE_PICK = "directive_pick"
97
99
  S_DIRECTIVE = "directive"
100
+ S_RELATED_TASKS_PICK = "related_tasks_pick"
98
101
  S_RELATED_TASKS = "related_tasks"
102
+ S_CLARIFICATION_PICK = "clarification_pick"
99
103
  S_CLARIFICATION = "clarification"
104
+ S_PR_TEMPLATE_PICK = "pr_template_pick"
100
105
  S_PR_TEMPLATE = "pr_template"
101
106
  S_PR_TEMPLATE_SCOPE = "pr_template_scope"
102
107
  S_CONFIRM = "confirm"
@@ -134,6 +139,7 @@ class WizardState:
134
139
 
135
140
  # impl extras
136
141
  approved_plan_path: str = ""
142
+ approved_plan_pending_text: bool = False
137
143
  executor: str = ""
138
144
 
139
145
  # customize
@@ -145,9 +151,13 @@ class WizardState:
145
151
  gemini_model: str = ""
146
152
  report_writer_model: str = ""
147
153
  directive: str = ""
154
+ directive_pending_text: bool = False
148
155
  related_tasks_raw: str = ""
156
+ related_tasks_pending_text: bool = False
149
157
  clarification_response_path: str = ""
158
+ clarification_pending_text: bool = False
150
159
  pr_template_path: str = ""
160
+ pr_template_pending_text: bool = False
151
161
  pr_template_scope: str = "" # "once" | "project" | "global"
152
162
 
153
163
  # confirm / edit
@@ -533,6 +543,81 @@ def _submit_base_ref_text(state: WizardState, value: str) -> Optional[str]:
533
543
  return f"base-ref: {ref}"
534
544
 
535
545
 
546
+ PICK_USE_DEFAULT = "__use_default__"
547
+ PICK_OTHER = "__other__"
548
+ PICK_SKIP = "__skip__"
549
+ PICK_ENTER = "__enter__"
550
+
551
+
552
+ def _latest_implementation_planning_report(state: WizardState) -> Optional[Path]:
553
+ """Find the latest ``final-report-implementation-planning-<seq>.md`` under
554
+ the current task's runs directory.
555
+
556
+ Returns the path relative to ``project_root`` if found, otherwise ``None``.
557
+ """
558
+ if not state.task_group or not state.task_id or not state.project_root:
559
+ return None
560
+ base = (Path(state.project_root) / ".project-docs" / "okstra" / "tasks"
561
+ / slugify_task_segment(state.task_group)
562
+ / slugify_task_segment(state.task_id)
563
+ / "runs" / "implementation-planning")
564
+ if not base.is_dir():
565
+ return None
566
+ pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
567
+ best: tuple[int, Path] | None = None
568
+ for run_dir in base.iterdir():
569
+ reports = run_dir / "reports"
570
+ if not reports.is_dir():
571
+ continue
572
+ for child in reports.iterdir():
573
+ m = pat.match(child.name)
574
+ if not m:
575
+ continue
576
+ n = int(m.group(1))
577
+ if best is None or n > best[0]:
578
+ best = (n, child)
579
+ if best is None:
580
+ return None
581
+ try:
582
+ return best[1].relative_to(Path(state.project_root))
583
+ except ValueError:
584
+ return best[1]
585
+
586
+
587
+ def _build_approved_plan_pick(state: WizardState) -> Prompt:
588
+ default = _latest_implementation_planning_report(state)
589
+ options = [
590
+ _opt(PICK_USE_DEFAULT, f"기본 경로 사용: {default}"),
591
+ _opt(PICK_OTHER, "다른 경로 입력"),
592
+ ]
593
+ return Prompt(
594
+ step=S_APPROVED_PLAN_PICK, kind="pick",
595
+ label=f"approved final-report 경로 (기본: {default})",
596
+ options=options,
597
+ echo_template="approved-plan(pick): {value}",
598
+ )
599
+
600
+
601
+ def _submit_approved_plan_pick(state: WizardState, value: str) -> Optional[str]:
602
+ if value == PICK_USE_DEFAULT:
603
+ default = _latest_implementation_planning_report(state)
604
+ if default is None:
605
+ raise WizardError(
606
+ "기본 approved-plan 경로를 찾을 수 없습니다. '다른 경로 입력'을 선택하세요."
607
+ )
608
+ p = _validate_approved_plan(str(default), Path(state.project_root))
609
+ state.approved_plan_path = str(p)
610
+ state.approved_plan_pending_text = False
611
+ return f"approved-plan: {p}"
612
+ if value == PICK_OTHER:
613
+ state.approved_plan_pending_text = True
614
+ state.approved_plan_path = ""
615
+ return None
616
+ raise WizardError(
617
+ f"expected '{PICK_USE_DEFAULT}' or '{PICK_OTHER}', got: {value!r}"
618
+ )
619
+
620
+
536
621
  def _build_approved_plan(state: WizardState) -> Prompt:
537
622
  return Prompt(
538
623
  step=S_APPROVED_PLAN, kind="text",
@@ -544,9 +629,103 @@ def _build_approved_plan(state: WizardState) -> Prompt:
544
629
  def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
545
630
  p = _validate_approved_plan(value, Path(state.project_root))
546
631
  state.approved_plan_path = str(p)
632
+ state.approved_plan_pending_text = False
547
633
  return f"approved-plan: {p}"
548
634
 
549
635
 
636
+ def _build_directive_pick(state: WizardState) -> Prompt:
637
+ return Prompt(
638
+ step=S_DIRECTIVE_PICK, kind="pick",
639
+ label="추가 directive 가 있나요?",
640
+ options=[
641
+ _opt(PICK_SKIP, "없음 (건너뛰기)"),
642
+ _opt(PICK_ENTER, "있음 (입력)"),
643
+ ],
644
+ echo_template="directive(pick): {value}",
645
+ )
646
+
647
+
648
+ def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
649
+ if value == PICK_SKIP:
650
+ state.directive = ""
651
+ state.directive_pending_text = False
652
+ return "directive: (none)"
653
+ if value == PICK_ENTER:
654
+ state.directive_pending_text = True
655
+ return None
656
+ raise WizardError(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
657
+
658
+
659
+ def _build_related_tasks_pick(state: WizardState) -> Prompt:
660
+ return Prompt(
661
+ step=S_RELATED_TASKS_PICK, kind="pick",
662
+ label="관련 task id 목록이 있나요?",
663
+ options=[
664
+ _opt(PICK_SKIP, "없음 (건너뛰기)"),
665
+ _opt(PICK_ENTER, "있음 (입력)"),
666
+ ],
667
+ echo_template="related-tasks(pick): {value}",
668
+ )
669
+
670
+
671
+ def _submit_related_tasks_pick(state: WizardState, value: str) -> Optional[str]:
672
+ if value == PICK_SKIP:
673
+ state.related_tasks_raw = ""
674
+ state.related_tasks_pending_text = False
675
+ return "related-tasks: (none)"
676
+ if value == PICK_ENTER:
677
+ state.related_tasks_pending_text = True
678
+ return None
679
+ raise WizardError(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
680
+
681
+
682
+ def _build_clarification_pick(state: WizardState) -> Prompt:
683
+ return Prompt(
684
+ step=S_CLARIFICATION_PICK, kind="pick",
685
+ label="clarification-response 파일 경로가 있나요? (follow-up 시에만)",
686
+ options=[
687
+ _opt(PICK_SKIP, "없음 (건너뛰기)"),
688
+ _opt(PICK_ENTER, "있음 (입력)"),
689
+ ],
690
+ echo_template="clarification(pick): {value}",
691
+ )
692
+
693
+
694
+ def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
695
+ if value == PICK_SKIP:
696
+ state.clarification_response_path = ""
697
+ state.clarification_pending_text = False
698
+ return "clarification: (none)"
699
+ if value == PICK_ENTER:
700
+ state.clarification_pending_text = True
701
+ return None
702
+ raise WizardError(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
703
+
704
+
705
+ def _build_pr_template_pick(state: WizardState) -> Prompt:
706
+ return Prompt(
707
+ step=S_PR_TEMPLATE_PICK, kind="pick",
708
+ label="PR 본문 템플릿 경로를 직접 지정할까요?",
709
+ options=[
710
+ _opt(PICK_SKIP, "자동 해석 (project.json → config → 기본)"),
711
+ _opt(PICK_ENTER, "직접 경로 입력 (1회성 override)"),
712
+ ],
713
+ echo_template="pr-template(pick): {value}",
714
+ )
715
+
716
+
717
+ def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
718
+ if value == PICK_SKIP:
719
+ state.pr_template_path = ""
720
+ state.pr_template_scope = ""
721
+ state.pr_template_pending_text = False
722
+ return "pr-template: (auto-resolve)"
723
+ if value == PICK_ENTER:
724
+ state.pr_template_pending_text = True
725
+ return None
726
+ raise WizardError(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
727
+
728
+
550
729
  def _build_executor(state: WizardState) -> Prompt:
551
730
  options = [_opt(e, e + (" (default)" if e == "claude" else ""))
552
731
  for e in EXECUTORS]
@@ -689,6 +868,7 @@ def _build_directive(state: WizardState) -> Prompt:
689
868
 
690
869
  def _submit_directive(state: WizardState, value: str) -> Optional[str]:
691
870
  state.directive = (value or "").strip()
871
+ state.directive_pending_text = False
692
872
  return f"directive: {state.directive or '(none)'}"
693
873
 
694
874
 
@@ -702,6 +882,7 @@ def _build_related_tasks(state: WizardState) -> Prompt:
702
882
 
703
883
  def _submit_related_tasks(state: WizardState, value: str) -> Optional[str]:
704
884
  state.related_tasks_raw = (value or "").strip()
885
+ state.related_tasks_pending_text = False
705
886
  return f"related-tasks: {state.related_tasks_raw or '(none)'}"
706
887
 
707
888
 
@@ -715,6 +896,7 @@ def _build_clarification(state: WizardState) -> Prompt:
715
896
 
716
897
  def _submit_clarification(state: WizardState, value: str) -> Optional[str]:
717
898
  val = (value or "").strip()
899
+ state.clarification_pending_text = False
718
900
  if not val:
719
901
  state.clarification_response_path = ""
720
902
  return "clarification: (none)"
@@ -734,6 +916,7 @@ def _build_pr_template(state: WizardState) -> Prompt:
734
916
 
735
917
  def _submit_pr_template(state: WizardState, value: str) -> Optional[str]:
736
918
  val = (value or "").strip()
919
+ state.pr_template_pending_text = False
737
920
  if not val:
738
921
  state.pr_template_path = ""
739
922
  state.pr_template_scope = ""
@@ -860,15 +1043,29 @@ STEPS: list[Step] = [
860
1043
  applies=lambda s: s.base_ref_pending_text,
861
1044
  build=_build_base_ref_text, submit=_submit_base_ref_text,
862
1045
  owns=("base_ref", "base_ref_pending_text")),
1046
+ Step(S_APPROVED_PLAN_PICK,
1047
+ applies=lambda s: (s.task_type == "implementation"
1048
+ and not s.approved_plan_path
1049
+ and not s.approved_plan_pending_text
1050
+ and S_APPROVED_PLAN_PICK not in s.answered
1051
+ and bool(s.brief_path)
1052
+ and (s.reuse_worktree is True
1053
+ or S_BASE_REF_PICK in s.answered)
1054
+ and not s.base_ref_pending_text
1055
+ and _latest_implementation_planning_report(s) is not None),
1056
+ build=_build_approved_plan_pick, submit=_submit_approved_plan_pick,
1057
+ owns=("approved_plan_path", "approved_plan_pending_text")),
863
1058
  Step(S_APPROVED_PLAN,
864
1059
  applies=lambda s: (s.task_type == "implementation"
865
1060
  and not s.approved_plan_path
866
1061
  and bool(s.brief_path)
867
1062
  and (s.reuse_worktree is True
868
1063
  or S_BASE_REF_PICK in s.answered)
869
- and not s.base_ref_pending_text),
1064
+ and not s.base_ref_pending_text
1065
+ and (s.approved_plan_pending_text
1066
+ or _latest_implementation_planning_report(s) is None)),
870
1067
  build=_build_approved_plan, submit=_submit_approved_plan,
871
- owns=("approved_plan_path",)),
1068
+ owns=("approved_plan_path", "approved_plan_pending_text")),
872
1069
  Step(S_EXECUTOR,
873
1070
  applies=lambda s: (s.task_type == "implementation"
874
1071
  and bool(s.approved_plan_path)
@@ -927,27 +1124,52 @@ STEPS: list[Step] = [
927
1124
  and S_REPORT_WRITER_MODEL not in s.answered),
928
1125
  build=_build_report_writer_model, submit=_submit_report_writer_model,
929
1126
  owns=("report_writer_model",)),
1127
+ Step(S_DIRECTIVE_PICK,
1128
+ applies=lambda s: (s.use_defaults is False
1129
+ and S_DIRECTIVE_PICK not in s.answered),
1130
+ build=_build_directive_pick, submit=_submit_directive_pick,
1131
+ owns=("directive", "directive_pending_text")),
930
1132
  Step(S_DIRECTIVE,
931
1133
  applies=lambda s: (s.use_defaults is False
1134
+ and s.directive_pending_text
932
1135
  and S_DIRECTIVE not in s.answered),
933
1136
  build=_build_directive, submit=_submit_directive,
934
- owns=("directive",)),
1137
+ owns=("directive", "directive_pending_text")),
1138
+ Step(S_RELATED_TASKS_PICK,
1139
+ applies=lambda s: (s.use_defaults is False
1140
+ and S_RELATED_TASKS_PICK not in s.answered),
1141
+ build=_build_related_tasks_pick, submit=_submit_related_tasks_pick,
1142
+ owns=("related_tasks_raw", "related_tasks_pending_text")),
935
1143
  Step(S_RELATED_TASKS,
936
1144
  applies=lambda s: (s.use_defaults is False
1145
+ and s.related_tasks_pending_text
937
1146
  and S_RELATED_TASKS not in s.answered),
938
1147
  build=_build_related_tasks, submit=_submit_related_tasks,
939
- owns=("related_tasks_raw",)),
1148
+ owns=("related_tasks_raw", "related_tasks_pending_text")),
1149
+ Step(S_CLARIFICATION_PICK,
1150
+ applies=lambda s: (s.use_defaults is False
1151
+ and S_CLARIFICATION_PICK not in s.answered),
1152
+ build=_build_clarification_pick, submit=_submit_clarification_pick,
1153
+ owns=("clarification_response_path", "clarification_pending_text")),
940
1154
  Step(S_CLARIFICATION,
941
1155
  applies=lambda s: (s.use_defaults is False
1156
+ and s.clarification_pending_text
942
1157
  and S_CLARIFICATION not in s.answered),
943
1158
  build=_build_clarification, submit=_submit_clarification,
944
- owns=("clarification_response_path",)),
1159
+ owns=("clarification_response_path", "clarification_pending_text")),
1160
+ Step(S_PR_TEMPLATE_PICK,
1161
+ applies=lambda s: (s.use_defaults is False
1162
+ and s.task_type == "release-handoff"
1163
+ and S_PR_TEMPLATE_PICK not in s.answered),
1164
+ build=_build_pr_template_pick, submit=_submit_pr_template_pick,
1165
+ owns=("pr_template_path", "pr_template_scope", "pr_template_pending_text")),
945
1166
  Step(S_PR_TEMPLATE,
946
1167
  applies=lambda s: (s.use_defaults is False
947
1168
  and s.task_type == "release-handoff"
1169
+ and s.pr_template_pending_text
948
1170
  and S_PR_TEMPLATE not in s.answered),
949
1171
  build=_build_pr_template, submit=_submit_pr_template,
950
- owns=("pr_template_path", "pr_template_scope")),
1172
+ owns=("pr_template_path", "pr_template_scope", "pr_template_pending_text")),
951
1173
  Step(S_PR_TEMPLATE_SCOPE,
952
1174
  applies=lambda s: (s.use_defaults is False
953
1175
  and s.task_type == "release-handoff"
@@ -1021,11 +1243,15 @@ _FIELD_DEFAULTS: dict[str, Any] = {
1021
1243
  "profile_workers": [], "keep_existing_brief": None,
1022
1244
  "brief_path": "", "reuse_worktree": None, "base_ref": "",
1023
1245
  "base_ref_pending_text": False, "approved_plan_path": "",
1246
+ "approved_plan_pending_text": False,
1024
1247
  "executor": "", "use_defaults": None, "workers_override": "",
1025
1248
  "lead_model": "", "claude_model": "", "codex_model": "",
1026
1249
  "gemini_model": "", "report_writer_model": "", "directive": "",
1027
- "related_tasks_raw": "", "clarification_response_path": "",
1028
- "pr_template_path": "", "pr_template_scope": "",
1250
+ "directive_pending_text": False,
1251
+ "related_tasks_raw": "", "related_tasks_pending_text": False,
1252
+ "clarification_response_path": "", "clarification_pending_text": False,
1253
+ "pr_template_path": "", "pr_template_pending_text": False,
1254
+ "pr_template_scope": "",
1029
1255
  "confirmed": None, "edit_target": "",
1030
1256
  }
1031
1257
 
@@ -1172,7 +1398,7 @@ def _cli(argv: list[str]) -> int:
1172
1398
 
1173
1399
  Subcommands:
1174
1400
  init --state-file PATH --workspace-root P --project-root P --project-id ID
1175
- step --state-file PATH [--answer VALUE]
1401
+ step --state-file PATH (--answer VALUE | --no-submit)
1176
1402
  render-args --state-file PATH
1177
1403
  confirmation --state-file PATH
1178
1404
  """
@@ -1190,6 +1416,11 @@ def _cli(argv: list[str]) -> int:
1190
1416
  p_step = sub.add_parser("step")
1191
1417
  p_step.add_argument("--state-file", required=True)
1192
1418
  p_step.add_argument("--answer", default=None)
1419
+ p_step.add_argument(
1420
+ "--no-submit",
1421
+ action="store_true",
1422
+ help="Fetch the current prompt without submitting an answer.",
1423
+ )
1193
1424
 
1194
1425
  p_render = sub.add_parser("render-args")
1195
1426
  p_render.add_argument("--state-file", required=True)
@@ -1214,8 +1445,26 @@ def _cli(argv: list[str]) -> int:
1214
1445
 
1215
1446
  if args.cmd == "step":
1216
1447
  state = load_state_file(state_path)
1448
+ if args.no_submit and args.answer is not None:
1449
+ print(json.dumps(
1450
+ {"ok": False, "error": "--no-submit and --answer are mutually exclusive"},
1451
+ ensure_ascii=False, indent=2,
1452
+ ))
1453
+ return 2
1454
+ if not args.no_submit and args.answer is None:
1455
+ print(json.dumps(
1456
+ {
1457
+ "ok": False,
1458
+ "error": (
1459
+ "step requires --answer VALUE (use --answer '' to submit an "
1460
+ "empty value, or --no-submit to peek at the current prompt)"
1461
+ ),
1462
+ },
1463
+ ensure_ascii=False, indent=2,
1464
+ ))
1465
+ return 2
1217
1466
  try:
1218
- if args.answer is None:
1467
+ if args.no_submit:
1219
1468
  result = {"echo": "", "next": next_prompt(state).to_json()}
1220
1469
  else:
1221
1470
  result = submit(state, args.answer)
@@ -18,18 +18,21 @@ def usage_block(totals: dict, source: str, note: str | None = None) -> dict:
18
18
  "source": source,
19
19
  "collectedAt": utc_now(),
20
20
  }
21
- for key in ("cacheCreationTokens", "cacheReadTokens", "cachedInputTokens",
21
+ for key in ("cacheCreationTokens", "cacheCreation5mTokens", "cacheCreation1hTokens",
22
+ "cacheReadTokens", "cachedInputTokens",
22
23
  "reasoningOutputTokens", "cachedTokens", "thoughtsTokens", "toolTokens"):
23
24
  if totals.get(key):
24
25
  block[key] = totals[key]
25
26
 
26
27
  # Billable-equivalent + cost.
27
28
  if source == "claude-jsonl":
29
+ cc_1h = totals.get("cacheCreation1hTokens", 0) or 0
28
30
  be = claude_billable_equivalent(
29
31
  totals.get("inputTokens", 0) or 0,
30
32
  totals.get("cacheCreationTokens", 0) or 0,
31
33
  totals.get("cacheReadTokens", 0) or 0,
32
34
  totals.get("outputTokens", 0) or 0,
35
+ cache_create_1h_t=cc_1h,
33
36
  )
34
37
  block["billableEquivalentTokens"] = be
35
38
  cost = claude_cost_usd(
@@ -38,6 +41,7 @@ def usage_block(totals: dict, source: str, note: str | None = None) -> dict:
38
41
  totals.get("cacheCreationTokens", 0) or 0,
39
42
  totals.get("cacheReadTokens", 0) or 0,
40
43
  totals.get("outputTokens", 0) or 0,
44
+ cache_create_1h_t=cc_1h,
41
45
  )
42
46
  if cost is not None:
43
47
  block["estimatedCostUsd"] = cost
@@ -10,6 +10,7 @@ from .paths import claude_project_dir
10
10
  def claude_session_totals(jsonl_path: Path) -> dict:
11
11
  """Return totals + agentName + assistant model + time window for a Claude session jsonl."""
12
12
  input_t = output_t = cache_create_t = cache_read_t = 0
13
+ cache_create_5m_t = cache_create_1h_t = 0
13
14
  tool_uses = 0
14
15
  agent_name: str | None = None
15
16
  model: str | None = None
@@ -23,8 +24,20 @@ def claude_session_totals(jsonl_path: Path) -> dict:
23
24
  if usage:
24
25
  input_t += usage.get("input_tokens", 0) or 0
25
26
  output_t += usage.get("output_tokens", 0) or 0
26
- cache_create_t += usage.get("cache_creation_input_tokens", 0) or 0
27
+ cc_total = usage.get("cache_creation_input_tokens", 0) or 0
28
+ cache_create_t += cc_total
27
29
  cache_read_t += usage.get("cache_read_input_tokens", 0) or 0
30
+ # Split into 5m / 1h ephemeral tiers when the API breakdown is
31
+ # present. If only the aggregate is given, attribute all of it to
32
+ # the 5m tier (1.25x — the cheaper assumption, matches prior
33
+ # behavior).
34
+ cc_break = usage.get("cache_creation") or {}
35
+ if isinstance(cc_break, dict) and (cc_break.get("ephemeral_5m_input_tokens") is not None
36
+ or cc_break.get("ephemeral_1h_input_tokens") is not None):
37
+ cache_create_5m_t += cc_break.get("ephemeral_5m_input_tokens", 0) or 0
38
+ cache_create_1h_t += cc_break.get("ephemeral_1h_input_tokens", 0) or 0
39
+ else:
40
+ cache_create_5m_t += cc_total
28
41
  if rec.get("type") == "assistant":
29
42
  if model is None and msg.get("model"):
30
43
  model = msg["model"]
@@ -51,6 +64,8 @@ def claude_session_totals(jsonl_path: Path) -> dict:
51
64
  "inputTokens": input_t,
52
65
  "outputTokens": output_t,
53
66
  "cacheCreationTokens": cache_create_t,
67
+ "cacheCreation5mTokens": cache_create_5m_t,
68
+ "cacheCreation1hTokens": cache_create_1h_t,
54
69
  "cacheReadTokens": cache_read_t,
55
70
  "toolUses": tool_uses,
56
71
  "durationMs": duration_ms,
@@ -54,14 +54,16 @@ def _aggregate_totals(items: list[dict]) -> dict:
54
54
  """
55
55
  aggregate: dict = {
56
56
  "totalTokens": 0, "inputTokens": 0, "outputTokens": 0,
57
- "cacheCreationTokens": 0, "cacheReadTokens": 0,
57
+ "cacheCreationTokens": 0, "cacheCreation5mTokens": 0, "cacheCreation1hTokens": 0,
58
+ "cacheReadTokens": 0,
58
59
  "toolUses": 0, "durationMs": 0,
59
60
  "agentName": None, "model": None,
60
61
  "startedAt": None, "endedAt": None,
61
62
  }
62
63
  for t in items:
63
64
  for k in ("totalTokens", "inputTokens", "outputTokens",
64
- "cacheCreationTokens", "cacheReadTokens", "toolUses"):
65
+ "cacheCreationTokens", "cacheCreation5mTokens", "cacheCreation1hTokens",
66
+ "cacheReadTokens", "toolUses"):
65
67
  aggregate[k] += t.get(k, 0) or 0
66
68
  if aggregate["agentName"] is None and t.get("agentName"):
67
69
  aggregate["agentName"] = t["agentName"]
@@ -210,6 +212,17 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
210
212
  worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
211
213
  worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
212
214
  cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
215
+
216
+ # Surface models whose pricing lookup failed so the silent-zero case is visible.
217
+ unmatched_models: list[str] = []
218
+ if lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
219
+ unmatched_models.append(lead["model"])
220
+ for w in workers:
221
+ u = w.get("usage") or {}
222
+ if u.get("model") and u.get("estimatedCostUsd") is None and (u.get("totalTokens") or 0) > 0:
223
+ unmatched_models.append(u["model"])
224
+ if u.get("cliModel") and u.get("cliEstimatedCostUsd") is None and (u.get("cliTotalTokens") or 0) > 0:
225
+ unmatched_models.append(u["cliModel"])
213
226
  state["usageSummary"] = {
214
227
  "leadTotalTokens": lead_total,
215
228
  "workerTotalTokens": worker_total,
@@ -226,9 +239,10 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
226
239
  "collectedAt": utc_now(),
227
240
  "teamName": team_name,
228
241
  "sessionsFound": len(claude_sessions),
242
+ "unmatchedModels": sorted(set(unmatched_models)),
229
243
  "definitions": {
230
244
  "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
231
- "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation x1.25, cache_read x0.1, output x5). Useful when comparing sessions across models or to gauge cost.",
245
+ "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.",
232
246
  "estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers Codex/Gemini CLI calls billed under those providers.",
233
247
  },
234
248
  }