okstra 0.187.0 → 0.188.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 (36) hide show
  1. package/dist/commands/lifecycle/install.mjs +3 -1
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +2 -2
  4. package/docs/cli.md +2 -2
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/bin/okstra-render-report-views.py +23 -5
  8. package/runtime/prompts/lead/adapters/cmux.md +3 -3
  9. package/runtime/prompts/lead/convergence.md +3 -1
  10. package/runtime/prompts/lead/report-writer.md +1 -1
  11. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
  12. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
  13. package/runtime/python/okstra_ctl/agent/activity.py +4 -1
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
  15. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
  16. package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
  17. package/runtime/python/okstra_ctl/context_cost.py +16 -2
  18. package/runtime/python/okstra_ctl/convergence.py +6 -1
  19. package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
  20. package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
  21. package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
  22. package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
  23. package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
  24. package/runtime/python/okstra_ctl/implementation_options.py +7 -2
  25. package/runtime/python/okstra_ctl/render_final_report.py +1 -1
  26. package/runtime/python/okstra_ctl/report_finalize.py +7 -1
  27. package/runtime/python/okstra_ctl/report_narrative.py +132 -33
  28. package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
  29. package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
  30. package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
  31. package/runtime/python/okstra_ctl/worker_runner.py +42 -8
  32. package/runtime/python/okstra_token_usage/antigravity.py +50 -12
  33. package/runtime/python/okstra_token_usage/collect.py +105 -31
  34. package/runtime/python/okstra_token_usage/pricing.py +6 -3
  35. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  36. package/runtime/validators/validate-run.py +6 -1
