okstra 0.30.3 → 0.32.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 (69) hide show
  1. package/docs/kr/architecture.md +2 -2
  2. package/docs/kr/cli.md +2 -2
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/SKILL.md +7 -5
  6. package/runtime/agents/workers/claude-worker.md +1 -1
  7. package/runtime/agents/workers/codex-worker.md +23 -6
  8. package/runtime/agents/workers/gemini-worker.md +23 -6
  9. package/runtime/agents/workers/report-writer-worker.md +45 -66
  10. package/runtime/bin/okstra-codex-exec.sh +31 -0
  11. package/runtime/bin/okstra-gemini-exec.sh +26 -0
  12. package/runtime/bin/okstra-render-final-report.py +101 -0
  13. package/runtime/bin/okstra-render-report-views.py +17 -10
  14. package/runtime/bin/okstra-token-usage.py +3 -1
  15. package/runtime/python/lib/okstra/globals.sh +1 -1
  16. package/runtime/python/lib/okstra/usage.sh +2 -2
  17. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  18. package/runtime/python/okstra_ctl/models.py +2 -0
  19. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  20. package/runtime/python/okstra_ctl/report_views.py +276 -297
  21. package/runtime/python/okstra_ctl/run.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +53 -14
  23. package/runtime/python/okstra_ctl/workers.py +45 -11
  24. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  25. package/runtime/python/okstra_token_usage/cli.py +66 -36
  26. package/runtime/python/okstra_token_usage/pricing.py +1 -0
  27. package/runtime/python/okstra_token_usage/report.py +148 -65
  28. package/runtime/python/okstra_vendor/__init__.py +37 -0
  29. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  30. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  31. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  32. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  33. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  34. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  35. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  36. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  37. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  38. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  39. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  40. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  41. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  42. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  43. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  44. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  45. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  46. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  47. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  48. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  49. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  50. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  51. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  52. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  53. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  54. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  55. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  56. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  57. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  58. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  59. package/runtime/skills/okstra-report-writer/SKILL.md +31 -30
  60. package/runtime/skills/okstra-run/SKILL.md +6 -4
  61. package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
  62. package/runtime/templates/reports/final-report.template.md +370 -405
  63. package/runtime/templates/reports/report.css +57 -4
  64. package/runtime/templates/reports/report.js +63 -7
  65. package/runtime/templates/reports/settings.template.json +1 -0
  66. package/runtime/validators/lib/fixtures.sh +7 -7
  67. package/runtime/validators/validate-report-views.py +24 -153
  68. package/runtime/validators/validate-run.py +102 -19
  69. package/src/install.mjs +21 -1
@@ -549,7 +549,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
549
549
  pr_template_source = resolved_tpl.source
550
550
 
551
551
  # ---- model assignments ----
552
- lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
552
+ lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus-4-6")
553
553
  claude_default = _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "sonnet")
554
554
  codex_default = _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5")
555
555
  gemini_default = _default("OKSTRA_DEFAULT_GEMINI_MODEL", "auto")
@@ -37,6 +37,7 @@ from okstra_ctl.workers import (
37
37
  ALLOWED_WORKERS,
38
38
  WorkersError,
39
39
  normalize_workers,
40
+ resolve_optional_workers,
40
41
  resolve_profile_workers,
41
42
  validate_workers_against_profile,
42
43
  )
@@ -215,6 +216,7 @@ class WizardState:
215
216
  # task-type + dependents
216
217
  task_type: str = ""
217
218
  profile_workers: list[str] = field(default_factory=list)
219
+ profile_optional_workers: list[str] = field(default_factory=list)
218
220
 
219
221
  # brief
220
222
  keep_existing_brief: Optional[bool] = None
@@ -278,6 +280,7 @@ class Prompt:
278
280
  options: list[Option] = field(default_factory=list)
279
281
  help: str = ""
280
282
  echo_template: str = "" # e.g. "task-group: {value}"
283
+ multi: bool = False # only meaningful when kind == "pick"
281
284
 
282
285
  def to_json(self) -> dict[str, Any]:
