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.
- package/dist/commands/lifecycle/install.mjs +3 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +23 -5
- package/runtime/prompts/lead/adapters/cmux.md +3 -3
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
- package/runtime/python/okstra_ctl/agent/activity.py +4 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
- package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
- package/runtime/python/okstra_ctl/context_cost.py +16 -2
- package/runtime/python/okstra_ctl/convergence.py +6 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
- package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
- package/runtime/python/okstra_ctl/implementation_options.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +1 -1
- package/runtime/python/okstra_ctl/report_finalize.py +7 -1
- package/runtime/python/okstra_ctl/report_narrative.py +132 -33
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
- package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
- package/runtime/python/okstra_ctl/worker_runner.py +42 -8
- package/runtime/python/okstra_token_usage/antigravity.py +50 -12
- package/runtime/python/okstra_token_usage/collect.py +105 -31
- package/runtime/python/okstra_token_usage/pricing.py +6 -3
- package/runtime/templates/report-writer-prompt-preamble.md +2 -0
- 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
|
-
|
|
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
|
|
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
|
|
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 는
|
|
5
|
-
|
|
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
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
if
|
|
339
|
-
|
|
340
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
741
|
-
if
|
|
742
|
-
session_paths.append(
|
|
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 는
|
|
766
|
-
|
|
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
|
|
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 를 완화한다.
|
|
1028
|
-
#
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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 —
|
|
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):
|