okstra 0.64.1 → 0.66.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/bin/okstra +1 -0
- package/docs/kr/architecture.md +2 -0
- package/docs/kr/cli.md +12 -4
- package/docs/kr/performance-improvement-plan-v2.md +2 -1
- package/docs/project-structure-overview.md +1 -0
- package/docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md +1029 -0
- package/docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md +168 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +4 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -0
- package/runtime/agents/workers/gemini-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +4 -0
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +4 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +96 -37
- package/runtime/python/okstra_ctl/context_cost.py +86 -8
- package/runtime/python/okstra_ctl/locks.py +32 -0
- package/runtime/python/okstra_ctl/migrate.py +45 -6
- package/runtime/python/okstra_ctl/models.py +5 -0
- package/runtime/python/okstra_ctl/pr_template.py +2 -7
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/run.py +58 -44
- package/runtime/python/okstra_ctl/run_context.py +3 -8
- package/runtime/python/okstra_ctl/seeding.py +25 -18
- package/runtime/python/okstra_ctl/wizard.py +9 -11
- package/runtime/python/okstra_ctl/worktree.py +13 -0
- package/runtime/python/okstra_project/dirs.py +10 -1
- package/runtime/python/okstra_token_usage/claude.py +226 -61
- package/runtime/python/okstra_token_usage/cli.py +10 -1
- package/runtime/python/okstra_token_usage/collect.py +34 -27
- package/runtime/python/okstra_token_usage/cursor.py +93 -0
- package/runtime/python/okstra_token_usage/paths.py +29 -2
- package/runtime/python/okstra_token_usage/pricing.py +7 -3
- package/runtime/skills/okstra-coding-preflight/clean-code.md +15 -0
- package/runtime/skills/okstra-inspect/SKILL.md +16 -11
- package/runtime/skills/okstra-run/templates/pr-body.template.md +13 -16
- package/runtime/skills/okstra-schedule/SKILL.md +3 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
- package/runtime/validators/lib/fixtures.sh +73 -10
- package/runtime/validators/lib/runners.sh +4 -0
- package/runtime/validators/validate-run.py +53 -0
- package/runtime/validators/validate_session_conformance.py +430 -0
- package/src/migrate.mjs +31 -0
|
@@ -9,11 +9,12 @@ DRY 위반의 비용: 이전에는 동일 path 문자열이 50+ Python·Shell·m
|
|
|
9
9
|
중복으로 박혀 있었고, 디렉토리 이름을 바꾸려면 60+ 파일을 동시에 수정해야 했다.
|
|
10
10
|
이 모듈은 그 비용을 한 줄 수정으로 줄인다.
|
|
11
11
|
|
|
12
|
-
의존성 0 (
|
|
12
|
+
의존성 0 (stdlib only). `paths.py` 와 `state.py` 양쪽에서 import 되므로 순환 위험을
|
|
13
13
|
피하기 위해 다른 okstra 모듈을 import 하지 않는다.
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import os
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
20
|
OKSTRA_DIR_NAME = ".okstra"
|
|
@@ -39,6 +40,14 @@ TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
|
|
|
39
40
|
LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
def okstra_home() -> Path:
|
|
44
|
+
"""`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override."""
|
|
45
|
+
override = os.environ.get("OKSTRA_HOME", "").strip()
|
|
46
|
+
if override:
|
|
47
|
+
return Path(override)
|
|
48
|
+
return Path.home() / ".okstra"
|
|
49
|
+
|
|
50
|
+
|
|
42
51
|
def okstra_root(project_root: Path) -> Path:
|
|
43
52
|
"""`<project_root>/.okstra` 절대 path."""
|
|
44
53
|
return Path(project_root) / OKSTRA_RELATIVE
|
|
@@ -1,66 +1,151 @@
|
|
|
1
1
|
"""Claude Code transcript collectors."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from .jsonl_io import iter_jsonl
|
|
7
|
-
from .paths import claude_project_dir
|
|
8
7
|
|
|
8
|
+
from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
|
|
9
|
+
from .paths import claude_project_dir, ts_in_window
|
|
9
10
|
|
|
10
|
-
def claude_session_totals(
|
|
11
|
-
jsonl_path: Path, *, since: str | None = None, until: str | None = None
|
|
12
|
-
) -> dict:
|
|
13
|
-
"""Return totals + agentName + assistant model + time window for a Claude session jsonl.
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
run). ``agentName`` / ``model`` are session metadata and are read from the
|
|
22
|
-
whole file regardless of the window. Records without a timestamp are kept
|
|
23
|
-
(conservative — never silently drop usage when we can't place it in time).
|
|
12
|
+
def _event_from_record(rec: dict) -> dict | None:
|
|
13
|
+
"""jsonl 레코드 1개 → 압축 이벤트. 집계에 기여하지 않으면 None.
|
|
14
|
+
|
|
15
|
+
키: t=timestamp, i/o=input/output, c=cache_creation 합, c5/c1=ephemeral
|
|
16
|
+
5m/1h, r=cache_read, u=tool_use 수. 0/부재 필드는 생략(캐시 크기 절약).
|
|
17
|
+
ts-only 레코드도 보존한다 — 임의 윈도우의 first/last ts 산출에 필요.
|
|
24
18
|
"""
|
|
19
|
+
msg = rec.get("message")
|
|
20
|
+
if not isinstance(msg, dict):
|
|
21
|
+
msg = {}
|
|
22
|
+
ev: dict = {}
|
|
23
|
+
usage = msg.get("usage")
|
|
24
|
+
if usage:
|
|
25
|
+
for src, key in (("input_tokens", "i"), ("output_tokens", "o"),
|
|
26
|
+
("cache_read_input_tokens", "r")):
|
|
27
|
+
v = usage.get(src, 0) or 0
|
|
28
|
+
if v:
|
|
29
|
+
ev[key] = v
|
|
30
|
+
cc_total = usage.get("cache_creation_input_tokens", 0) or 0
|
|
31
|
+
if cc_total:
|
|
32
|
+
ev["c"] = cc_total
|
|
33
|
+
cc_break = usage.get("cache_creation") or {}
|
|
34
|
+
if isinstance(cc_break, dict) and (
|
|
35
|
+
cc_break.get("ephemeral_5m_input_tokens") is not None
|
|
36
|
+
or cc_break.get("ephemeral_1h_input_tokens") is not None):
|
|
37
|
+
v5 = cc_break.get("ephemeral_5m_input_tokens", 0) or 0
|
|
38
|
+
v1 = cc_break.get("ephemeral_1h_input_tokens", 0) or 0
|
|
39
|
+
if v5:
|
|
40
|
+
ev["c5"] = v5
|
|
41
|
+
if v1:
|
|
42
|
+
ev["c1"] = v1
|
|
43
|
+
elif cc_total:
|
|
44
|
+
# API 분해가 없으면 전부 5m 티어로(1.25x — 더 싼 가정, 기존 동작).
|
|
45
|
+
ev["c5"] = cc_total
|
|
46
|
+
if rec.get("type") == "assistant":
|
|
47
|
+
tools = sum(1 for b in (msg.get("content") or [])
|
|
48
|
+
if isinstance(b, dict) and b.get("type") == "tool_use")
|
|
49
|
+
if tools:
|
|
50
|
+
ev["u"] = tools
|
|
51
|
+
ts = rec.get("timestamp") or msg.get("timestamp")
|
|
52
|
+
if ts:
|
|
53
|
+
ev["t"] = ts
|
|
54
|
+
return ev or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _session_meta_from_record(rec: dict) -> tuple[str | None, str | None]:
|
|
58
|
+
"""레코드에서 (agentName, model) 후보 추출 — 둘 다 first-non-null 정책."""
|
|
59
|
+
agent = rec.get("agentName") or None
|
|
60
|
+
model = None
|
|
61
|
+
if rec.get("type") == "assistant":
|
|
62
|
+
msg = rec.get("message")
|
|
63
|
+
if isinstance(msg, dict) and msg.get("model"):
|
|
64
|
+
model = msg["model"]
|
|
65
|
+
return agent, model
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _advance_usage_scan(jsonl_path: Path, usage_state: dict) -> dict:
|
|
69
|
+
"""`usage_state['offset']` 이후의 완결 라인을 읽어 이벤트를 커밋하고,
|
|
70
|
+
개행 없는 마지막 라인은 transient 로만 반영한 view 를 돌려준다.
|
|
71
|
+
|
|
72
|
+
transient tail: 아직 쓰는 중일 수 있는 라인 — 이번 집계에는 포함하되
|
|
73
|
+
커서를 전진시키지 않아, 다음 호출이 완결본으로 다시 읽는다(이중 집계도
|
|
74
|
+
누락도 없음). 깨진 utf-8 / JSON / 비-dict 라인은 건너뛰되 커서는 전진
|
|
75
|
+
(구버전은 text-mode 디코드 실패 시 collect 전체가 죽었다 — fail-open 개선).
|
|
76
|
+
"""
|
|
77
|
+
events = list(usage_state.get("events") or [])
|
|
78
|
+
agent_name = usage_state.get("agentName")
|
|
79
|
+
model = usage_state.get("model")
|
|
80
|
+
offset = usage_state.get("offset", 0) or 0
|
|
81
|
+
try:
|
|
82
|
+
size = jsonl_path.stat().st_size
|
|
83
|
+
except OSError:
|
|
84
|
+
size = 0
|
|
85
|
+
if offset > size:
|
|
86
|
+
# 식별자 가드를 통과했더라도 truncate 방어 — 처음부터 재스캔.
|
|
87
|
+
events, agent_name, model, offset = [], None, None, 0
|
|
88
|
+
tail_events: list[dict] = []
|
|
89
|
+
tail_agent: str | None = None
|
|
90
|
+
tail_model: str | None = None
|
|
91
|
+
try:
|
|
92
|
+
with jsonl_path.open("rb") as fh:
|
|
93
|
+
fh.seek(offset)
|
|
94
|
+
while True:
|
|
95
|
+
raw = fh.readline()
|
|
96
|
+
if not raw:
|
|
97
|
+
break
|
|
98
|
+
rec = None
|
|
99
|
+
stripped = raw.strip()
|
|
100
|
+
if stripped:
|
|
101
|
+
try:
|
|
102
|
+
parsed = json.loads(stripped.decode("utf-8"))
|
|
103
|
+
rec = parsed if isinstance(parsed, dict) else None
|
|
104
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
105
|
+
rec = None
|
|
106
|
+
ev = _event_from_record(rec) if rec else None
|
|
107
|
+
rec_agent, rec_model = _session_meta_from_record(rec) if rec else (None, None)
|
|
108
|
+
if raw.endswith(b"\n"):
|
|
109
|
+
offset = fh.tell()
|
|
110
|
+
if agent_name is None and rec_agent:
|
|
111
|
+
agent_name = rec_agent
|
|
112
|
+
if model is None and rec_model:
|
|
113
|
+
model = rec_model
|
|
114
|
+
if ev:
|
|
115
|
+
events.append(ev)
|
|
116
|
+
else:
|
|
117
|
+
tail_agent, tail_model = rec_agent, rec_model
|
|
118
|
+
if ev:
|
|
119
|
+
tail_events.append(ev)
|
|
120
|
+
break
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
usage_state.update(offset=offset, events=events,
|
|
124
|
+
agentName=agent_name, model=model)
|
|
125
|
+
return {"events": events + tail_events,
|
|
126
|
+
"agentName": agent_name if agent_name is not None else tail_agent,
|
|
127
|
+
"model": model if model is not None else tail_model}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _totals_from_events(events: list[dict], agent_name: str | None,
|
|
131
|
+
model: str | None,
|
|
132
|
+
since: str | None, until: str | None) -> dict:
|
|
25
133
|
input_t = output_t = cache_create_t = cache_read_t = 0
|
|
26
134
|
cache_create_5m_t = cache_create_1h_t = 0
|
|
27
135
|
tool_uses = 0
|
|
28
|
-
agent_name: str | None = None
|
|
29
|
-
model: str | None = None
|
|
30
136
|
first_ts: str | None = None
|
|
31
137
|
last_ts: str | None = None
|
|
32
|
-
for
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
msg = rec.get("message") or {}
|
|
36
|
-
ts = rec.get("timestamp") or (msg.get("timestamp") if isinstance(msg, dict) else None)
|
|
37
|
-
in_window = not (ts and ((since and ts < since) or (until and ts > until)))
|
|
38
|
-
if rec.get("type") == "assistant" and model is None and msg.get("model"):
|
|
39
|
-
model = msg["model"]
|
|
40
|
-
if not in_window:
|
|
138
|
+
for ev in events:
|
|
139
|
+
ts = ev.get("t")
|
|
140
|
+
if ts and not ts_in_window(ts, since, until):
|
|
41
141
|
continue
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# Split into 5m / 1h ephemeral tiers when the API breakdown is
|
|
50
|
-
# present. If only the aggregate is given, attribute all of it to
|
|
51
|
-
# the 5m tier (1.25x — the cheaper assumption, matches prior
|
|
52
|
-
# behavior).
|
|
53
|
-
cc_break = usage.get("cache_creation") or {}
|
|
54
|
-
if isinstance(cc_break, dict) and (cc_break.get("ephemeral_5m_input_tokens") is not None
|
|
55
|
-
or cc_break.get("ephemeral_1h_input_tokens") is not None):
|
|
56
|
-
cache_create_5m_t += cc_break.get("ephemeral_5m_input_tokens", 0) or 0
|
|
57
|
-
cache_create_1h_t += cc_break.get("ephemeral_1h_input_tokens", 0) or 0
|
|
58
|
-
else:
|
|
59
|
-
cache_create_5m_t += cc_total
|
|
60
|
-
if rec.get("type") == "assistant":
|
|
61
|
-
for block in (msg.get("content") or []):
|
|
62
|
-
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
63
|
-
tool_uses += 1
|
|
142
|
+
input_t += ev.get("i", 0)
|
|
143
|
+
output_t += ev.get("o", 0)
|
|
144
|
+
cache_create_t += ev.get("c", 0)
|
|
145
|
+
cache_create_5m_t += ev.get("c5", 0)
|
|
146
|
+
cache_create_1h_t += ev.get("c1", 0)
|
|
147
|
+
cache_read_t += ev.get("r", 0)
|
|
148
|
+
tool_uses += ev.get("u", 0)
|
|
64
149
|
if ts:
|
|
65
150
|
if first_ts is None or ts < first_ts:
|
|
66
151
|
first_ts = ts
|
|
@@ -97,7 +182,75 @@ def claude_session_totals(
|
|
|
97
182
|
}
|
|
98
183
|
|
|
99
184
|
|
|
100
|
-
def
|
|
185
|
+
def claude_session_totals(
|
|
186
|
+
jsonl_path: Path, *, since: str | None = None, until: str | None = None,
|
|
187
|
+
incremental: bool = False,
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Return totals + agentName + assistant model + time window for a Claude session jsonl.
|
|
190
|
+
|
|
191
|
+
``since`` / ``until`` are ISO-8601 timestamp strings (UTC ``...Z``). When
|
|
192
|
+
given, only records whose ``timestamp`` falls within ``[since, until]`` are
|
|
193
|
+
counted toward tokens / tool_uses / duration. This is the run-scoping seam:
|
|
194
|
+
an **in-session** lead writes its run into the user's whole-session jsonl,
|
|
195
|
+
so without a window the totals swallow every unrelated turn (observed:
|
|
196
|
+
lead billed 1.7억 tokens / $416 / 3h for a single requirements-discovery
|
|
197
|
+
run). ``agentName`` / ``model`` are session metadata and are read from the
|
|
198
|
+
whole file regardless of the window. Records without a timestamp are kept
|
|
199
|
+
(conservative — never silently drop usage when we can't place it in time).
|
|
200
|
+
|
|
201
|
+
``incremental=True`` 면 $OKSTRA_HOME 캐시의 byte cursor 이후만 읽는다.
|
|
202
|
+
캐시에는 윈도우 적용 전 이벤트가 저장되므로 호출마다 다른 since/until
|
|
203
|
+
에도 결과는 전체 스캔과 동일하다 (P6 plan 참조).
|
|
204
|
+
"""
|
|
205
|
+
if incremental:
|
|
206
|
+
cache = load_cache(jsonl_path)
|
|
207
|
+
view = _advance_usage_scan(jsonl_path, cache["usage"])
|
|
208
|
+
save_cache(jsonl_path, cache)
|
|
209
|
+
else:
|
|
210
|
+
view = _advance_usage_scan(jsonl_path, fresh_cache()["usage"])
|
|
211
|
+
return _totals_from_events(view["events"], view["agentName"],
|
|
212
|
+
view["model"], since, until)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _needle_scan(jsonl_path: Path, entry: dict, needle_lower: str) -> bool:
|
|
216
|
+
"""entry({'offset','found'}) 를 전진시키며 needle 존재 여부 반환.
|
|
217
|
+
|
|
218
|
+
미완결 tail 라인도 검사한다 — 부분 문자열 매칭은 라인 완결 후에도 유효
|
|
219
|
+
하므로 found=True 는 그대로 커밋해도 안전하다. 단 offset 은 완결 라인
|
|
220
|
+
까지만 전진해, 미완결 tail 은 다음 호출이 다시 본다.
|
|
221
|
+
"""
|
|
222
|
+
if entry.get("found"):
|
|
223
|
+
return True
|
|
224
|
+
offset = entry.get("offset", 0) or 0
|
|
225
|
+
try:
|
|
226
|
+
if offset > jsonl_path.stat().st_size:
|
|
227
|
+
offset = 0 # truncate/교체 방어
|
|
228
|
+
with jsonl_path.open("rb") as fh:
|
|
229
|
+
fh.seek(offset)
|
|
230
|
+
while True:
|
|
231
|
+
raw = fh.readline()
|
|
232
|
+
if not raw:
|
|
233
|
+
break
|
|
234
|
+
if needle_lower in raw.decode("utf-8", errors="replace").lower():
|
|
235
|
+
entry["found"] = True
|
|
236
|
+
entry["offset"] = offset
|
|
237
|
+
return True
|
|
238
|
+
if raw.endswith(b"\n"):
|
|
239
|
+
offset = fh.tell()
|
|
240
|
+
except OSError:
|
|
241
|
+
return False
|
|
242
|
+
entry["offset"] = offset
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def find_claude_team_sessions(
|
|
247
|
+
cwd: Path,
|
|
248
|
+
team_name: str,
|
|
249
|
+
lead_sid: str | None = None,
|
|
250
|
+
projects_root: Path | None = None,
|
|
251
|
+
*,
|
|
252
|
+
incremental: bool = False,
|
|
253
|
+
) -> dict[str, Path]:
|
|
101
254
|
"""Map sessionId -> jsonl path for all jsonls tagged with `team_name`.
|
|
102
255
|
|
|
103
256
|
Matching is case-insensitive on the teamName needle to tolerate runs where
|
|
@@ -107,25 +260,37 @@ def find_claude_team_sessions(cwd: Path, team_name: str, lead_sid: str | None =
|
|
|
107
260
|
If `lead_sid` is provided and exists in the project dir, it is always
|
|
108
261
|
included even when no teamName needle matches — this lets us recover lead
|
|
109
262
|
usage in fallback runs that never wrote `team.teamName` into team-state.
|
|
263
|
+
|
|
264
|
+
`projects_root` 는 테스트/진단용 주입 시드 — 기본은 실제 ~/.claude/projects.
|
|
265
|
+
|
|
266
|
+
``incremental=True`` 면 파일별 needle cursor 이후의 신규 byte 만 검사한다.
|
|
267
|
+
needle(=team 이름)은 run 마다 다르므로 파일당 MAX_NEEDLES 개까지 오래된
|
|
268
|
+
순으로 교체 보존한다.
|
|
110
269
|
"""
|
|
111
|
-
proj_dir = claude_project_dir(cwd)
|
|
270
|
+
proj_dir = claude_project_dir(cwd, projects_root)
|
|
112
271
|
out: dict[str, Path] = {}
|
|
113
272
|
if not proj_dir.is_dir():
|
|
114
273
|
return out
|
|
115
274
|
needle_lower = f'"teamname":"{(team_name or "").lower()}"'
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
275
|
+
if team_name:
|
|
276
|
+
for p in proj_dir.glob("*.jsonl"):
|
|
277
|
+
if incremental:
|
|
278
|
+
cache = load_cache(p)
|
|
279
|
+
needles = cache.setdefault("needles", {})
|
|
280
|
+
entry = needles.get(needle_lower)
|
|
281
|
+
if entry is None:
|
|
282
|
+
entry = {"offset": 0, "found": False}
|
|
283
|
+
while len(needles) >= MAX_NEEDLES:
|
|
284
|
+
needles.pop(next(iter(needles)))
|
|
285
|
+
needles[needle_lower] = entry
|
|
286
|
+
if _needle_scan(p, entry, needle_lower):
|
|
287
|
+
out[p.stem] = p
|
|
288
|
+
save_cache(p, cache)
|
|
289
|
+
else:
|
|
290
|
+
if _needle_scan(p, {"offset": 0, "found": False}, needle_lower):
|
|
291
|
+
out[p.stem] = p
|
|
126
292
|
if lead_sid:
|
|
127
293
|
direct = proj_dir / f"{lead_sid}.jsonl"
|
|
128
294
|
if direct.is_file():
|
|
129
295
|
out.setdefault(lead_sid, direct)
|
|
130
296
|
return out
|
|
131
|
-
|
|
@@ -39,6 +39,14 @@ def main() -> int:
|
|
|
39
39
|
action="store_true",
|
|
40
40
|
help="Also print a one-line summary to stderr",
|
|
41
41
|
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--no-cache",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help=(
|
|
46
|
+
"Disable the incremental session-scan cache and force a full "
|
|
47
|
+
"linear rescan of every session jsonl (correctness fallback)"
|
|
48
|
+
),
|
|
49
|
+
)
|
|
42
50
|
parser.add_argument(
|
|
43
51
|
"--substitute-data",
|
|
44
52
|
type=Path,
|
|
@@ -58,7 +66,8 @@ def main() -> int:
|
|
|
58
66
|
print(f"team-state not found: {args.team_state}", file=sys.stderr)
|
|
59
67
|
return 2
|
|
60
68
|
|
|
61
|
-
updated = collect(args.team_state, args.project_root
|
|
69
|
+
updated = collect(args.team_state, args.project_root,
|
|
70
|
+
incremental=not args.no_cache)
|
|
62
71
|
|
|
63
72
|
if args.write:
|
|
64
73
|
args.team_state.write_text(
|
|
@@ -86,7 +86,7 @@ def _aggregate_totals(items: list[dict]) -> dict:
|
|
|
86
86
|
return aggregate
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def
|
|
89
|
+
def run_artifact_suffix(team_state_path: Path) -> str | None:
|
|
90
90
|
"""``team-state-<task-type>-<seq>.json`` → ``<task-type>-<seq>``.
|
|
91
91
|
|
|
92
92
|
이 접미사로 *같은 run* 의 run-manifest / status 를 정확히 짚는다. task 디렉토리
|
|
@@ -118,7 +118,7 @@ def _run_end_estimate(run_dir: Path, suffix: str) -> str | None:
|
|
|
118
118
|
return datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
def
|
|
121
|
+
def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
|
|
122
122
|
"""이 run 의 [시작, 종료] ISO 윈도우.
|
|
123
123
|
|
|
124
124
|
in-session lead 는 자기 run 을 사용자의 *세션 전체* jsonl 에 기록하므로,
|
|
@@ -128,7 +128,7 @@ def _resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
|
|
|
128
128
|
이 run 의 run-manifest createdAt, 종료 = team-state.runEndedAt → 이 run 의
|
|
129
129
|
status mtime → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
|
|
130
130
|
(None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백."""
|
|
131
|
-
suffix =
|
|
131
|
+
suffix = run_artifact_suffix(team_state_path)
|
|
132
132
|
if not suffix:
|
|
133
133
|
return None, None
|
|
134
134
|
run_dir = team_state_path.parent.parent
|
|
@@ -137,37 +137,42 @@ def _resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
|
|
|
137
137
|
return since, until
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def
|
|
141
|
-
state
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# - nested: state.team.teamName (current documented schema)
|
|
152
|
-
# - root: state.teamName (older convention; still common in
|
|
153
|
-
# actual runs because the team
|
|
154
|
-
# contract docs did not pin the
|
|
155
|
-
# location until v0.24)
|
|
156
|
-
# Read both; whichever is non-empty wins. The fallback derives a short
|
|
157
|
-
# team name from task-id only and routinely mis-matches multi-segment
|
|
158
|
-
# task keys (e.g. `okstra-fontsninja-classifier-v2:DEV-9389:DEV-9389`),
|
|
159
|
-
# so it is a last resort.
|
|
160
|
-
state_team = (state.get("team") or {})
|
|
140
|
+
def resolve_team_name(state: dict) -> str:
|
|
141
|
+
"""team-state 에서 이 run 의 team name 을 해소한다.
|
|
142
|
+
|
|
143
|
+
Phase 3 TeamCreate 성공 시 lead 가 기록한 값을 우선한다. 기록 위치는 계약
|
|
144
|
+
버전에 따라 둘 중 하나다:
|
|
145
|
+
- nested: state.team.teamName (현재 문서화된 스키마)
|
|
146
|
+
- root: state.teamName (v0.24 이전 관행; 실 run 에 여전히 흔함)
|
|
147
|
+
둘 다 비어 있으면 `okstra-<task-id>` 관례로 폴백 — task-id 만 쓰므로
|
|
148
|
+
multi-segment task key 에서 빈번히 mis-match 하는 최후 수단이다.
|
|
149
|
+
"""
|
|
150
|
+
state_team = state.get("team") or {}
|
|
161
151
|
team_name = state_team.get("teamName") or state.get("teamName") or ""
|
|
162
152
|
if not team_name:
|
|
153
|
+
task_key = state.get("taskKey", "")
|
|
163
154
|
task_id = task_key.rsplit(":", 1)[-1] if task_key else ""
|
|
164
155
|
team_name = f"okstra-{task_id}" if task_id else ""
|
|
156
|
+
return team_name
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
160
|
+
incremental: bool = True) -> dict:
|
|
161
|
+
# incremental: 세션 jsonl 스캔에 byte cursor 캐시 사용 (P6). 캐시는 윈도우
|
|
162
|
+
# 적용 전 이벤트를 저장하므로 결과는 전체 스캔과 동일 — False 는 캐시 경로를
|
|
163
|
+
# 완전히 우회하는 정확성 폴백(CLI --no-cache).
|
|
164
|
+
state = json.loads(team_state_path.read_text())
|
|
165
|
+
cwd = project_root or _infer_project_root(team_state_path, state)
|
|
166
|
+
run_since, run_until = resolve_run_window(team_state_path, state)
|
|
167
|
+
task_key = state.get("taskKey", "")
|
|
168
|
+
team_name = resolve_team_name(state)
|
|
165
169
|
lead_sid = (state.get("lead") or {}).get("sessionId")
|
|
166
170
|
|
|
167
171
|
# 1) Claude sessions (lead + claude-side workers). Cache totals at scan
|
|
168
172
|
# time so we don't re-read the jsonl when a worker matches multiple
|
|
169
173
|
# sessions.
|
|
170
|
-
claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid
|
|
174
|
+
claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid,
|
|
175
|
+
incremental=incremental)
|
|
171
176
|
by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
|
|
172
177
|
lead_path: Path | None = None
|
|
173
178
|
# Team-tagged non-lead sessions that carry no agentName. These are almost
|
|
@@ -182,7 +187,8 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
|
|
|
182
187
|
if sid == lead_sid:
|
|
183
188
|
lead_path = path
|
|
184
189
|
continue
|
|
185
|
-
totals = claude_session_totals(path, since=run_since, until=run_until
|
|
190
|
+
totals = claude_session_totals(path, since=run_since, until=run_until,
|
|
191
|
+
incremental=incremental)
|
|
186
192
|
agent = totals.get("agentName")
|
|
187
193
|
if agent:
|
|
188
194
|
by_agent.setdefault(agent, []).append((sid, path, totals))
|
|
@@ -191,7 +197,8 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
|
|
|
191
197
|
|
|
192
198
|
# Lead.
|
|
193
199
|
if lead_path is not None:
|
|
194
|
-
totals = claude_session_totals(lead_path, since=run_since, until=run_until
|
|
200
|
+
totals = claude_session_totals(lead_path, since=run_since, until=run_until,
|
|
201
|
+
incremental=incremental)
|
|
195
202
|
state["leadUsage"] = usage_block(totals, source="claude-jsonl")
|
|
196
203
|
state["leadUsage"]["sessionId"] = lead_sid
|
|
197
204
|
else:
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""세션 jsonl 증분 스캔 캐시 — byte cursor + usage 이벤트 추출본 (P6).
|
|
2
|
+
|
|
3
|
+
캐시에는 *윈도우 적용 전* 이벤트 추출본을 저장하고, since/until 윈도우는 매
|
|
4
|
+
호출 시 이벤트 위에서 재평가한다. run 재시도로 윈도우가 좁아져도(until 이
|
|
5
|
+
과거로 이동) 합계가 틀어지지 않는 이유다.
|
|
6
|
+
|
|
7
|
+
캐시는 파생 데이터다: head-bytes 식별자 불일치(파일 교체)·truncate·손상 시
|
|
8
|
+
조용히 폐기하고 전체 재스캔으로 폴백한다(fail-open). 쓰기는 tmp+os.replace
|
|
9
|
+
원자적. 동시 collect 가 같은 캐시를 쓰면 last-writer-wins — 최악의 경우 다음
|
|
10
|
+
호출이 일부 byte 를 다시 읽을 뿐 결과는 불변.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from okstra_project.dirs import okstra_home
|
|
20
|
+
|
|
21
|
+
CACHE_SCHEMA_VERSION = 1
|
|
22
|
+
IDENTITY_PREFIX_BYTES = 256
|
|
23
|
+
MAX_NEEDLES = 16
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cache_path_for(jsonl_path: Path) -> Path:
|
|
27
|
+
"""`$OKSTRA_HOME/cache/token-usage/<transcript-dir-name>/<session>.json`."""
|
|
28
|
+
return (okstra_home() / "cache" / "token-usage"
|
|
29
|
+
/ jsonl_path.parent.name / f"{jsonl_path.stem}.json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fresh_cache(identity: dict | None = None) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"schemaVersion": CACHE_SCHEMA_VERSION,
|
|
35
|
+
"identity": identity,
|
|
36
|
+
"usage": {"offset": 0, "agentName": None, "model": None, "events": []},
|
|
37
|
+
"needles": {},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _file_identity(jsonl_path: Path) -> dict | None:
|
|
42
|
+
try:
|
|
43
|
+
with jsonl_path.open("rb") as fh:
|
|
44
|
+
prefix = fh.read(IDENTITY_PREFIX_BYTES)
|
|
45
|
+
except OSError:
|
|
46
|
+
return None
|
|
47
|
+
return {"prefixLen": len(prefix), "sha256": hashlib.sha256(prefix).hexdigest()}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _identity_matches(jsonl_path: Path, identity: object) -> bool:
|
|
51
|
+
if not isinstance(identity, dict):
|
|
52
|
+
return False
|
|
53
|
+
want_len = identity.get("prefixLen") or 0
|
|
54
|
+
try:
|
|
55
|
+
with jsonl_path.open("rb") as fh:
|
|
56
|
+
prefix = fh.read(want_len)
|
|
57
|
+
except OSError:
|
|
58
|
+
return False
|
|
59
|
+
if len(prefix) != want_len:
|
|
60
|
+
return False # 캐시 시점보다 짧아짐 → truncate/교체
|
|
61
|
+
return hashlib.sha256(prefix).hexdigest() == identity.get("sha256")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_cache(jsonl_path: Path) -> dict:
|
|
65
|
+
"""파일에 대응하는 캐시. 미스·손상·버전/식별자 불일치면 빈 캐시.
|
|
66
|
+
|
|
67
|
+
identity 는 이번 스캔 시점 기준으로 갱신해 둔다 — 첫 256B 미만이던 파일이
|
|
68
|
+
자란 경우 prefix 를 늘려 잡기 위함(append-only 라 기존 prefix 는 불변).
|
|
69
|
+
"""
|
|
70
|
+
identity = _file_identity(jsonl_path)
|
|
71
|
+
p = cache_path_for(jsonl_path)
|
|
72
|
+
try:
|
|
73
|
+
cache = json.loads(p.read_text())
|
|
74
|
+
except (OSError, json.JSONDecodeError):
|
|
75
|
+
return fresh_cache(identity)
|
|
76
|
+
if not isinstance(cache, dict) or cache.get("schemaVersion") != CACHE_SCHEMA_VERSION:
|
|
77
|
+
return fresh_cache(identity)
|
|
78
|
+
if not _identity_matches(jsonl_path, cache.get("identity")):
|
|
79
|
+
return fresh_cache(identity)
|
|
80
|
+
cache["identity"] = identity
|
|
81
|
+
return cache
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_cache(jsonl_path: Path, cache: dict) -> None:
|
|
85
|
+
"""원자적 저장. 실패는 무시 — 캐시는 파생 데이터, 결과에 영향 없음."""
|
|
86
|
+
p = cache_path_for(jsonl_path)
|
|
87
|
+
try:
|
|
88
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
tmp = p.with_suffix(".json.tmp")
|
|
90
|
+
tmp.write_text(json.dumps(cache, ensure_ascii=False, separators=(",", ":")))
|
|
91
|
+
os.replace(tmp, p)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
@@ -15,8 +15,35 @@ def utc_now() -> str:
|
|
|
15
15
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def _floor_to_second(ts: str) -> datetime | None:
|
|
19
|
+
try:
|
|
20
|
+
moment = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
if moment.tzinfo is None:
|
|
24
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
25
|
+
return moment.replace(microsecond=0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ts_in_window(ts: str, since: str | None, until: str | None) -> bool:
|
|
29
|
+
"""ts 가 run 윈도우 [since, until] 안인지 — 초 단위로 절삭해 비교한다.
|
|
30
|
+
|
|
31
|
+
세션 jsonl 의 ts 는 밀리초(`…00.123Z`), 윈도우 끝점(run-manifest createdAt /
|
|
32
|
+
status mtime)은 초(`…00Z`) 정밀도라 문자열 비교는 '.' < 'Z' 탓에 경계 초의
|
|
33
|
+
레코드를 잘못 떨군다. 파싱 불가한 끝점은 개방 경계로, 파싱 불가한 ts 는
|
|
34
|
+
포함으로 취급한다(빈 ts 를 포함시키는 기존 동작과 동일 원칙).
|
|
35
|
+
"""
|
|
36
|
+
moment = _floor_to_second(ts)
|
|
37
|
+
if moment is None:
|
|
38
|
+
return True
|
|
39
|
+
lo = _floor_to_second(since) if since else None
|
|
40
|
+
hi = _floor_to_second(until) if until else None
|
|
41
|
+
return not ((lo is not None and moment < lo) or (hi is not None and moment > hi))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def claude_project_dir(cwd: Path, projects_root: Path | None = None) -> Path:
|
|
19
45
|
# Claude Code encodes cwd by replacing "/" with "-" (leading slash → leading "-").
|
|
46
|
+
# `projects_root` 는 테스트/진단용 주입 시드 — 기본은 실제 ~/.claude/projects.
|
|
20
47
|
encoded = "-" + str(cwd).strip("/").replace("/", "-")
|
|
21
|
-
return CLAUDE_PROJECTS / encoded
|
|
48
|
+
return (projects_root or CLAUDE_PROJECTS) / encoded
|
|
22
49
|
|
|
@@ -4,9 +4,9 @@ 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-
|
|
8
|
-
`claude-
|
|
9
|
-
`claude-3-haiku-*`.
|
|
7
|
+
* Anthropic — `claude-fable-5*`, `claude-opus-4-*`, `claude-sonnet-4-*`,
|
|
8
|
+
`claude-haiku-4-5-*`, `claude-3-5-sonnet-*`, `claude-3-5-haiku-*`,
|
|
9
|
+
`claude-3-opus-*`, `claude-3-haiku-*`.
|
|
10
10
|
* OpenAI / Codex — `gpt-5*`, `gpt-4o*`, `gpt-4*`.
|
|
11
11
|
* Google / Gemini — `gemini-2.5-pro*`, `gemini-2.5-flash*`, `gemini-2.0-flash*`.
|
|
12
12
|
|
|
@@ -45,7 +45,11 @@ CLAUDE_PRICING = {
|
|
|
45
45
|
"3-sonnet": (3.0, 3.75, 0.30, 15.0), # legacy 3 Sonnet
|
|
46
46
|
"3-haiku": (0.25, 0.30, 0.03, 1.25), # Haiku 3
|
|
47
47
|
|
|
48
|
+
# Claude Fable 5 (tier above Opus).
|
|
49
|
+
"fable-5": (10.0, 12.5, 1.0, 50.0), # Fable 5 (cache prices derived from ratios)
|
|
50
|
+
|
|
48
51
|
# Claude 4 point releases (explicit so future divergence is easy to see).
|
|
52
|
+
"opus-4-8": (5.0, 6.25, 0.50, 25.0), # Opus 4.8 (cache prices derived from ratios)
|
|
49
53
|
"opus-4-7": (5.0, 6.25, 0.50, 25.0), # Opus 4.7 (cache prices derived from ratios)
|
|
50
54
|
"opus-4-6": (5.0, 6.25, 0.50, 25.0), # Opus 4.6 (legacy; pricing matches 4.7 per Anthropic)
|
|
51
55
|
"sonnet-4-6": (3.0, 3.75, 0.30, 15.0), # Sonnet 4.6 (cache prices derived from ratios)
|