283
286
  return {
@@ -287,6 +290,7 @@ class Prompt:
287
290
  "options": [asdict(o) for o in self.options],
288
291
  "help": self.help,
289
292
  "echoTemplate": self.echo_template,
293
+ "multi": self.multi,
290
294
  }
291
295
 
292
296
 
@@ -401,6 +405,12 @@ def _load_profile_workers(workspace_root: Path, task_type: str) -> list[str]:
401
405
  return resolve_profile_workers(_profile_path(workspace_root, task_type))
402
406
 
403
407
 
408
+ def _load_profile_optional_workers(
409
+ workspace_root: Path, task_type: str
410
+ ) -> list[str]:
411
+ return resolve_optional_workers(_profile_path(workspace_root, task_type))
412
+
413
+
404
414
  def _resolved_roster(state: WizardState) -> list[str]:
405
415
  """Effective worker list AFTER override. Implementation: profile default
406
416
  (caller never asks for override). Others: override or profile default."""
@@ -628,6 +638,9 @@ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
628
638
  state.profile_workers = _load_profile_workers(
629
639
  Path(state.workspace_root), value
630
640
  )
641
+ state.profile_optional_workers = _load_profile_optional_workers(
642
+ Path(state.workspace_root), value
643
+ )
631
644
  # Reuse-worktree is decided once identity is final. Recompute here so
632
645
  # subsequent base-ref step knows whether to apply.
633
646
  state.reuse_worktree = _resolve_reuse_worktree(state)
@@ -932,25 +945,43 @@ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
932
945
 
933
946
 
934
947
  def _build_workers_override(state: WizardState) -> Prompt:
935
- csv = ",".join(state.profile_workers)
948
+ """분석 워커 멀티픽. report-writer 는 옵션에서 빼고 항상 결과에 강제
949
+ 포함시킨다(프로필이 report-writer 를 Required 로 가질 때)."""
950
+ analyser_choices = [
951
+ w for w in (state.profile_workers + state.profile_optional_workers)
952
+ if w != "report-writer"
953
+ ]
954
+ options: list[Option] = []
955
+ for w in analyser_choices:
956
+ is_optional = w in state.profile_optional_workers
957
+ label = f"{w} (옵션)" if is_optional else w
958
+ options.append(_opt(value=w, label=label))
936
959
  return Prompt(
937
- step=S_WORKERS_OVERRIDE, kind="text",
938
- label=(f"참여 워커 목록을 쉼표로 구분해서 적어주세요. "
939
- f" 줄이면 프로필 기본값 [{csv}] 을 그대로 씁니다. "
940
- f"사용 가능한 워커: {csv}"),
960
+ step=S_WORKERS_OVERRIDE, kind="pick", multi=True,
961
+ label=("참여시킬 분석 워커를 선택해주세요 (최소 1개). "
962
+ "report-writer 항상 포함됩니다."),
963
+ options=options,
941
964
  echo_template="workers: {value}",
942
965
  )
943
966
 
944
967
 
945
968
  def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
946
- if not (value or "").strip():
947
- state.workers_override = ""
948
- return f"workers: (profile default: {','.join(state.profile_workers)})"
969
+ raw = (value or "").strip()
949
970
  try:
950
- chosen = normalize_workers(value)
951
- validate_workers_against_profile(chosen, state.profile_workers)
971
+ chosen = normalize_workers(raw) if raw else []
972
+ if not chosen:
973
+ raise WizardError("워커를 최소 1개 선택해주세요")
974
+ validate_workers_against_profile(
975
+ chosen,
976
+ state.profile_workers,
977
+ state.profile_optional_workers,
978
+ )
952
979
  except WorkersError as exc:
953
980
  raise WizardError(str(exc))
981
+ # report-writer 는 프로필이 Required 로 선언했을 때만 강제 포함.
982
+ if ("report-writer" in state.profile_workers
983
+ and "report-writer" not in chosen):
984
+ chosen.append("report-writer")
954
985
  state.workers_override = ",".join(chosen)