@@ -73,7 +73,7 @@ def run_worker(
73
73
 
74
74
  try:
75
75
  with guard:
76
- exit_code, timed_out, idle_seconds, raw_model = _launch(
76
+ exit_code, timed_out, idle_seconds, raw_model, usage = _launch(
77
77
  command,
78
78
  log_path,
79
79
  presentation=presentation,
@@ -94,6 +94,11 @@ def run_worker(
94
94
  raise
95
95
 
96
96
  status = _closed(status, started_monotonic)
97
+ if usage is not None:
98
+ # 스트림이 마지막으로 보고한 토큰 스냅샷, 공급자 어휘 그대로. 토큰
99
+ # 수집기(`okstra_token_usage`)가 홈 트랜스크립트가 없는 공급자의
100
+ # 사용량을 여기서 읽는다.
101
+ status["usage"] = dict(usage)
97
102
  if served_model_normalizer is not None:
98
103
  attestation = served_model_normalizer(raw_model)
99
104
  status["servedModelAttestation"] = _attestation_payload(attestation)
@@ -172,11 +177,14 @@ def _launch(
172
177
  presentation: str,
173
178
  idle_timeout_seconds: int,
174
179
  on_spawn: Callable[[subprocess.Popen[bytes]], None],
175
- ) -> tuple[int, bool, int, str | None]:
180
+ ) -> tuple[int, bool, int, str | None, Mapping[str, Any] | None]:
176
181
  live = presentation == LIVE
177
182
  transcript = SessionTranscript(log_path, live=live)
178
183
  observation = _ServedModelObservation()
179
- strategy = _with_served_model_observation(command.presentation, observation)
184
+ usage_observation = _UsageObservation()
185
+ strategy = _with_stream_observations(
186
+ command.presentation, observation, usage_observation
187
+ )
180
188
  try:
181
189
  # The strategy decided where this provider runs — some CLIs work in the
182
190
  # stage tree, others in the project root and reach the tree by flag.
@@ -205,27 +213,38 @@ def _launch(
205
213
  idle_timeout_seconds=idle_timeout_seconds,
206
214
  stdin_text=command.stdin_text,
207
215
  )
208
- return exit_code, timed_out, idle_seconds, observation.raw_model
216
+ return (
217
+ exit_code,
218
+ timed_out,
219
+ idle_seconds,
220
+ observation.raw_model,
221
+ usage_observation.usage,
222
+ )
209
223
  finally:
210
224
  transcript.close()
211
225
 
212
226
 
213
- def _with_served_model_observation(
227
+ def _with_stream_observations(
214
228
  presentation: Presentation,
215
229
  observation: _ServedModelObservation,
230
+ usage_observation: _UsageObservation,
216
231
  ) -> Presentation:
217
- """JSON 경로의 서빙 모델 관측을 러너가 모아 둔다.
232
+ """JSON 경로의 서빙 모델·토큰 사용량 관측을 러너가 모아 둔다.
218
233
 
219
- 해석 전략은 이벤트를 보고 모델 문자열만 돌려준다. 그 값을 사이드카에
220
- 적는 일은 러너의 것이라, 여기서 한 번 감싼다.
234
+ 해석 전략은 이벤트를 보고 모델 문자열과 사용량 스냅샷만 돌려준다. 그 값을
235
+ 사이드카에 적는 일은 러너의 것이라, 여기서 한 번 감싼다. 사용량은 어댑터가
236
+ 읽는 법을 넘긴 어휘에서만 관측된다.
221
237
  """
222
238
  if not isinstance(presentation, JsonEvents):
223
239
  return presentation
224
240
  original = presentation.observe
241
+ original_usage = presentation.observe_usage
225
242
 
226
243
  def observe(event: Mapping[str, Any]) -> str | None:
227
244
  observed = original(event)
228
245
  observation.record(observed)
246
+ if original_usage is not None:
247
+ usage_observation.record(original_usage(event))
229
248
  return observed
230
249
 
231
250
  # 필드를 손으로 옮기지 않는다 — 그렇게 하던 동안 어댑터가 선언한
@@ -242,6 +261,21 @@ class _ServedModelObservation:
242
261
  self.raw_model = raw_model
243
262
 
244
263
 
264
+ class _UsageObservation:
265
+ """스트림이 보고한 마지막 사용량 스냅샷.
266
+
267
+ 첫 값이 아니라 마지막 값이다 — 단계별 스냅샷을 내는 CLI 도 종결 이벤트에
268
+ 합계를 싣고, 그 이벤트가 스트림의 끝에 온다.
269
+ """
270
+
271
+ def __init__(self) -> None:
272
+ self.usage: Mapping[str, Any] | None = None
273
+
274
+ def record(self, usage: Mapping[str, Any] | None) -> None:
275
+ if isinstance(usage, Mapping) and usage:
276
+ self.usage = usage
277
+
278
+
245
279
  class _AbnormalExit:
246
280
  """Close the status sidecar for the exits that raise nothing at all.
247
281
 
@@ -1,18 +1,64 @@
1
1
  """Antigravity (`agy`) CLI usage collector.
2
2
 
3
3
  공식 headless 문서는 ``--output-format json`` / ``stream-json`` 의 최종
4
- ``usage`` 객체에 토큰을 둔다. okstra 는 이미 ``stream-json`` 으로 호출하고
5
- 그 줄을 워커 로그에 남긴다. 정본은 마지막 ``result.usage`` 이다.
4
+ ``usage`` 객체에 토큰을 둔다. okstra 는 ``stream-json`` 으로 호출하고, 러너가
5
+ 스트림의 마지막 ``usage`` 스냅샷을 래퍼 status 사이드카의 ``usage`` 에 적는다
6
+ (`worker_runner`) — 그것이 정본이다. 워커 로그는 러너가 사람이 읽는 줄로 옮겨
7
+ 적은 것이라 stream-json 이 아니다. raw stream-json 로그를 읽는 경로는 러너
8
+ 이전의 로그를 위해 남긴다.
6
9
  """
7
10
  from __future__ import annotations
8
11
 
12
+ import json
9
13
  from pathlib import Path
14
+ from typing import Any, Mapping
10
15
 
11
16
  from .jsonl_io import iter_jsonl
12
17
 
13
18
 
19
+ def _usage_total(usage: Mapping[str, Any], model: str | None) -> dict:
20
+ return {
21
+ "totalTokens": usage.get("total_tokens", 0) or 0,
22
+ "inputTokens": usage.get("input_tokens", 0) or 0,
23
+ "outputTokens": usage.get("output_tokens", 0) or 0,
24
+ "thoughtsTokens": usage.get("thinking_tokens", 0) or 0,
25
+ "cacheReadTokens": usage.get("cache_read_tokens", 0) or 0,
26
+ "model": model,
27
+ "available": True,
28
+ }
29
+
30
+
31
+ def _read_status(status_path: Path) -> dict | None:
32
+ try:
33
+ data = json.loads(status_path.read_text(encoding="utf-8"))
34
+ except (OSError, ValueError):
35
+ return None
36
+ return data if isinstance(data, dict) else None
37
+
38
+
39
+ def status_carries_usage(path: Path) -> bool:
40
+ """이 경로가 ``usage`` 스냅샷을 실은 래퍼 status 사이드카인가."""
41
+ if not path.name.endswith(".status.json"):
42
+ return False
43
+ status = _read_status(path)
44
+ return isinstance((status or {}).get("usage"), dict) and bool(status["usage"])
45
+
46
+
47
+ def antigravity_status_total(status_path: Path) -> dict:
48
+ """래퍼 status 사이드카의 ``usage`` 스냅샷. 모델은 served-model 관측값이다."""
49
+ status = _read_status(status_path) or {}
50
+ usage = status.get("usage")
51
+ if not isinstance(usage, dict) or not usage:
52
+ return {"totalTokens": 0, "available": False}
53
+ attestation = status.get("servedModelAttestation")
54
+ model = (
55
+ attestation.get("observedModel") if isinstance(attestation, dict) else None
56
+ )
57
+ return _usage_total(usage, model if isinstance(model, str) and model else None)
58
+
59
+
14
60
  def antigravity_session_total(json_path: Path) -> dict:
15
- """stream-json 로그에서 마지막 result.usage 를 읽는다."""
61
+ """raw stream-json 로그에서 마지막 result.usage 를 읽는다."""
16
62
  result_usage: dict | None = None
17
63
  step_usage: dict | None = None
18
64
  model: str | None = None
@@ -35,15 +81,7 @@ def antigravity_session_total(json_path: Path) -> dict:
35
81
  usage = result_usage or step_usage
36
82
  if usage is None:
37
83
  return {"totalTokens": 0, "available": False}
38
- return {
39
- "totalTokens": usage.get("total_tokens", 0) or 0,
40
- "inputTokens": usage.get("input_tokens", 0) or 0,
41
- "outputTokens": usage.get("output_tokens", 0) or 0,
42
- "thoughtsTokens": usage.get("thinking_tokens", 0) or 0,
43
- "cacheReadTokens": usage.get("cache_read_tokens", 0) or 0,
44
- "model": model,
45
- "available": True,
46
- }
84
+ return _usage_total(usage, model)
47
85
 
48
86
 
49
87
  def find_antigravity_session(project_root: Path, started_at: str, ended_at: str) -> Path | None:
@@ -21,7 +21,12 @@ from .codex import (
21
21
  codex_session_total,
22
22
  find_codex_sessions,
23
23
  )
24
- from .antigravity import antigravity_session_total, find_antigravity_sessions
24
+ from .antigravity import (
25
+ antigravity_session_total,
26
+ antigravity_status_total,
27
+ find_antigravity_sessions,
28
+ status_carries_usage,
29
+ )
25
30
  from .grok import (
26
31
  find_grok_sessions,
27
32
  grok_session_is_non_interactive,
@@ -179,11 +184,50 @@ def _created_at_by_suffix(run_dir: Path, suffix: str) -> str | None:
179
184
  return None if data is None else data.get("createdAt")
180
185
 
181
186
 
182
- def _run_manifest_created_at(
183
- run_dir: Path, suffix: str, team_state_path: Path
187
+ def relaxation_floor(run_dir: Path, suffix: str, manifest: dict | None) -> str | None:
188
+ """since 완화가 내려갈 수 있는 하한.
189
+
190
+ in-session 리드(`entryMode: current-session`)의 세션은 run 을 위해 태어난 것이
191
+ 아니다 — 한 세션이 여러 날에 걸쳐 여러 task 의 run 을 돌린다(관측 2026-09-02,
192
+ dev-10626 error-analysis r04: 세션 첫 ts 08-17T08:00Z, run createdAt
193
+ 09-02T20:03Z, 리드 소요 398h / $292 로 보고). 그 세션의 첫 ts 는 이 run 의
194
+ 시작에 대해 아무것도 말하지 않으므로 하한은 매니페스트 createdAt 자체다 —
195
+ 완화가 일어나지 않는다. 프로세스를 새로 띄운 리드는 세션이 prep 직전에
196
+ 태어나므로 종전대로 직전 run 의 종료가 하한이다(`_previous_run_end`).
197
+ """
198
+ if manifest is not None and manifest.get("entryMode") == "current-session":
199
+ created = manifest.get("createdAt")
200
+ if created:
201
+ return str(created)
202
+ return _previous_run_end(run_dir, suffix)
203
+
204
+
205
+ def window_start_is_pinned(since: str | None, floor: str | None) -> bool:
206
+ """하한이 시작점 이상이면 완화할 여지가 없다 — 세션 jsonl 을 훑을 이유도 없다."""
207
+ return bool(since and floor and floor >= since)
208
+
209
+
210
+ def relax_window_start(
211
+ since: str | None, earliest: str | None, floor: str | None
184
212
  ) -> str | None:
185
- data = _run_manifest_for_team_state(run_dir, suffix, team_state_path)
186
- return None if data is None else data.get("createdAt")
213
+ """`earliest` 가 `since` 보다 앞서면 하한 안에서 시작점을 앞당긴다."""
214
+ if not earliest:
215
+ return since
216
+ if floor and earliest < floor:
217
+ earliest = floor
218
+ if since is None or earliest < since:
219
+ return earliest
220
+ return since
221
+
222
+
223
+ def run_window_relaxation_floor(team_state_path: Path) -> str | None:
224
+ """collect 의 found-sessions 완화가 쓰는 하한 — `resolve_run_window` 와 같은 규칙."""
225
+ suffix = run_artifact_suffix(team_state_path)
226
+ if not suffix:
227
+ return None
228
+ run_dir = team_state_path.parent.parent
229
+ manifest = _run_manifest_for_team_state(run_dir, suffix, team_state_path)
230
+ return relaxation_floor(run_dir, suffix, manifest)
187
231
 
188
232
 
189
233
  def _run_end_estimate(run_dir: Path, suffix: str) -> str | None:
@@ -319,7 +363,10 @@ def resolve_run_window(
319
363
  since 를 앞당긴다. `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존
320
364
  동작 불변. 완화 하한: 직전 run 의 종료 시각 이전으로는 내려가지 않는다
321
365
  (`_previous_run_end`). 세션 jsonl 은 run 마다 새로 생기지 않으므로 하한이
322
- 없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다.
366
+ 없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다. in-session
367
+ 리드(`entryMode: current-session`)는 완화하지 않는다 — 그 세션은 run 보다
368
+ 먼저 태어났고 다른 task 의 run 도 돌렸으므로 첫 ts 는 세션 탄생일이다
369
+ (`relaxation_floor`).
323
370
 
324
371
  `relax_start=False` (세션 스코프 검증기 전용 — session-conformance /
325
372
  forbidden-actions): 완화를 건너뛰고 since 를 createdAt 에 고정한다. createdAt
@@ -332,14 +379,13 @@ def resolve_run_window(
332
379
  if not suffix:
333
380
  return None, None
334
381
  run_dir = team_state_path.parent.parent
335
- since = _run_manifest_created_at(run_dir, suffix, team_state_path)
382
+ manifest = _run_manifest_for_team_state(run_dir, suffix, team_state_path)
383
+ since = None if manifest is None else manifest.get("createdAt")
336
384
  if relax_start:
337
- earliest = _earliest_lead_session_ts(state, team_state_path)
338
- if earliest and (since is None or earliest < since):
339
- since = earliest
340
- floor = _previous_run_end(run_dir, suffix)
341
- if floor and since and since < floor:
342
- since = floor
385
+ floor = relaxation_floor(run_dir, suffix, manifest)
386
+ if not window_start_is_pinned(since, floor):
387
+ earliest = _earliest_lead_session_ts(state, team_state_path)
388
+ since = relax_window_start(since, earliest, floor)
343
389
  until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
344
390
  return since, until
345
391
 
@@ -516,10 +562,18 @@ def _codex_worker_windows(project_root: Path, state: dict) -> dict[str, list[tup
516
562
  return windows
517
563
 
518
564
 
565
+ # Claude 의 트랜스크립트는 세션 jsonl 이고, 그것은 이 모듈의 Claude 경로가
566
+ # 읽는다. 별도의 CLI 트랜스크립트가 없으므로 CLI 경로에 태우면 매번
567
+ # `no transcript was found` 가 붙는다 — 세션을 찾아 토큰을 붙인 행에도.
568
+ _SESSION_JSONL_PROVIDERS = frozenset({"claude"})
569
+
570
+
519
571
  def _cli_assignment_provider(worker: dict) -> str:
520
572
  worker_id = str(worker.get("workerId") or "").strip()
521
573
  explicit_provider = str(worker.get("provider") or "").strip()
522
574
  provider = explicit_provider or str(worker.get("agent") or "").strip()
575
+ if provider in _SESSION_JSONL_PROVIDERS:
576
+ return ""
523
577
  runner = str(worker.get("runner") or "").strip()
524
578
  if runner and runner != "cli-wrapper":
525
579
  return ""
@@ -557,7 +611,11 @@ def _cli_session_totals(provider: str, session_paths: list[Path]) -> list[dict]:
557
611
  if provider == "codex":
558
612
  total = codex_session_total(session_path)
559
613
  elif provider == "antigravity":
560
- total = antigravity_session_total(session_path)
614
+ total = (
615
+ antigravity_status_total(session_path)
616
+ if status_carries_usage(session_path)
617
+ else antigravity_session_total(session_path)
618
+ )
561
619
  elif provider == "grok":
562
620
  total = grok_session_total(session_path)
563
621
  else:
@@ -703,12 +761,23 @@ def _worker_cli_windows(
703
761
  return fallback_windows, status_path, True
704
762
 
705
763
 
706
- def _antigravity_log_paths(
764
+ def _antigravity_usage_sources(
707
765
  project_root: Path,
708
766
  worker: dict,
709
767
  status_path: Path | None,
710
768
  ) -> list[Path]:
711
- """agy stream-json 은 워커 로그에 남는다. 홈 세션 디렉터리가 아니다."""
769
+ """agy 의 토큰 스냅샷이 있는 곳. 홈 세션 디렉터리가 아니다.
770
+
771
+ 정본은 래퍼 status 사이드카의 `usage` 다 — 러너가 스트림의 마지막
772
+ `result.usage` 를 종료 시점에 적는다(`worker_runner`). 워커 로그는 러너가
773
+ 사람이 읽는 줄로 옮겨 적은 것이라 stream-json 이 아니고, 그 로그에서
774
+ usage 를 찾던 동안 antigravity 워커 전부가 `transcript found but no final
775
+ token snapshot was recorded` 였다(관측 2026-09-02, dev-10626 r01·r02·r04).
776
+ 로그를 읽는 경로는 러너 이전의 raw stream-json 로그를 위해 남긴다 — 스냅샷이
777
+ 있는 run 의 로그는 옮겨 적은 텍스트라 트랜스크립트로 나열하지 않는다.
778
+ """
779
+ if status_path is not None and status_carries_usage(status_path):
780
+ return [status_path]
712
781
  paths: list[Path] = []
713
782
  prompt_path = _resolve_project_path(project_root, str(worker.get("promptPath") or ""))
714
783
  if prompt_path is not None:
@@ -737,9 +806,9 @@ def _worker_cli_usage_block(
737
806
  )
738
807
  session_paths = _cli_sessions_for_windows(provider, project_root, windows)
739
808
  if provider == "antigravity":
740
- for log_path in _antigravity_log_paths(project_root, worker, status_path):
741
- if log_path not in session_paths:
742
- session_paths.append(log_path)
809
+ for source in _antigravity_usage_sources(project_root, worker, status_path):
810
+ if source not in session_paths:
811
+ session_paths.append(source)
743
812
  return collect_cli_usage(
744
813
  provider=provider,
745
814
  status_path=status_path,
@@ -762,8 +831,8 @@ def _attach_cli_usage(
762
831
  window produces several rollout jsonls; pricing only the latest (the old
763
832
  ``find_codex_session`` -> ``sessions[-1]`` behavior) dropped the earlier
764
833
  attempts' tokens. We sum every in-window session here so the redispatched
765
- CLI spend is fully reported. antigravity 는 워커 로그의 stream-json
766
- ``result.usage`` 를 같은 합산 경로에 붙인다.
834
+ CLI spend is fully reported. antigravity 는 status 사이드카의 ``usage``
835
+ 스냅샷을 같은 합산 경로에 붙인다.
767
836
  """
768
837
  session_paths = _cli_sessions_for_windows(provider, project_root, windows)
769
838
  cli = collect_cli_usage(
@@ -794,7 +863,8 @@ def _collect_cli_runtime_usage(
794
863
  provider = _cli_assignment_provider(worker)
795
864
  if not provider:
796
865
  worker["usage"] = na_block(
797
- "worker is host-native or has no registered CLI provider: "
866
+ "worker usage is not read from a provider CLI transcript "
867
+ "(host-native, session-jsonl provider, or no registered CLI provider): "
798
868
  f"{worker.get('provider') or worker.get('agent') or worker.get('workerId')}"
799
869
  )
800
870
  continue
@@ -1024,16 +1094,20 @@ def collect_claude_runtime_usage(
1024
1094
  claude_sessions = find_claude_team_sessions(cwd, team_needles, lead_sid,
1025
1095
  incremental=incremental)
1026
1096
  # Task 9: 축1 기록(observed-team-names)으로 발견한 세션들의 min first-ts 로
1027
- # run_since 를 완화한다. session-* needle 은 세션 고유라 발견 세션은 이 run
1028
- # 것이므로 안전. 불확실한 reconstruct/none source 는 완화하지 않는다(오귀속 방지).
1097
+ # run_since 를 완화한다. 불확실한 reconstruct/none source 는 완화하지
1098
+ # 않는다(오귀속 방지). 하한은 `resolve_run_window` 와 같다 — 발견 세션에는
1099
+ # 리드 세션 자신이 들어 있고, in-session 리드의 세션 첫 ts 는 run 이 아니라
1100
+ # 세션의 탄생일이다(관측: 16일 전). 하한 없이 두던 동안 리드 소요 시간과
1101
+ # 비용이 세션 수명 전체로 보고됐다.
1029
1102
  if needle_source == "observed-team-names":
1030
- found_earliest = None
1031
- for path in claude_sessions.values():
1032
- ts = _session_first_ts(path)
1033
- if ts and (found_earliest is None or ts < found_earliest):
1034
- found_earliest = ts
1035
- if found_earliest and (run_since is None or found_earliest < run_since):
1036
- run_since = found_earliest
1103
+ floor = run_window_relaxation_floor(team_state_path)
1104
+ if not window_start_is_pinned(run_since, floor):
1105
+ found_earliest = None
1106
+ for path in claude_sessions.values():
1107
+ ts = _session_first_ts(path)
1108
+ if ts and (found_earliest is None or ts < found_earliest):
1109
+ found_earliest = ts
1110
+ run_since = relax_window_start(run_since, found_earliest, floor)
1037
1111
  by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
1038
1112
  lead_path: Path | None = None
1039
1113
  # Team-tagged non-lead sessions that carry no agentName. These are almost
@@ -4,7 +4,7 @@ Pricing is matched by substring against the model id recorded in the session
4
4
  transcript, so keys must reflect the *actual* model id form emitted by each
5
5
  provider:
6
6
 
7
- * Anthropic — `claude-fable-5*`, `claude-opus-5*`, `claude-opus-4-*`, `claude-sonnet-5*`, `claude-sonnet-4-*`,
7
+ * Anthropic — `claude-fable-5-1*`, `claude-fable-5*`, `claude-opus-5*`, `claude-opus-4-*`, `claude-sonnet-5*`, `claude-sonnet-4-*`,
8
8
  `claude-haiku-4-5-*`, `claude-3-5-sonnet-*`, `claude-3-5-haiku-*`,
9
9
  `claude-3-opus-*`, `claude-3-haiku-*`.
10
10
  * OpenAI / Codex — `gpt-5*`, `gpt-4o*`, `gpt-4*`.
@@ -15,8 +15,8 @@ Matching prefers the longest key that is a substring of the model id (most
15
15
  specific wins), so table ordering does not affect the result. Update when
16
16
  providers change list pricing.
17
17
 
18
- Sources (last verified 2026-08-03 — Google table re-verified 2026-08-26,
19
- public list prices, USD per 1M tokens):
18
+ Sources (last verified 2026-08-03 — Anthropic Fable 5.1 re-verified 2026-09-02,
19
+ Google table re-verified 2026-08-26, public list prices, USD per 1M tokens):
20
20
  * Anthropic: https://www.anthropic.com/pricing
21
21
  * OpenAI: https://openai.com/api/pricing
22
22
  * Google: https://ai.google.dev/gemini-api/docs/pricing
@@ -50,6 +50,9 @@ CLAUDE_PRICING = {
50
50
  "3-sonnet": (3.0, 3.75, 0.30, 15.0), # legacy 3 Sonnet
51
51
  "3-haiku": (0.25, 0.30, 0.03, 1.25), # Haiku 3
52
52
 
53
+ # Claude Fable 5.1 — input/output match Fable 5; cache_read is 75% lower
54
+ # ($0.25, not the 0.1x ratio). Longest-key match so this beats "fable-5".
55
+ "fable-5-1": (10.0, 12.5, 0.25, 50.0), # Fable 5.1 (Anthropic 2026-09-01)
53
56
  # Claude Fable 5 (tier above Opus).
54
57
  "fable-5": (10.0, 12.5, 1.0, 50.0), # Fable 5 (cache prices derived from ratios)
55
58
 
@@ -36,6 +36,8 @@ These are the only names allowed at the top level:
36
36
 
37
37
  `Analysis Common`, `Change Impact Analysis`, `End State Coverage`, `Error Analysis`, `Feature Analysis`, `Final Verdict`, `Final Verification`, `Follow Up Tasks`, `Human Summary`, `Implementation`, `Implementation Option Selection`, `Implementation Planning`, `Improvement Discovery`, `Project Analysis`, `Rationale`, `Recommended Next Steps`, `Release Handoff`, `Requirements Discovery`, `Summary`, `Ticket Coverage`, `Verdict Card`
38
38
 
39
+ The synthesis packet's Authoring Contract names which of these are **required** for this run and the exact `Verdict Token` value; those lines are read from the frozen report schema, so follow them over memory. `Verdict Token` under `Final Verdict` is pinned by task type: `not-applicable` for `requirements-discovery`, `error-analysis`, `implementation-option-selection`, `implementation-planning`, `implementation`, `release-handoff` and `quick`; one of `accepted`, `conditional-accept`, `blocked` for `final-verification`; one of `analysis-complete`, `analysis-partial`, `blocked` for `project-analysis`, `feature-analysis` and `change-impact-analysis`. `Human Summary`, `Verdict Card`, `Rationale`, `Summary`, `Final Verdict`, `Recommended Next Steps` and `Follow Up Tasks` are required at the top level of every narrative.
40
+
39
41
  Any other top-level name is rejected however reasonable it reads — a section title copied out of a lead procedure document (`Clarification Response Carried In`, `Stage Map`, `Rollback Strategy`) is a heading in that document, not a top-level field here. Nested names come from the task's block in `schemas/final-report-v3.0.schema.json`; when a name is refused, the parser's message lists the names allowed at that exact position, so correct against that list rather than guessing a second time.
40
42
 
41
43
  ## Pointer record
@@ -8105,11 +8105,16 @@ def _run_scoped_glob(pattern: str, suffix: str | None) -> str:
8105
8105
  접미사는 확장자 앞에 `*<suffix>` 로 끼운다 — `convergence-*.json` 은
8106
8106
  `convergence-work-<suffix>.json` 같은 중간 산출물까지 종전처럼 포함하고,
8107
8107
  `*-reverify-r*.md` 는 라운드 라벨(`r1`·`r1b`)을 그대로 흡수한다.
8108
+
8109
+ 호출부 패턴은 전부 `*` 로 끝나는 stem 이라 그냥 이어 붙이면 `**` 가 된다.
8110
+ Python 3.13+ 는 그것을 `*` 와 같게 읽지만 3.11(CI 기준 버전)은
8111
+ `ValueError: '**' can only be an entire path component` 로 죽는다. 두 버전이
8112
+ 같은 결과를 내므로 stem 끝의 `*` 를 흡수해 하나로 만든다.
8108
8113
  """
8109
8114
  if not suffix:
8110
8115
  return pattern
8111
8116
  stem, _dot, ext = pattern.rpartition(".")
8112
- return f"{stem}*{suffix}.{ext}"
8117
+ return f"{stem.rstrip('*')}*{suffix}.{ext}"
8113
8118
 
8114
8119
 
8115
8120
  def _convergence_states(run_dir, suffix=None):