955
986
  return f"workers: {state.workers_override}"
956
987
 
@@ -1173,6 +1204,7 @@ STEPS: list[Step] = [
1173
1204
  build=_build_task_pick, submit=_submit_task_pick,
1174
1205
  owns=("is_new_task", "task_group", "task_id", "task_type",
1175
1206
  "existing_brief_path", "profile_workers",
1207
+ "profile_optional_workers",
1176
1208
  "task_group_suggestion", "task_id_suggestion",
1177
1209
  "task_group_pending_text", "task_id_pending_text")),
1178
1210
  Step(S_BRIEF_PATH,
@@ -1228,7 +1260,8 @@ STEPS: list[Step] = [
1228
1260
  and (s.is_new_task is False or bool(s.task_id))
1229
1261
  and S_TASK_TYPE not in s.answered),
1230
1262
  build=_build_task_type, submit=_submit_task_type,
1231
- owns=("task_type", "profile_workers", "reuse_worktree")),
1263
+ owns=("task_type", "profile_workers", "profile_optional_workers",
1264
+ "reuse_worktree")),
1232
1265
  Step(S_BRIEF_KEEP,
1233
1266
  applies=lambda s: (not s.is_new_task
1234
1267
  and bool(s.existing_brief_path)
@@ -1281,11 +1314,16 @@ STEPS: list[Step] = [
1281
1314
  and s.use_defaults is None),
1282
1315
  build=_build_defaults_or_custom, submit=_submit_defaults_or_custom,
1283
1316
  owns=("use_defaults",)),
1284
- # Customize branch — workers override only when non-empty profile + not impl.
1317
+ # Customize branch — workers override only when the profile actually
1318
+ # has analyser-candidate workers (required ∪ optional, minus report-writer).
1285
1319
  Step(S_WORKERS_OVERRIDE,
1286
1320
  applies=lambda s: (s.use_defaults is False
1287
1321
  and s.task_type != "implementation"
1288
- and bool(s.profile_workers)
1322
+ and any(
1323
+ w != "report-writer"
1324
+ for w in (s.profile_workers
1325
+ + s.profile_optional_workers)
1326
+ )
1289
1327
  and S_WORKERS_OVERRIDE not in s.answered),
1290
1328
  build=_build_workers_override, submit=_submit_workers_override,
1291
1329
  owns=("workers_override",)),
@@ -1446,7 +1484,8 @@ _FIELD_DEFAULTS: dict[str, Any] = {
1446
1484
  "existing_brief_path": "", "task_type": "",
1447
1485
  "task_group_suggestion": "", "task_id_suggestion": "",
1448
1486
  "task_group_pending_text": False, "task_id_pending_text": False,
1449
- "profile_workers": [], "keep_existing_brief": None,
1487
+ "profile_workers": [], "profile_optional_workers": [],
1488
+ "keep_existing_brief": None,
1450
1489
  "brief_path": "", "reuse_worktree": None, "base_ref": "",
1451
1490
  "base_ref_pending_text": False, "approved_plan_path": "",
1452
1491
  "approved_plan_pending_text": False,
@@ -16,16 +16,20 @@ PROFILE_BULLET_HEADERS = {
16
16
  "- Reviewers:",
17
17
  "- Analysers:",
18
18
  }
19
+ PROFILE_BULLET_HEADERS_OPTIONAL = {
20
+ "- Optional workers:",
21
+ "- Optional workers (opt-in via `--workers`):",
22
+ }
19
23
 
20
24
 
21
25
  class WorkersError(Exception):
22
26
  """invalid worker selection — surface to user."""
23
27
 
24
28
 
25
- def resolve_profile_workers(profile_path: Path) -> list[str]:
26
- """`prompts/profiles/<task-type>.md` 본문의 `- Workers:` 섹션 아래
27
- sub-bullet 들을 worker id 리스트로 돌려준다.
28
- profile 파일이 없거나 섹션이 없으면 리스트.
29
+ def _resolve_workers_under(profile_path: Path, headers: set[str]) -> list[str]:
30
+ """Generic parser: collect ` - <id> …` sub-bullets under any of `headers`
31
+ until the next top-level bullet. Returns first token (before any
32
+ ` ` / ` -- ` / whitespace) of each captured line.
29
33
  """
30
34
  if not Path(profile_path).is_file():
31
35
  return []
@@ -33,7 +37,7 @@ def resolve_profile_workers(profile_path: Path) -> list[str]:
33
37
  out: list[str] = []
34
38
  for line in Path(profile_path).read_text(encoding="utf-8").splitlines():
35
39
  stripped = line.strip()
36
- if stripped in PROFILE_BULLET_HEADERS:
40
+ if stripped in headers:
37
41
  capturing = True
38
42
  continue
39
43
  if not capturing:
@@ -41,13 +45,40 @@ def resolve_profile_workers(profile_path: Path) -> list[str]:
41
45
  if line.startswith("- "):
42
46
  break
43
47
  if line.startswith(" - "):
44
- out.append(line[4:].strip())
48
+ body = line[4:].strip()
49
+ token = body.split(" — ", 1)[0].split(" -- ", 1)[0].split()[0]
50
+ out.append(token)
45
51
  continue
46
52
  if stripped:
47
53
  break
48
54
  return out
49
55
 
50
56
 
57
+ def resolve_profile_workers(profile_path: Path) -> list[str]:
58
+ """`prompts/profiles/<task-type>.md` 본문의 `- Required workers:` (또는
59
+ `- Workers:` / `- Reviewers:` / `- Analysers:`) 섹션 아래 sub-bullet 들을
60
+ worker id 리스트로 돌려준다. profile 파일이 없거나 섹션이 없으면 빈 리스트.
61
+ """
62
+ return _resolve_workers_under(profile_path, PROFILE_BULLET_HEADERS)
63
+
64
+
65
+ def resolve_optional_workers(profile_path: Path) -> list[str]:
66
+ r"""`- Optional workers (opt-in via \`--workers\`):` 섹션 아래 sub-bullet
67
+ 들에서 worker id 만 추출한다. (` - gemini — when added …` → `gemini`)
68
+ Required 와 중복되는 항목은 제거. ALLOWED_WORKERS 밖 토큰도 제거.
69
+ """
70
+ raw = _resolve_workers_under(profile_path, PROFILE_BULLET_HEADERS_OPTIONAL)
71
+ required = set(resolve_profile_workers(profile_path))
72
+ seen: set[str] = set()
73
+ out: list[str] = []
74
+ for w in raw:
75
+ if w in required or w in seen or w not in ALLOWED_WORKERS:
76
+ continue
77
+ seen.add(w)
78
+ out.append(w)
79
+ return out
80
+
81
+
51
82
  def normalize_workers(value: str) -> list[str]:
52
83
  """CSV 입력을 정규화한다.
53
84
 
@@ -71,20 +102,23 @@ def normalize_workers(value: str) -> list[str]:
71
102
 
72
103
 
73
104
  def validate_workers_against_profile(
74
- workers: list[str], profile_workers: list[str]
105
+ workers: list[str],
106
+ profile_workers: list[str],
107
+ optional_workers: list[str] | None = None,
75
108
  ) -> None:
76
109
  """프로파일이 `Required workers:` 로 로스터를 선언했다면, 사용자
77
- override 가 부분집합인지 검증한다.
110
+ override 가 (required ∪ optional) 의 부분집합인지 검증한다.
78
111
 
79
112
  `profile_workers` 가 비어 있으면(프로파일이 로스터를 선언하지 않은
80
- 구버전) 검증을 건너뛴다 — 하위 호환을 위해.
113
+ 구버전) 검증을 건너뛴다.
81
114
  """
82
115
  if not profile_workers:
83
116
  return
84
- allowed = set(profile_workers)
117
+ allowed = set(profile_workers) | set(optional_workers or [])
85
118
  extras = [w for w in workers if w not in allowed]
86
119
  if extras:
120
+ allow_str = ",".join(profile_workers + list(optional_workers or []))
87
121
  raise WorkersError(
88
122
  "workers not allowed by profile roster: "
89
- f"{','.join(extras)} (profile allows: {','.join(profile_workers)})"
123
+ f"{','.join(extras)} (profile allows: {allow_str})"
90
124
  )
@@ -23,4 +23,8 @@ from .pricing import (
23
23
  codex_cost_usd,
24
24
  gemini_cost_usd,
25
25
  )
26
- from .report import substitute_final_report
26
+ from .report import (
27
+ SubstituteRefusedError,
28
+ populate_and_render,
29
+ populate_data_token_cells,
30
+ )
@@ -5,25 +5,53 @@ import argparse
5
5
  import json
6
6
  import sys
7
7
  from pathlib import Path
8
+
8
9
  from .collect import collect
9
- from .report import SubstituteRefusedError, substitute_final_report
10
+ from .report import (
11
+ SubstituteRefusedError,
12
+ populate_and_render,
13
+ )
10
14
 
11
15
 
12
16
  def main() -> int:
13
17
  parser = argparse.ArgumentParser(description=__doc__)
14
- parser.add_argument("team_state", type=Path, help="Path to team-state JSON file")
15
- parser.add_argument("--project-root", type=Path, default=None,
16
- help="Override project root (default: inferred from team-state path)")
17
- parser.add_argument("--write", action="store_true",
18
- help="Write the updated team-state back to the same path (default: print to stdout)")
19
- parser.add_argument("--summary", action="store_true",
20
- help="Also print a one-line summary to stderr")
21
- parser.add_argument("--substitute-final-report", type=Path, default=None,
22
- help="After collecting usage, substitute {{LEAD_TOTAL_TOKENS}} / {{LEAD_BILLABLE_TOKENS}} / "
23
- "{{LEAD_COST_USD}} / {{WORKER_TOTAL_TOKENS}} / {{WORKER_BILLABLE_TOKENS}} / "
24
- "{{WORKER_COST_USD}} / {{GRAND_TOTAL_TOKENS}} / {{GRAND_BILLABLE_TOKENS}} / "
25
- "{{GRAND_COST_USD}} / {{CLI_COST_USD}} placeholders in the given final-report file "
26
- "with concrete values from the freshly computed usageSummary.")
18
+ parser.add_argument(
19
+ "team_state",
20
+ type=Path,
21
+ help="Path to team-state JSON file",
22
+ )
23
+ parser.add_argument(
24
+ "--project-root",
25
+ type=Path,
26
+ default=None,
27
+ help="Override project root (default: inferred from team-state path)",
28
+ )
29
+ parser.add_argument(
30
+ "--write",
31
+ action="store_true",
32
+ help=(
33
+ "Write the updated team-state back to the same path "
34
+ "(default: print to stdout)"
35
+ ),
36
+ )
37
+ parser.add_argument(
38
+ "--summary",
39
+ action="store_true",
40
+ help="Also print a one-line summary to stderr",
41
+ )
42
+ parser.add_argument(
43
+ "--substitute-data",
44
+ type=Path,
45
+ default=None,
46
+ help=(
47
+ "After collecting usage, populate the final-report data.json "
48
+ "at this path with concrete token / cost / duration values "
49
+ "from the freshly computed usageSummary, then re-render the "
50
+ "sibling final-report markdown via the renderer. The data.json "
51
+ "is the SSOT; the markdown is regenerated from it. "
52
+ "See schemas/final-report-v1.0.schema.json for the data shape."
53
+ ),
54
+ )
27
55
  args = parser.parse_args()
28
56
 
29
57
  if not args.team_state.is_file():
@@ -33,7 +61,9 @@ def main() -> int:
33
61
  updated = collect(args.team_state, args.project_root)
34
62
 
35
63
  if args.write:
36
- args.team_state.write_text(json.dumps(updated, indent=2, ensure_ascii=False) + "\n")
64
+ args.team_state.write_text(
65
+ json.dumps(updated, indent=2, ensure_ascii=False) + "\n",
66
+ )
37
67
  else:
38
68
  json.dump(updated, sys.stdout, indent=2, ensure_ascii=False)
39
69
  sys.stdout.write("\n")
@@ -42,7 +72,8 @@ def main() -> int:
42
72
  s = updated.get("usageSummary") or {}
43
73
  cost = s.get("estimatedCostUsd") or {}
44
74
  print(
45
- f"raw: lead={s.get('leadTotalTokens', 0):,} workers={s.get('workerTotalTokens', 0):,} "
75
+ f"raw: lead={s.get('leadTotalTokens', 0):,} "
76
+ f"workers={s.get('workerTotalTokens', 0):,} "
46
77
  f"grand={s.get('grandTotalTokens', 0):,}",
47
78
  file=sys.stderr,
48
79
  )
@@ -53,36 +84,35 @@ def main() -> int:
53
84
  file=sys.stderr,
54
85
  )
55
86
  print(
56
- f"cost USD: lead=${cost.get('lead', 0):.2f} claude-workers=${cost.get('claudeWorkers', 0):.2f} "
57
- f"cli-workers=${cost.get('cliWorkers', 0):.2f} grand=${cost.get('grandTotal', 0):.2f}",
87
+ f"cost USD: lead=${cost.get('lead', 0):.2f} "
88
+ f"claude-workers=${cost.get('claudeWorkers', 0):.2f} "
89
+ f"cli-workers=${cost.get('cliWorkers', 0):.2f} "
90
+ f"grand=${cost.get('grandTotal', 0):.2f}",
91
+ file=sys.stderr,
92
+ )
93
+ print(
94
+ f"sessions={s.get('sessionsFound', 0)} "
95
+ f"team={s.get('teamName', '')}",
58
96
  file=sys.stderr,
59
97
  )
60
- print(f"sessions={s.get('sessionsFound', 0)} team={s.get('teamName', '')}", file=sys.stderr)
61
98
 
62
- if args.substitute_final_report is not None:
99
+ if args.substitute_data is not None:
63
100
  try:
64
- replaced = substitute_final_report(args.substitute_final_report, updated)
101
+ cells_changed, bytes_written = populate_and_render(
102
+ args.substitute_data,
103
+ updated,
104
+ )
65
105
  except SubstituteRefusedError as exc:
66
106
  print(
67
107
  f"final-report substitution REFUSED: {exc}",
68
108
  file=sys.stderr,
69
109
  )
70
110
  return 2
71
- if replaced < 0:
72
- print(
73
- f"final-report substitution skipped: file not found at {args.substitute_final_report}",
74
- file=sys.stderr,
75
- )
76
- elif replaced == 0:
77
- print(
78
- f"final-report substitution: no token placeholders found at {args.substitute_final_report}",
79
- file=sys.stderr,
80
- )
81
- else:
82
- print(
83
- f"final-report substitution: replaced {replaced} placeholder occurrence(s) in {args.substitute_final_report}",
84
- file=sys.stderr,
85
- )
111
+ print(
112
+ f"final-report substitution: populated {cells_changed} cell(s) in "
113
+ f"{args.substitute_data}, re-rendered {bytes_written} bytes of markdown.",
114
+ file=sys.stderr,
115
+ )
86
116
 
87
117
  return 0
88
118
 
@@ -47,6 +47,7 @@ CLAUDE_PRICING = {
47
47
 
48
48
  # Claude 4 point releases (explicit so future divergence is easy to see).
49
49
  "opus-4-7": (5.0, 6.25, 0.50, 25.0), # Opus 4.7 (cache prices derived from ratios)
50
+ "opus-4-6": (5.0, 6.25, 0.50, 25.0), # Opus 4.6 (legacy; pricing matches 4.7 per Anthropic)
50
51
  "sonnet-4-6": (3.0, 3.75, 0.30, 15.0), # Sonnet 4.6 (cache prices derived from ratios)
51
52
  "haiku-4-5": (1.0, 1.25, 0.10, 5.0), # Haiku 4.5 (cache prices derived from ratios)
52
53