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
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
# P6 Token usage collector 증분화 구현 plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** `okstra-token-usage` collector 가 run 마다 세션 jsonl 전체를 재스캔하지 않고, 세션별 byte offset cursor + usage 이벤트 추출본 캐시로 새로 추가된 byte 만 읽게 한다 ([performance-improvement-plan-v2.md §P6](../../kr/performance-improvement-plan-v2.md)).
|
|
6
|
+
|
|
7
|
+
**Architecture:** 캐시에는 **윈도우 적용 전** 압축 이벤트 추출본을 저장하고, `since`/`until` 윈도우 필터는 매 호출 시 이벤트 위에서 재평가한다. run 재시도로 윈도우가 좁아져도(`until` 이 `utc_now()` → 과거의 `runEndedAt` 으로 이동) 합계가 틀어지지 않는다. 캐시는 파생 데이터다 — 식별자 불일치·truncate·손상 시 조용히 폐기하고 전체 재스캔으로 폴백한다(fail-open). 스캔 구현은 증분/전체 공용 한 벌만 둔다(전체 스캔 = 빈 커서로 증분 스캔, DRY).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Python 3 표준 라이브러리만 (json, hashlib, os, pathlib). 신규 외부 의존성 없음.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 배경과 정확성 제약
|
|
14
|
+
|
|
15
|
+
- 현재 [collect.py:185](../../../scripts/okstra_token_usage/collect.py) 는 run 마다 모든 매칭 세션에 대해 [claude.py 의 `claude_session_totals`](../../../scripts/okstra_token_usage/claude.py) 를 호출해 jsonl 전체를 선형 재파싱한다.
|
|
16
|
+
- [claude.py 의 `find_claude_team_sessions`](../../../scripts/okstra_token_usage/claude.py) 는 프로젝트 transcript 디렉토리의 **모든** `*.jsonl` 을 team needle 로 전체 스캔한다 (needle 미발견 파일은 매번 EOF 까지).
|
|
17
|
+
- run 윈도우의 `until` 은 진행 중엔 `utc_now()`, 종료 후엔 `runEndedAt`/status mtime 으로 해소되므로 **호출 간 뒤로 이동할 수 있다** ([collect.py `_resolve_run_window`](../../../scripts/okstra_token_usage/collect.py)). 윈도우 적용 후 누계를 캐시하면 이 케이스에서 과대 집계가 된다 → 윈도우 적용 전 이벤트를 캐시해야 한다.
|
|
18
|
+
- 여러 subagent 세션 aggregate(`_aggregate_totals`)·재dispatch suffix 매칭은 세션별 totals 위에서 동작하므로, 세션별 totals 가 동일하면 aggregate 도 동일하다.
|
|
19
|
+
- Phase 7 final-report placeholder substitution([validators/validate-run.py](../../../validators/validate-run.py) → `collect()`)이 이 결과를 소비한다 — 정확성이 성능보다 우선.
|
|
20
|
+
|
|
21
|
+
## 캐시 저장소 설계
|
|
22
|
+
|
|
23
|
+
- 위치: `$OKSTRA_HOME/cache/token-usage/<transcript-dir-name>/<session-id>.json` (`OKSTRA_HOME` 미설정 시 `~/.okstra`). 세션 파일은 머신 전역(`~/.claude/projects/...`)이므로 캐시도 프로젝트 `.okstra/` 가 아닌 okstra home 에 둔다. 테스트는 기존 [tests/conftest.py](../../../tests/conftest.py) 의 `OKSTRA_HOME` 격리를 그대로 탄다.
|
|
24
|
+
- 파일 스키마 (`schemaVersion: 1`):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"schemaVersion": 1,
|
|
29
|
+
"identity": {"prefixLen": 256, "sha256": "<head-bytes hash>"},
|
|
30
|
+
"usage": {
|
|
31
|
+
"offset": 12345,
|
|
32
|
+
"agentName": "codex-worker",
|
|
33
|
+
"model": "claude-sonnet-4-6",
|
|
34
|
+
"events": [{"t": "2026-06-10T01:00:00.123Z", "i": 100, "o": 20, "c": 5, "c5": 5, "r": 9000, "u": 2}]
|
|
35
|
+
},
|
|
36
|
+
"needles": {"\"teamname\":\"okstra-…\"": {"offset": 12345, "found": false}}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- 이벤트 키: `t`=timestamp, `i`/`o`=input/output, `c`=cache_creation 합, `c5`/`c1`=ephemeral 5m/1h, `r`=cache_read, `u`=tool_use 수. 0/부재 필드는 생략. ts 만 있는 레코드는 `{"t": …}` 로 저장(윈도우별 first/last ts 산출에 필요), 아무 기여도 없는 레코드는 저장하지 않는다.
|
|
41
|
+
- 무효화 가드: (a) head 256 byte sha256 식별자 불일치 → 폐기, (b) `offset > 현재 파일 크기`(truncate) → usage 상태 리셋, (c) `schemaVersion` 불일치·JSON 손상 → 폐기. 모두 전체 재스캔 폴백.
|
|
42
|
+
- 미완결 tail(개행 없는 마지막 라인)은 이번 호출 totals 에는 반영하되 커서를 전진시키지 않는다(transient) — 다음 호출에서 완결본으로 다시 읽으므로 이중 집계도 누락도 없다.
|
|
43
|
+
- 쓰기는 tmp + `os.replace` 원자적, 실패는 무시(파생 데이터). 동시 collect 두 개가 같은 캐시에 쓰면 last-writer-wins — 최악의 경우 다음 호출이 일부 byte 를 다시 읽을 뿐 결과는 불변.
|
|
44
|
+
- needle map 은 파일당 최대 16개(오래된 순 제거) — run 마다 team 이름이 달라 무한 증식하는 것 방지.
|
|
45
|
+
|
|
46
|
+
## Non-goals (YAGNI)
|
|
47
|
+
|
|
48
|
+
- codex/gemini 세션 증분화: 파일이 작고 윈도우 글롭으로 1개만 선택되므로 제외.
|
|
49
|
+
- 캐시 GC: 세션 jsonl 이 삭제돼도 캐시 파일이 남지만 수 KB 수준 — 후속 과제로 미룬다.
|
|
50
|
+
- `iter_jsonl` 의 다른 호출자(codex.py) 변경 없음.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### Task 0: 변경 전 기준값 스냅샷 (실제 jsonl 고정 fixture)
|
|
55
|
+
|
|
56
|
+
DB/IO 변경 검증 규칙: mock 이 아닌 **실제 세션 jsonl** 로 전후 동일성을 증명한다. 현재 세션이 계속 append 되므로 파일을 먼저 /tmp 에 복사해 고정한다.
|
|
57
|
+
|
|
58
|
+
**Files:** 코드 변경 없음. 산출물: `/tmp/p6-fixtures/*.jsonl`, `/tmp/p6-legacy-snapshot.json`, `/tmp/p6-snapshot.py`
|
|
59
|
+
|
|
60
|
+
- [x] **Step 1: 실제 jsonl 3개를 고정 fixture 로 복사**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
mkdir -p /tmp/p6-fixtures
|
|
64
|
+
ls -S "$HOME/.claude/projects/-Volumes-Workspaces-workspace-projects-Okstra/"*.jsonl | head -3 | while read f; do cp "$f" /tmp/p6-fixtures/; done
|
|
65
|
+
ls -lh /tmp/p6-fixtures/
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- [x] **Step 2: 변경 전 코드로 기준값 저장 (전체 + 윈도우 변형 3종)**
|
|
69
|
+
|
|
70
|
+
`/tmp/p6-snapshot.py` 로 저장 후 실행 (변경 후 검증에서 같은 스크립트를 재사용한다):
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import json, sys
|
|
74
|
+
from datetime import datetime, timedelta
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
sys.path.insert(0, "scripts")
|
|
77
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
78
|
+
|
|
79
|
+
def windows(full):
|
|
80
|
+
s, e = full["startedAt"], full["endedAt"]
|
|
81
|
+
a = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
82
|
+
b = datetime.fromisoformat(e.replace("Z", "+00:00"))
|
|
83
|
+
mid = (a + (b - a) / 2).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
84
|
+
return {"first_half": {"until": mid}, "second_half": {"since": mid},
|
|
85
|
+
"mid_only": {"since": (a + (b - a) / 4).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
86
|
+
"until": (a + 3 * (b - a) / 4).strftime("%Y-%m-%dT%H:%M:%SZ")}}
|
|
87
|
+
|
|
88
|
+
kwargs = {}
|
|
89
|
+
if len(sys.argv) > 1 and sys.argv[1] == "--incremental":
|
|
90
|
+
kwargs["incremental"] = True
|
|
91
|
+
out = {}
|
|
92
|
+
for f in sorted(Path("/tmp/p6-fixtures").glob("*.jsonl")):
|
|
93
|
+
full = claude_session_totals(f, **kwargs)
|
|
94
|
+
out[f.name] = {"full": full}
|
|
95
|
+
for name, w in windows(full).items():
|
|
96
|
+
out[f.name][name] = claude_session_totals(f, **w, **kwargs)
|
|
97
|
+
json.dump(out, sys.stdout, indent=1, sort_keys=True, ensure_ascii=False)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd /Volumes/Workspaces/workspace/projects/Okstra
|
|
102
|
+
python3 /tmp/p6-snapshot.py > /tmp/p6-legacy-snapshot.json
|
|
103
|
+
python3 -c "import json; d=json.load(open('/tmp/p6-legacy-snapshot.json')); print(len(d), 'files,', sum(v['full']['totalTokens'] for v in d.values()), 'total tokens')"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Expected: 파일 3개, totalTokens 합이 0 이 아닌 값.
|
|
107
|
+
|
|
108
|
+
### Task 1: `okstra_home()` 단일 참조점 추출
|
|
109
|
+
|
|
110
|
+
`OKSTRA_HOME` 해석이 [seeding.py `_okstra_home`](../../../scripts/okstra_ctl/seeding.py) 과 [run_context.py `_okstra_home`](../../../scripts/okstra_ctl/run_context.py) 에 중복. 새 캐시 모듈이 세 번째 호출자가 되므로 `okstra_project/dirs.py` 로 추출하고 기존 두 곳을 경유시킨다 (`okstra_ctl` → `okstra_project` import 는 기존 선례 다수).
|
|
111
|
+
|
|
112
|
+
**Files:**
|
|
113
|
+
- Modify: `scripts/okstra_project/dirs.py`
|
|
114
|
+
- Modify: `scripts/okstra_ctl/seeding.py` (`_okstra_home` 제거)
|
|
115
|
+
- Modify: `scripts/okstra_ctl/run_context.py` (`_okstra_home` 제거)
|
|
116
|
+
- Test: `tests/test_okstra_token_usage_incremental.py` (신규 — 이 plan 의 모든 신규 테스트가 들어갈 파일)
|
|
117
|
+
|
|
118
|
+
- [x] **Step 1: 실패하는 테스트 작성**
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
"""P6: token usage collector 증분화 — byte cursor 캐시의 정확성 가드."""
|
|
122
|
+
from __future__ import annotations
|
|
123
|
+
|
|
124
|
+
import json
|
|
125
|
+
import sys
|
|
126
|
+
from pathlib import Path
|
|
127
|
+
|
|
128
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
129
|
+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_okstra_home_honors_env_isolation(monkeypatch, tmp_path):
|
|
133
|
+
"""OKSTRA_HOME 단일 참조점: env override > ~/.okstra 기본값."""
|
|
134
|
+
from okstra_project.dirs import okstra_home
|
|
135
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "custom"))
|
|
136
|
+
assert okstra_home() == tmp_path / "custom"
|
|
137
|
+
monkeypatch.setenv("OKSTRA_HOME", " ")
|
|
138
|
+
assert okstra_home() == Path.home() / ".okstra"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- [x] **Step 2: 실패 확인**
|
|
142
|
+
|
|
143
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py -v`
|
|
144
|
+
Expected: FAIL — `ImportError: cannot import name 'okstra_home'`
|
|
145
|
+
|
|
146
|
+
- [x] **Step 3: `dirs.py` 에 구현**
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import os # 파일 상단 from __future__ 다음
|
|
150
|
+
|
|
151
|
+
def okstra_home() -> Path:
|
|
152
|
+
"""`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override."""
|
|
153
|
+
override = os.environ.get("OKSTRA_HOME", "").strip()
|
|
154
|
+
if override:
|
|
155
|
+
return Path(override)
|
|
156
|
+
return Path.home() / ".okstra"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- [x] **Step 4: seeding.py / run_context.py 의 로컬 `_okstra_home` 정의 삭제, `from okstra_project.dirs import okstra_home` 으로 대체하고 호출부 이름 변경**
|
|
160
|
+
|
|
161
|
+
`grep -n "_okstra_home" scripts/okstra_ctl/*.py` 로 호출부 전수 확인 후 일괄 치환. docstring 의 `_okstra_home()` 언급도 갱신.
|
|
162
|
+
|
|
163
|
+
- [x] **Step 5: 테스트 + 기존 suite 통과 확인 후 commit**
|
|
164
|
+
|
|
165
|
+
Run: `python3 -m pytest tests/ -q`
|
|
166
|
+
Expected: PASS (기존 테스트 회귀 없음)
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
git add scripts/okstra_project/dirs.py scripts/okstra_ctl/seeding.py scripts/okstra_ctl/run_context.py tests/test_okstra_token_usage_incremental.py
|
|
170
|
+
git commit -m "refactor(project-dirs): okstra_home() 단일 참조점 추출"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Task 2: `cursor.py` — 세션별 캐시 load/save + 식별자 가드
|
|
174
|
+
|
|
175
|
+
**Files:**
|
|
176
|
+
- Create: `scripts/okstra_token_usage/cursor.py`
|
|
177
|
+
- Test: `tests/test_okstra_token_usage_incremental.py`
|
|
178
|
+
|
|
179
|
+
- [x] **Step 1: 실패하는 테스트 작성**
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
def _write(p: Path, text: str) -> None:
|
|
183
|
+
p.write_bytes(text.encode("utf-8"))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_cursor_cache_roundtrip_and_identity_guard(monkeypatch, tmp_path):
|
|
187
|
+
from okstra_token_usage import cursor
|
|
188
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
189
|
+
jsonl = tmp_path / "proj" / "sess.jsonl"
|
|
190
|
+
jsonl.parent.mkdir(parents=True)
|
|
191
|
+
_write(jsonl, '{"a":1}\n')
|
|
192
|
+
|
|
193
|
+
cache = cursor.load_cache(jsonl) # 미스 → 빈 캐시
|
|
194
|
+
assert cache["usage"]["offset"] == 0
|
|
195
|
+
cache["usage"]["offset"] = 8
|
|
196
|
+
cursor.save_cache(jsonl, cache)
|
|
197
|
+
|
|
198
|
+
again = cursor.load_cache(jsonl) # 히트
|
|
199
|
+
assert again["usage"]["offset"] == 8
|
|
200
|
+
|
|
201
|
+
_write(jsonl, '{"b":2}\n') # 같은 길이, 다른 내용 → 식별자 불일치
|
|
202
|
+
assert cursor.load_cache(jsonl)["usage"]["offset"] == 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_cursor_cache_corrupt_or_wrong_version_falls_back(monkeypatch, tmp_path):
|
|
206
|
+
from okstra_token_usage import cursor
|
|
207
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
208
|
+
jsonl = tmp_path / "proj" / "sess.jsonl"
|
|
209
|
+
jsonl.parent.mkdir(parents=True)
|
|
210
|
+
_write(jsonl, '{"a":1}\n')
|
|
211
|
+
|
|
212
|
+
cp = cursor.cache_path_for(jsonl)
|
|
213
|
+
cp.parent.mkdir(parents=True)
|
|
214
|
+
cp.write_text("not-json{")
|
|
215
|
+
assert cursor.load_cache(jsonl)["usage"]["offset"] == 0
|
|
216
|
+
|
|
217
|
+
cp.write_text(json.dumps({"schemaVersion": 999}))
|
|
218
|
+
assert cursor.load_cache(jsonl)["usage"]["offset"] == 0
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- [x] **Step 2: 실패 확인**
|
|
222
|
+
|
|
223
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py -v -k cursor`
|
|
224
|
+
Expected: FAIL — `ModuleNotFoundError: okstra_token_usage.cursor`
|
|
225
|
+
|
|
226
|
+
- [x] **Step 3: `cursor.py` 구현**
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
"""세션 jsonl 증분 스캔 캐시 — byte cursor + usage 이벤트 추출본 (P6).
|
|
230
|
+
|
|
231
|
+
캐시에는 *윈도우 적용 전* 이벤트 추출본을 저장하고, since/until 윈도우는 매
|
|
232
|
+
호출 시 이벤트 위에서 재평가한다. run 재시도로 윈도우가 좁아져도(until 이
|
|
233
|
+
과거로 이동) 합계가 틀어지지 않는 이유다.
|
|
234
|
+
|
|
235
|
+
캐시는 파생 데이터다: head-bytes 식별자 불일치(파일 교체)·truncate·손상 시
|
|
236
|
+
조용히 폐기하고 전체 재스캔으로 폴백한다(fail-open). 쓰기는 tmp+os.replace
|
|
237
|
+
원자적. 동시 collect 가 같은 캐시를 쓰면 last-writer-wins — 최악의 경우 다음
|
|
238
|
+
호출이 일부 byte 를 다시 읽을 뿐 결과는 불변.
|
|
239
|
+
"""
|
|
240
|
+
from __future__ import annotations
|
|
241
|
+
|
|
242
|
+
import hashlib
|
|
243
|
+
import json
|
|
244
|
+
import os
|
|
245
|
+
from pathlib import Path
|
|
246
|
+
|
|
247
|
+
from okstra_project.dirs import okstra_home
|
|
248
|
+
|
|
249
|
+
CACHE_SCHEMA_VERSION = 1
|
|
250
|
+
IDENTITY_PREFIX_BYTES = 256
|
|
251
|
+
MAX_NEEDLES = 16
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cache_path_for(jsonl_path: Path) -> Path:
|
|
255
|
+
"""`$OKSTRA_HOME/cache/token-usage/<transcript-dir-name>/<session>.json`."""
|
|
256
|
+
return (okstra_home() / "cache" / "token-usage"
|
|
257
|
+
/ jsonl_path.parent.name / f"{jsonl_path.stem}.json")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def fresh_cache(identity: dict | None = None) -> dict:
|
|
261
|
+
return {
|
|
262
|
+
"schemaVersion": CACHE_SCHEMA_VERSION,
|
|
263
|
+
"identity": identity,
|
|
264
|
+
"usage": {"offset": 0, "agentName": None, "model": None, "events": []},
|
|
265
|
+
"needles": {},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _file_identity(jsonl_path: Path) -> dict | None:
|
|
270
|
+
try:
|
|
271
|
+
with jsonl_path.open("rb") as fh:
|
|
272
|
+
prefix = fh.read(IDENTITY_PREFIX_BYTES)
|
|
273
|
+
except OSError:
|
|
274
|
+
return None
|
|
275
|
+
return {"prefixLen": len(prefix), "sha256": hashlib.sha256(prefix).hexdigest()}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _identity_matches(jsonl_path: Path, identity) -> bool:
|
|
279
|
+
if not isinstance(identity, dict):
|
|
280
|
+
return False
|
|
281
|
+
want_len = identity.get("prefixLen") or 0
|
|
282
|
+
try:
|
|
283
|
+
with jsonl_path.open("rb") as fh:
|
|
284
|
+
prefix = fh.read(want_len)
|
|
285
|
+
except OSError:
|
|
286
|
+
return False
|
|
287
|
+
if len(prefix) != want_len:
|
|
288
|
+
return False # 캐시 시점보다 짧아짐 → truncate/교체
|
|
289
|
+
return hashlib.sha256(prefix).hexdigest() == identity.get("sha256")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def load_cache(jsonl_path: Path) -> dict:
|
|
293
|
+
"""파일에 대응하는 캐시. 미스·손상·버전/식별자 불일치면 빈 캐시.
|
|
294
|
+
|
|
295
|
+
identity 는 이번 스캔 시점 기준으로 갱신해 둔다 — 첫 256B 미만이던 파일이
|
|
296
|
+
자란 경우 prefix 를 늘려 잡기 위함(append-only 라 기존 prefix 는 불변).
|
|
297
|
+
"""
|
|
298
|
+
identity = _file_identity(jsonl_path)
|
|
299
|
+
p = cache_path_for(jsonl_path)
|
|
300
|
+
try:
|
|
301
|
+
cache = json.loads(p.read_text())
|
|
302
|
+
except (OSError, json.JSONDecodeError):
|
|
303
|
+
return fresh_cache(identity)
|
|
304
|
+
if not isinstance(cache, dict) or cache.get("schemaVersion") != CACHE_SCHEMA_VERSION:
|
|
305
|
+
return fresh_cache(identity)
|
|
306
|
+
if not _identity_matches(jsonl_path, cache.get("identity")):
|
|
307
|
+
return fresh_cache(identity)
|
|
308
|
+
cache["identity"] = identity
|
|
309
|
+
return cache
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def save_cache(jsonl_path: Path, cache: dict) -> None:
|
|
313
|
+
"""원자적 저장. 실패는 무시 — 캐시는 파생 데이터, 결과에 영향 없음."""
|
|
314
|
+
p = cache_path_for(jsonl_path)
|
|
315
|
+
try:
|
|
316
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
tmp = p.with_suffix(".json.tmp")
|
|
318
|
+
tmp.write_text(json.dumps(cache, ensure_ascii=False, separators=(",", ":")))
|
|
319
|
+
os.replace(tmp, p)
|
|
320
|
+
except OSError:
|
|
321
|
+
pass
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- [x] **Step 4: 통과 확인 후 commit**
|
|
325
|
+
|
|
326
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py -v -k cursor`
|
|
327
|
+
Expected: PASS
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
git add scripts/okstra_token_usage/cursor.py tests/test_okstra_token_usage_incremental.py
|
|
331
|
+
git commit -m "feat(token-usage): 세션 캐시 cursor 모듈 — byte offset + 식별자 가드"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Task 3: `claude_session_totals` 증분화 — 스캔 구현 단일화
|
|
335
|
+
|
|
336
|
+
기존 누적 루프를 (a) 레코드→이벤트 추출, (b) byte cursor 전진 스캔, (c) 이벤트→윈도우 totals 세 함수로 분해한다. 전체 스캔은 "빈 커서로 증분 스캔" — 구현은 한 벌(DRY). 미완결 tail 라인은 transient 처리(집계엔 반영, 커서는 미전진).
|
|
337
|
+
|
|
338
|
+
**Files:**
|
|
339
|
+
- Modify: `scripts/okstra_token_usage/claude.py`
|
|
340
|
+
- Test: `tests/test_okstra_token_usage_incremental.py`
|
|
341
|
+
|
|
342
|
+
- [x] **Step 1: 실패하는 테스트 작성** (parity / 증분 / 윈도우 축소 / tail / truncate)
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
REC_TPL = ('{{"type":"assistant","timestamp":"{ts}","message":{{"model":"claude-opus-4-7",'
|
|
346
|
+
'"usage":{{"input_tokens":{i},"output_tokens":{o},'
|
|
347
|
+
'"cache_creation_input_tokens":{c},"cache_read_input_tokens":{r}}},'
|
|
348
|
+
'"content":[{{"type":"tool_use"}},{{"type":"text"}}]}}}}')
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _rec(ts, i=10, o=1, c=0, r=0):
|
|
352
|
+
return REC_TPL.format(ts=ts, i=i, o=o, c=c, r=r)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_incremental_cold_equals_legacy_full_scan(monkeypatch, tmp_path):
|
|
356
|
+
"""캐시 없는 첫 호출(incremental=True) == 비증분 전체 스캔. ts 없는 레코드,
|
|
357
|
+
cache_creation 5m/1h 분해, ts-only 레코드, 미완결 tail 까지 포함."""
|
|
358
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
359
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
360
|
+
p = tmp_path / "s.jsonl"
|
|
361
|
+
lines = [
|
|
362
|
+
'{"agentName":"codex-worker","type":"system","timestamp":"2026-06-10T01:00:00Z"}',
|
|
363
|
+
_rec("2026-06-10T01:01:00Z", i=100, o=10, c=50, r=9000),
|
|
364
|
+
'{"type":"assistant","message":{"usage":{"input_tokens":7,"output_tokens":3,'
|
|
365
|
+
'"cache_creation_input_tokens":40,"cache_read_input_tokens":0,'
|
|
366
|
+
'"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":10}}}}',
|
|
367
|
+
'{"type":"user","timestamp":"2026-06-10T02:00:00Z"}',
|
|
368
|
+
]
|
|
369
|
+
p.write_bytes(("\n".join(lines) + "\n" + _rec("2026-06-10T03:00:00Z", i=5, o=5)).encode()) # tail 개행 없음
|
|
370
|
+
|
|
371
|
+
legacy = claude_session_totals(p)
|
|
372
|
+
cold = claude_session_totals(p, incremental=True)
|
|
373
|
+
assert cold == legacy
|
|
374
|
+
assert cold["inputTokens"] == 112 and cold["outputTokens"] == 18
|
|
375
|
+
assert cold["cacheCreationTokens"] == 90
|
|
376
|
+
assert cold["cacheCreation5mTokens"] == 80 and cold["cacheCreation1hTokens"] == 10
|
|
377
|
+
assert cold["cacheReadTokens"] == 9000
|
|
378
|
+
assert cold["toolUses"] == 2 # tool_use 블록 2개 (본문 1 + tail 1)
|
|
379
|
+
assert cold["agentName"] == "codex-worker"
|
|
380
|
+
assert cold["startedAt"] == "2026-06-10T01:00:00Z"
|
|
381
|
+
assert cold["endedAt"] == "2026-06-10T03:00:00Z"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_incremental_warm_reads_only_new_bytes(monkeypatch, tmp_path):
|
|
385
|
+
"""warm 호출이 캐시 구간을 재읽지 않음을 증명: 첫 스캔 후 이미 읽은 구간을
|
|
386
|
+
같은 길이의 쓰레기로 바꿔도(식별자 prefix 밖) 결과가 캐시에서 나온다."""
|
|
387
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
388
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
389
|
+
p = tmp_path / "s.jsonl"
|
|
390
|
+
head = '{"f":"' + "x" * 300 + '"}\n' # 식별자 prefix(256B) 채움
|
|
391
|
+
body = _rec("2026-06-10T01:00:00Z", i=100, o=10) + "\n"
|
|
392
|
+
p.write_bytes((head + body).encode())
|
|
393
|
+
first = claude_session_totals(p, incremental=True)
|
|
394
|
+
assert first["inputTokens"] == 100
|
|
395
|
+
|
|
396
|
+
garbage = b"#" * len(body.encode()) # body 구간만 파괴 (prefix 는 보존)
|
|
397
|
+
with p.open("r+b") as fh:
|
|
398
|
+
fh.seek(len(head.encode()))
|
|
399
|
+
fh.write(garbage)
|
|
400
|
+
with p.open("ab") as fh:
|
|
401
|
+
fh.write((_rec("2026-06-10T02:00:00Z", i=7, o=3) + "\n").encode())
|
|
402
|
+
|
|
403
|
+
warm = claude_session_totals(p, incremental=True)
|
|
404
|
+
assert warm["inputTokens"] == 107 # 캐시 100 + 신규 7 — body 재읽기 없음
|
|
405
|
+
fresh = claude_session_totals(p) # 비증분은 파괴된 body 를 못 읽음
|
|
406
|
+
assert fresh["inputTokens"] == 7
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_incremental_window_shrink_matches_fresh_scan(monkeypatch, tmp_path):
|
|
410
|
+
"""P6 핵심 정확성: 진행 중(until=now) 집계 후 run 종료로 until 이 과거로
|
|
411
|
+
이동해도, warm 결과 == 캐시 없는 전체 스캔 결과."""
|
|
412
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
413
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
414
|
+
p = tmp_path / "s.jsonl"
|
|
415
|
+
p.write_bytes(("\n".join([
|
|
416
|
+
_rec("2026-06-10T01:00:00Z", i=100, o=10),
|
|
417
|
+
_rec("2026-06-10T02:00:00Z", i=200, o=20),
|
|
418
|
+
_rec("2026-06-10T03:00:00Z", i=400, o=40),
|
|
419
|
+
]) + "\n").encode())
|
|
420
|
+
|
|
421
|
+
wide = claude_session_totals(p, since="2026-06-10T00:00:00Z",
|
|
422
|
+
until="2026-06-10T04:00:00Z", incremental=True)
|
|
423
|
+
assert wide["inputTokens"] == 700
|
|
424
|
+
narrow = claude_session_totals(p, since="2026-06-10T00:00:00Z",
|
|
425
|
+
until="2026-06-10T02:30:00Z", incremental=True)
|
|
426
|
+
fresh = claude_session_totals(p, since="2026-06-10T00:00:00Z",
|
|
427
|
+
until="2026-06-10T02:30:00Z")
|
|
428
|
+
assert narrow == fresh
|
|
429
|
+
assert narrow["inputTokens"] == 300 # 03:00 레코드 제외
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_incremental_tail_not_double_counted(monkeypatch, tmp_path):
|
|
433
|
+
"""미완결 tail 은 transient: 이번 호출 집계에는 들어가되 커서는 미전진.
|
|
434
|
+
이후 완결되면 그때 1회만 커밋된다."""
|
|
435
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
436
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
437
|
+
p = tmp_path / "s.jsonl"
|
|
438
|
+
p.write_bytes((_rec("2026-06-10T01:00:00Z", i=10, o=1) + "\n"
|
|
439
|
+
+ _rec("2026-06-10T02:00:00Z", i=5, o=2)).encode()) # 개행 없음
|
|
440
|
+
assert claude_session_totals(p, incremental=True)["inputTokens"] == 15
|
|
441
|
+
with p.open("ab") as fh:
|
|
442
|
+
fh.write(("\n" + _rec("2026-06-10T03:00:00Z", i=100, o=9) + "\n").encode())
|
|
443
|
+
out = claude_session_totals(p, incremental=True)
|
|
444
|
+
assert out["inputTokens"] == 115 # 5 가 두 번 세어지면 120
|
|
445
|
+
assert out == claude_session_totals(p)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_incremental_truncate_triggers_full_rescan(monkeypatch, tmp_path):
|
|
449
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
450
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
451
|
+
p = tmp_path / "s.jsonl"
|
|
452
|
+
p.write_bytes(("\n".join(_rec(f"2026-06-10T0{n}:00:00Z", i=10 * n, o=n)
|
|
453
|
+
for n in (1, 2, 3)) + "\n").encode())
|
|
454
|
+
assert claude_session_totals(p, incremental=True)["inputTokens"] == 60
|
|
455
|
+
# 교체: 내용이 전혀 다른 더 짧은 파일
|
|
456
|
+
p.write_bytes((_rec("2026-06-10T09:00:00Z", i=8, o=1) + "\n").encode())
|
|
457
|
+
assert claude_session_totals(p, incremental=True)["inputTokens"] == 8
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
- [x] **Step 2: 실패 확인**
|
|
461
|
+
|
|
462
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py -v -k incremental`
|
|
463
|
+
Expected: FAIL — `claude_session_totals() got an unexpected keyword argument 'incremental'`
|
|
464
|
+
|
|
465
|
+
- [x] **Step 3: `claude.py` 재구성**
|
|
466
|
+
|
|
467
|
+
기존 `claude_session_totals` 본문을 다음 세 함수 + 진입점으로 교체한다 (docstring 의 윈도우/보수성 설명은 유지·이전):
|
|
468
|
+
|
|
469
|
+
```python
|
|
470
|
+
"""Claude Code transcript collectors."""
|
|
471
|
+
from __future__ import annotations
|
|
472
|
+
|
|
473
|
+
import json
|
|
474
|
+
from datetime import datetime
|
|
475
|
+
from pathlib import Path
|
|
476
|
+
|
|
477
|
+
from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
|
|
478
|
+
from .paths import claude_project_dir
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _event_from_record(rec: dict) -> dict | None:
|
|
482
|
+
"""jsonl 레코드 1개 → 압축 이벤트. 집계에 기여하지 않으면 None.
|
|
483
|
+
|
|
484
|
+
키: t=timestamp, i/o=input/output, c=cache_creation 합, c5/c1=ephemeral
|
|
485
|
+
5m/1h, r=cache_read, u=tool_use 수. 0/부재 필드는 생략(캐시 크기 절약).
|
|
486
|
+
ts-only 레코드도 보존한다 — 임의 윈도우의 first/last ts 산출에 필요.
|
|
487
|
+
"""
|
|
488
|
+
msg = rec.get("message")
|
|
489
|
+
if not isinstance(msg, dict):
|
|
490
|
+
msg = {}
|
|
491
|
+
ev: dict = {}
|
|
492
|
+
usage = msg.get("usage")
|
|
493
|
+
if usage:
|
|
494
|
+
for src, key in (("input_tokens", "i"), ("output_tokens", "o"),
|
|
495
|
+
("cache_read_input_tokens", "r")):
|
|
496
|
+
v = usage.get(src, 0) or 0
|
|
497
|
+
if v:
|
|
498
|
+
ev[key] = v
|
|
499
|
+
cc_total = usage.get("cache_creation_input_tokens", 0) or 0
|
|
500
|
+
if cc_total:
|
|
501
|
+
ev["c"] = cc_total
|
|
502
|
+
cc_break = usage.get("cache_creation") or {}
|
|
503
|
+
if isinstance(cc_break, dict) and (
|
|
504
|
+
cc_break.get("ephemeral_5m_input_tokens") is not None
|
|
505
|
+
or cc_break.get("ephemeral_1h_input_tokens") is not None):
|
|
506
|
+
v5 = cc_break.get("ephemeral_5m_input_tokens", 0) or 0
|
|
507
|
+
v1 = cc_break.get("ephemeral_1h_input_tokens", 0) or 0
|
|
508
|
+
if v5:
|
|
509
|
+
ev["c5"] = v5
|
|
510
|
+
if v1:
|
|
511
|
+
ev["c1"] = v1
|
|
512
|
+
elif cc_total:
|
|
513
|
+
# API 분해가 없으면 전부 5m 티어로(1.25x — 더 싼 가정, 기존 동작).
|
|
514
|
+
ev["c5"] = cc_total
|
|
515
|
+
if rec.get("type") == "assistant":
|
|
516
|
+
tools = sum(1 for b in (msg.get("content") or [])
|
|
517
|
+
if isinstance(b, dict) and b.get("type") == "tool_use")
|
|
518
|
+
if tools:
|
|
519
|
+
ev["u"] = tools
|
|
520
|
+
ts = rec.get("timestamp") or msg.get("timestamp")
|
|
521
|
+
if ts:
|
|
522
|
+
ev["t"] = ts
|
|
523
|
+
return ev or None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _advance_usage_scan(jsonl_path: Path, usage_state: dict) -> dict:
|
|
527
|
+
"""`usage_state['offset']` 이후의 완결 라인을 읽어 이벤트를 커밋하고,
|
|
528
|
+
개행 없는 마지막 라인은 transient 로만 반영한 view 를 돌려준다.
|
|
529
|
+
|
|
530
|
+
transient tail: 아직 쓰는 중일 수 있는 라인 — 이번 집계에는 포함하되
|
|
531
|
+
커서를 전진시키지 않아, 다음 호출이 완결본으로 다시 읽는다(이중 집계도
|
|
532
|
+
누락도 없음). 깨진 utf-8 / JSON / 비-dict 라인은 건너뛰되 커서는 전진
|
|
533
|
+
(구버전은 text-mode 디코드 실패 시 collect 전체가 죽었다 — fail-open 개선).
|
|
534
|
+
"""
|
|
535
|
+
events = list(usage_state.get("events") or [])
|
|
536
|
+
agent_name = usage_state.get("agentName")
|
|
537
|
+
model = usage_state.get("model")
|
|
538
|
+
offset = usage_state.get("offset", 0) or 0
|
|
539
|
+
try:
|
|
540
|
+
size = jsonl_path.stat().st_size
|
|
541
|
+
except OSError:
|
|
542
|
+
size = 0
|
|
543
|
+
if offset > size:
|
|
544
|
+
# 식별자 가드를 통과했더라도 truncate 방어 — 처음부터 재스캔.
|
|
545
|
+
events, agent_name, model, offset = [], None, None, 0
|
|
546
|
+
tail_events: list[dict] = []
|
|
547
|
+
tail_agent = tail_model = None
|
|
548
|
+
try:
|
|
549
|
+
with jsonl_path.open("rb") as fh:
|
|
550
|
+
fh.seek(offset)
|
|
551
|
+
while True:
|
|
552
|
+
raw = fh.readline()
|
|
553
|
+
if not raw:
|
|
554
|
+
break
|
|
555
|
+
complete = raw.endswith(b"\n")
|
|
556
|
+
rec = None
|
|
557
|
+
stripped = raw.strip()
|
|
558
|
+
if stripped:
|
|
559
|
+
try:
|
|
560
|
+
rec = json.loads(stripped.decode("utf-8"))
|
|
561
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
562
|
+
rec = None
|
|
563
|
+
if not isinstance(rec, dict):
|
|
564
|
+
rec = None
|
|
565
|
+
ev = _event_from_record(rec) if rec else None
|
|
566
|
+
if complete:
|
|
567
|
+
offset = fh.tell()
|
|
568
|
+
if rec:
|
|
569
|
+
if agent_name is None and rec.get("agentName"):
|
|
570
|
+
agent_name = rec["agentName"]
|
|
571
|
+
if (model is None and rec.get("type") == "assistant"
|
|
572
|
+
and isinstance(rec.get("message"), dict)
|
|
573
|
+
and rec["message"].get("model")):
|
|
574
|
+
model = rec["message"]["model"]
|
|
575
|
+
if ev:
|
|
576
|
+
events.append(ev)
|
|
577
|
+
else:
|
|
578
|
+
if rec:
|
|
579
|
+
if rec.get("agentName"):
|
|
580
|
+
tail_agent = rec["agentName"]
|
|
581
|
+
if (rec.get("type") == "assistant"
|
|
582
|
+
and isinstance(rec.get("message"), dict)
|
|
583
|
+
and rec["message"].get("model")):
|
|
584
|
+
tail_model = rec["message"]["model"]
|
|
585
|
+
if ev:
|
|
586
|
+
tail_events.append(ev)
|
|
587
|
+
break
|
|
588
|
+
except OSError:
|
|
589
|
+
pass
|
|
590
|
+
usage_state.update(offset=offset, events=events,
|
|
591
|
+
agentName=agent_name, model=model)
|
|
592
|
+
return {"events": events + tail_events,
|
|
593
|
+
"agentName": agent_name if agent_name is not None else tail_agent,
|
|
594
|
+
"model": model if model is not None else tail_model}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _totals_from_events(events: list[dict], agent_name, model,
|
|
598
|
+
since: str | None, until: str | None) -> dict:
|
|
599
|
+
input_t = output_t = cache_create_t = cache_read_t = 0
|
|
600
|
+
cache_create_5m_t = cache_create_1h_t = 0
|
|
601
|
+
tool_uses = 0
|
|
602
|
+
first_ts: str | None = None
|
|
603
|
+
last_ts: str | None = None
|
|
604
|
+
for ev in events:
|
|
605
|
+
ts = ev.get("t")
|
|
606
|
+
if ts and ((since and ts < since) or (until and ts > until)):
|
|
607
|
+
continue
|
|
608
|
+
input_t += ev.get("i", 0)
|
|
609
|
+
output_t += ev.get("o", 0)
|
|
610
|
+
cache_create_t += ev.get("c", 0)
|
|
611
|
+
cache_create_5m_t += ev.get("c5", 0)
|
|
612
|
+
cache_create_1h_t += ev.get("c1", 0)
|
|
613
|
+
cache_read_t += ev.get("r", 0)
|
|
614
|
+
tool_uses += ev.get("u", 0)
|
|
615
|
+
if ts:
|
|
616
|
+
if first_ts is None or ts < first_ts:
|
|
617
|
+
first_ts = ts
|
|
618
|
+
if last_ts is None or ts > last_ts:
|
|
619
|
+
last_ts = ts
|
|
620
|
+
duration_ms = 0
|
|
621
|
+
if first_ts and last_ts:
|
|
622
|
+
try:
|
|
623
|
+
a = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
|
|
624
|
+
b = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
|
|
625
|
+
duration_ms = max(0, int((b - a).total_seconds() * 1000))
|
|
626
|
+
except ValueError:
|
|
627
|
+
duration_ms = 0
|
|
628
|
+
# '처리 토큰' total 에서 cache_read 는 제외한다. (기존 주석 유지 — 중복
|
|
629
|
+
# 카운트 부풀림 방지, cacheReadTokens 로 별도 노출, 비용은 0.1x 별도 반영)
|
|
630
|
+
total = input_t + output_t + cache_create_t
|
|
631
|
+
return {
|
|
632
|
+
"totalTokens": total,
|
|
633
|
+
"inputTokens": input_t,
|
|
634
|
+
"outputTokens": output_t,
|
|
635
|
+
"cacheCreationTokens": cache_create_t,
|
|
636
|
+
"cacheCreation5mTokens": cache_create_5m_t,
|
|
637
|
+
"cacheCreation1hTokens": cache_create_1h_t,
|
|
638
|
+
"cacheReadTokens": cache_read_t,
|
|
639
|
+
"toolUses": tool_uses,
|
|
640
|
+
"durationMs": duration_ms,
|
|
641
|
+
"agentName": agent_name,
|
|
642
|
+
"model": model,
|
|
643
|
+
"startedAt": first_ts,
|
|
644
|
+
"endedAt": last_ts,
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def claude_session_totals(
|
|
649
|
+
jsonl_path: Path, *, since: str | None = None, until: str | None = None,
|
|
650
|
+
incremental: bool = False,
|
|
651
|
+
) -> dict:
|
|
652
|
+
"""(기존 docstring 윈도우 설명 유지) + incremental=True 면 $OKSTRA_HOME
|
|
653
|
+
캐시의 byte cursor 이후만 읽는다. 캐시에는 윈도우 적용 전 이벤트가 저장
|
|
654
|
+
되므로 호출마다 다른 since/until 에도 결과는 전체 스캔과 동일하다.
|
|
655
|
+
"""
|
|
656
|
+
if incremental:
|
|
657
|
+
cache = load_cache(jsonl_path)
|
|
658
|
+
view = _advance_usage_scan(jsonl_path, cache["usage"])
|
|
659
|
+
save_cache(jsonl_path, cache)
|
|
660
|
+
else:
|
|
661
|
+
view = _advance_usage_scan(jsonl_path, fresh_cache()["usage"])
|
|
662
|
+
return _totals_from_events(view["events"], view["agentName"],
|
|
663
|
+
view["model"], since, until)
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
`iter_jsonl` import 는 claude.py 에서 제거된다(codex.py 는 계속 사용).
|
|
667
|
+
|
|
668
|
+
- [x] **Step 4: 신규 + 기존 테스트 통과 확인**
|
|
669
|
+
|
|
670
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py tests/test_okstra_token_usage_collect.py -v`
|
|
671
|
+
Expected: PASS — 특히 기존 `test_claude_session_totals_window_filter`, `test_total_tokens_excludes_cache_read` 가 무수정 통과(parity 증거).
|
|
672
|
+
|
|
673
|
+
- [x] **Step 5: Commit**
|
|
674
|
+
|
|
675
|
+
```bash
|
|
676
|
+
git add scripts/okstra_token_usage/claude.py tests/test_okstra_token_usage_incremental.py
|
|
677
|
+
git commit -m "feat(token-usage): claude_session_totals 증분 스캔 — 이벤트 추출본 캐시로 윈도우 재평가"
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
### Task 4: `find_claude_team_sessions` needle 스캔 증분화
|
|
681
|
+
|
|
682
|
+
needle 미발견 파일은 매 collect 마다 EOF 까지 재스캔된다 — 파일별 needle cursor 로 신규 byte 만 검사한다. 구현은 한 벌: 비증분 = 일회용 entry 로 같은 스캐너 호출.
|
|
683
|
+
|
|
684
|
+
**Files:**
|
|
685
|
+
- Modify: `scripts/okstra_token_usage/claude.py`
|
|
686
|
+
- Test: `tests/test_okstra_token_usage_incremental.py`
|
|
687
|
+
|
|
688
|
+
- [x] **Step 1: 실패하는 테스트 작성**
|
|
689
|
+
|
|
690
|
+
```python
|
|
691
|
+
def test_team_needle_scan_is_incremental(monkeypatch, tmp_path):
|
|
692
|
+
"""미발견 파일에 나중에 team 태그가 append 되면 warm 스캔이 발견해야 하고,
|
|
693
|
+
이미 읽은 구간은 재읽지 않아야 한다(읽은 구간 파괴로 증명)."""
|
|
694
|
+
from okstra_token_usage.claude import find_claude_team_sessions
|
|
695
|
+
from okstra_token_usage import paths as paths_mod
|
|
696
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
697
|
+
claude_root = tmp_path / "claude-home" / "projects"
|
|
698
|
+
cwd = tmp_path / "project"
|
|
699
|
+
encoded = "-" + str(cwd).strip("/").replace("/", "-")
|
|
700
|
+
proj_dir = claude_root / encoded
|
|
701
|
+
proj_dir.mkdir(parents=True)
|
|
702
|
+
monkeypatch.setattr(paths_mod, "CLAUDE_PROJECTS", claude_root)
|
|
703
|
+
|
|
704
|
+
pad = '{"f":"' + "x" * 300 + '"}\n' # 식별자 prefix 밖에서 파괴 가능하게
|
|
705
|
+
other = '{"type":"user","text":"no team here"}\n'
|
|
706
|
+
(proj_dir / "sess-a.jsonl").write_bytes((pad + other).encode())
|
|
707
|
+
|
|
708
|
+
assert find_claude_team_sessions(cwd, "okstra-T1", incremental=True) == {}
|
|
709
|
+
|
|
710
|
+
with (proj_dir / "sess-a.jsonl").open("r+b") as fh: # 읽은 구간 파괴
|
|
711
|
+
fh.seek(len(pad.encode()))
|
|
712
|
+
fh.write(b"#" * len(other.encode()))
|
|
713
|
+
with (proj_dir / "sess-a.jsonl").open("ab") as fh: # 신규 구간에 태그 append
|
|
714
|
+
fh.write(b'{"team":{"teamName":"okstra-T1"}}\n')
|
|
715
|
+
|
|
716
|
+
found = find_claude_team_sessions(cwd, "okstra-T1", incremental=True)
|
|
717
|
+
assert "sess-a" in found
|
|
718
|
+
# found=True 캐시: 이후 호출은 파일을 읽지 않고도 매칭 유지
|
|
719
|
+
assert "sess-a" in find_claude_team_sessions(cwd, "okstra-T1", incremental=True)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def test_team_needle_cache_caps_needle_count(monkeypatch, tmp_path):
|
|
723
|
+
from okstra_token_usage import cursor
|
|
724
|
+
from okstra_token_usage.claude import find_claude_team_sessions
|
|
725
|
+
from okstra_token_usage import paths as paths_mod
|
|
726
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "home"))
|
|
727
|
+
claude_root = tmp_path / "claude-home" / "projects"
|
|
728
|
+
cwd = tmp_path / "project"
|
|
729
|
+
encoded = "-" + str(cwd).strip("/").replace("/", "-")
|
|
730
|
+
proj_dir = claude_root / encoded
|
|
731
|
+
proj_dir.mkdir(parents=True)
|
|
732
|
+
monkeypatch.setattr(paths_mod, "CLAUDE_PROJECTS", claude_root)
|
|
733
|
+
p = proj_dir / "sess-a.jsonl"
|
|
734
|
+
p.write_bytes(b'{"type":"user"}\n')
|
|
735
|
+
for n in range(cursor.MAX_NEEDLES + 4):
|
|
736
|
+
find_claude_team_sessions(cwd, f"team-{n}", incremental=True)
|
|
737
|
+
assert len(cursor.load_cache(p)["needles"]) <= cursor.MAX_NEEDLES
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
- [x] **Step 2: 실패 확인**
|
|
741
|
+
|
|
742
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py -v -k needle`
|
|
743
|
+
Expected: FAIL — `find_claude_team_sessions() got an unexpected keyword argument 'incremental'`
|
|
744
|
+
|
|
745
|
+
- [x] **Step 3: `claude.py` 에 구현**
|
|
746
|
+
|
|
747
|
+
```python
|
|
748
|
+
def _needle_scan(jsonl_path: Path, entry: dict, needle_lower: str) -> bool:
|
|
749
|
+
"""entry({'offset','found'}) 를 전진시키며 needle 존재 여부 반환.
|
|
750
|
+
|
|
751
|
+
미완결 tail 라인도 검사한다(부분 문자열 매칭은 라인 완결 후에도 유효하므로
|
|
752
|
+
found=True 는 그대로 커밋해도 안전). 단 offset 은 완결 라인까지만 전진.
|
|
753
|
+
"""
|
|
754
|
+
if entry.get("found"):
|
|
755
|
+
return True
|
|
756
|
+
offset = entry.get("offset", 0) or 0
|
|
757
|
+
try:
|
|
758
|
+
size = jsonl_path.stat().st_size
|
|
759
|
+
except OSError:
|
|
760
|
+
return False
|
|
761
|
+
if offset > size:
|
|
762
|
+
offset = 0
|
|
763
|
+
try:
|
|
764
|
+
with jsonl_path.open("rb") as fh:
|
|
765
|
+
fh.seek(offset)
|
|
766
|
+
while True:
|
|
767
|
+
raw = fh.readline()
|
|
768
|
+
if not raw:
|
|
769
|
+
break
|
|
770
|
+
if needle_lower in raw.decode("utf-8", errors="replace").lower():
|
|
771
|
+
entry["found"] = True
|
|
772
|
+
entry["offset"] = offset
|
|
773
|
+
return True
|
|
774
|
+
if raw.endswith(b"\n"):
|
|
775
|
+
offset = fh.tell()
|
|
776
|
+
except OSError:
|
|
777
|
+
return False
|
|
778
|
+
entry["offset"] = offset
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def find_claude_team_sessions(
|
|
783
|
+
cwd: Path, team_name: str, lead_sid: str | None = None, *,
|
|
784
|
+
incremental: bool = False,
|
|
785
|
+
) -> dict[str, Path]:
|
|
786
|
+
"""(기존 docstring 유지) + incremental=True 면 파일별 needle cursor 이후의
|
|
787
|
+
신규 byte 만 검사한다. needle(=team 이름)은 run 마다 다르므로 파일당
|
|
788
|
+
MAX_NEEDLES 개까지 오래된 순으로 보존한다.
|
|
789
|
+
"""
|
|
790
|
+
proj_dir = claude_project_dir(cwd)
|
|
791
|
+
out: dict[str, Path] = {}
|
|
792
|
+
if not proj_dir.is_dir():
|
|
793
|
+
return out
|
|
794
|
+
needle_lower = f'"teamname":"{(team_name or "").lower()}"'
|
|
795
|
+
if team_name:
|
|
796
|
+
for p in proj_dir.glob("*.jsonl"):
|
|
797
|
+
if incremental:
|
|
798
|
+
cache = load_cache(p)
|
|
799
|
+
needles = cache.setdefault("needles", {})
|
|
800
|
+
entry = needles.get(needle_lower)
|
|
801
|
+
if entry is None:
|
|
802
|
+
entry = {"offset": 0, "found": False}
|
|
803
|
+
while len(needles) >= MAX_NEEDLES:
|
|
804
|
+
needles.pop(next(iter(needles)))
|
|
805
|
+
needles[needle_lower] = entry
|
|
806
|
+
if _needle_scan(p, entry, needle_lower):
|
|
807
|
+
out[p.stem] = p
|
|
808
|
+
save_cache(p, cache)
|
|
809
|
+
else:
|
|
810
|
+
if _needle_scan(p, {"offset": 0, "found": False}, needle_lower):
|
|
811
|
+
out[p.stem] = p
|
|
812
|
+
if lead_sid:
|
|
813
|
+
direct = proj_dir / f"{lead_sid}.jsonl"
|
|
814
|
+
if direct.is_file():
|
|
815
|
+
out.setdefault(lead_sid, direct)
|
|
816
|
+
return out
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
비고: `team_name` 이 빈 값이면 기존 코드도 매칭 0건이었으므로 루프 자체를 건너뛴다(결과 동일, 무의미한 전체 읽기 제거). 기존 `except OSError: continue` 의미는 `_needle_scan` 내부 OSError → False 로 보존.
|
|
820
|
+
|
|
821
|
+
- [x] **Step 4: 통과 확인 후 commit**
|
|
822
|
+
|
|
823
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py tests/test_okstra_token_usage_collect.py -v`
|
|
824
|
+
Expected: PASS
|
|
825
|
+
|
|
826
|
+
```bash
|
|
827
|
+
git add scripts/okstra_token_usage/claude.py tests/test_okstra_token_usage_incremental.py
|
|
828
|
+
git commit -m "feat(token-usage): team needle 스캔 증분화 — 파일별 needle cursor"
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### Task 5: `collect()`/CLI 배선 — 증분 기본 on, `--no-cache` 킬스위치
|
|
832
|
+
|
|
833
|
+
**Files:**
|
|
834
|
+
- Modify: `scripts/okstra_token_usage/collect.py` (함수 시그니처 + 3개 호출부)
|
|
835
|
+
- Modify: `scripts/okstra_token_usage/cli.py` (`--no-cache` 플래그)
|
|
836
|
+
- Test: `tests/test_okstra_token_usage_incremental.py`
|
|
837
|
+
|
|
838
|
+
- [x] **Step 1: 실패하는 테스트 작성** (collect e2e — cold/warm/no-cache 3-way 동일성; 기존 e2e fixture 패턴 재사용)
|
|
839
|
+
|
|
840
|
+
```python
|
|
841
|
+
def _stand_up_project(tmp_path, monkeypatch):
|
|
842
|
+
"""기존 collect e2e 와 같은 fixture 패턴 (실제 파일 IO, mock 없음)."""
|
|
843
|
+
from okstra_token_usage import paths as paths_mod
|
|
844
|
+
monkeypatch.setenv("OKSTRA_HOME", str(tmp_path / "okstra-home"))
|
|
845
|
+
project_root = tmp_path / "project"
|
|
846
|
+
run_dir = project_root / ".okstra" / "tasks" / "TG" / "TID" / "runs" / "phase"
|
|
847
|
+
run_dir.mkdir(parents=True)
|
|
848
|
+
claude_root = tmp_path / "claude-home" / "projects"
|
|
849
|
+
encoded = "-" + str(project_root).strip("/").replace("/", "-")
|
|
850
|
+
proj_dir = claude_root / encoded
|
|
851
|
+
proj_dir.mkdir(parents=True)
|
|
852
|
+
monkeypatch.setattr(paths_mod, "CLAUDE_PROJECTS", claude_root)
|
|
853
|
+
return project_root, run_dir, proj_dir
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _strip_volatile(state: dict) -> dict:
|
|
857
|
+
"""collectedAt 류 타임스탬프 제거 후 비교용 사본."""
|
|
858
|
+
s = json.loads(json.dumps(state))
|
|
859
|
+
s.get("usageSummary", {}).pop("collectedAt", None)
|
|
860
|
+
for blk in [s.get("leadUsage") or {}] + [w.get("usage") or {} for w in s.get("workers", [])]:
|
|
861
|
+
blk.pop("collectedAt", None)
|
|
862
|
+
return s
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def test_collect_cold_warm_nocache_identical(monkeypatch, tmp_path):
|
|
866
|
+
"""재실행(2회 collect)·재dispatch aggregate 가 캐시 유무와 무관하게 동일."""
|
|
867
|
+
import importlib
|
|
868
|
+
collect_mod = importlib.import_module("okstra_token_usage.collect")
|
|
869
|
+
project_root, run_dir, proj_dir = _stand_up_project(tmp_path, monkeypatch)
|
|
870
|
+
team = "okstra-TG:TID:tid"
|
|
871
|
+
|
|
872
|
+
def session(sid, agent, ts0, ts1, i, o):
|
|
873
|
+
lines = [
|
|
874
|
+
json.dumps({"sessionId": sid, "agentName": agent,
|
|
875
|
+
"team": {"teamName": team}, "timestamp": ts0,
|
|
876
|
+
"type": "system"}, separators=(",", ":")),
|
|
877
|
+
json.dumps({"sessionId": sid, "type": "assistant", "timestamp": ts1,
|
|
878
|
+
"message": {"model": "claude-sonnet-4-6",
|
|
879
|
+
"usage": {"input_tokens": i, "output_tokens": o,
|
|
880
|
+
"cache_creation_input_tokens": 0,
|
|
881
|
+
"cache_read_input_tokens": 0}}},
|
|
882
|
+
separators=(",", ":")),
|
|
883
|
+
]
|
|
884
|
+
(proj_dir / f"{sid}.jsonl").write_text("\n".join(lines) + "\n")
|
|
885
|
+
|
|
886
|
+
session("lead-sid", "lead", "2026-06-10T09:00:00Z", "2026-06-10T09:01:00Z", 10, 20)
|
|
887
|
+
session("codex-1", "codex-worker", "2026-06-10T10:00:00Z", "2026-06-10T10:05:00Z", 100, 200)
|
|
888
|
+
session("codex-2", "codex-worker-002", "2026-06-10T10:10:00Z", "2026-06-10T10:20:00Z", 300, 400)
|
|
889
|
+
|
|
890
|
+
ts_path = run_dir / "state" / "team-state-error-analysis-001.json"
|
|
891
|
+
ts_path.parent.mkdir(parents=True)
|
|
892
|
+
ts_path.write_text(json.dumps({
|
|
893
|
+
"schemaVersion": "1.0", "taskKey": "TG:TID:tid",
|
|
894
|
+
"runDirectoryPath": str(run_dir.relative_to(project_root)),
|
|
895
|
+
"team": {"teamName": team},
|
|
896
|
+
"runEndedAt": "2026-06-10T11:00:00Z",
|
|
897
|
+
"lead": {"sessionId": "lead-sid", "model": "opus"},
|
|
898
|
+
"workers": [{"workerId": "codex", "agent": "codex", "model": "gpt-5.5"}],
|
|
899
|
+
}))
|
|
900
|
+
(run_dir / "manifests").mkdir()
|
|
901
|
+
(run_dir / "manifests" / "run-manifest-error-analysis-001.json").write_text(
|
|
902
|
+
json.dumps({"createdAt": "2026-06-10T08:00:00Z"}))
|
|
903
|
+
|
|
904
|
+
cold = collect_mod.collect(ts_path, project_root=project_root) # 캐시 생성
|
|
905
|
+
warm = collect_mod.collect(ts_path, project_root=project_root) # 캐시 사용
|
|
906
|
+
nocache = collect_mod.collect(ts_path, project_root=project_root, incremental=False)
|
|
907
|
+
assert _strip_volatile(cold) == _strip_volatile(warm) == _strip_volatile(nocache)
|
|
908
|
+
assert cold["usageSummary"]["workerTotalTokens"] == 1000 # aggregate 보존
|
|
909
|
+
assert cold["workers"][0]["usage"]["additionalSessionIds"] == ["codex-2"]
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
- [x] **Step 2: 실패 확인**
|
|
913
|
+
|
|
914
|
+
Run: `python3 -m pytest tests/test_okstra_token_usage_incremental.py -v -k collect_cold`
|
|
915
|
+
Expected: FAIL — `collect() got an unexpected keyword argument 'incremental'`
|
|
916
|
+
|
|
917
|
+
- [x] **Step 3: `collect.py` 배선**
|
|
918
|
+
|
|
919
|
+
```python
|
|
920
|
+
def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
921
|
+
incremental: bool = True) -> dict:
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
내부 3개 호출부에 전달:
|
|
925
|
+
- `find_claude_team_sessions(cwd, team_name, lead_sid, incremental=incremental)`
|
|
926
|
+
- worker 루프: `claude_session_totals(path, since=run_since, until=run_until, incremental=incremental)`
|
|
927
|
+
- lead: `claude_session_totals(lead_path, since=run_since, until=run_until, incremental=incremental)`
|
|
928
|
+
|
|
929
|
+
- [x] **Step 4: `cli.py` 에 킬스위치 추가**
|
|
930
|
+
|
|
931
|
+
```python
|
|
932
|
+
parser.add_argument(
|
|
933
|
+
"--no-cache",
|
|
934
|
+
action="store_true",
|
|
935
|
+
help=(
|
|
936
|
+
"Disable the incremental session-scan cache and force a full "
|
|
937
|
+
"linear rescan of every session jsonl (correctness fallback)"
|
|
938
|
+
),
|
|
939
|
+
)
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
호출부: `updated = collect(args.team_state, args.project_root, incremental=not args.no_cache)`
|
|
943
|
+
|
|
944
|
+
- [x] **Step 5: 전체 테스트 통과 확인 후 commit**
|
|
945
|
+
|
|
946
|
+
Run: `python3 -m pytest tests/ -q && python3 -m pytest scripts/okstra_token_usage/ -q`
|
|
947
|
+
Expected: PASS (기존 collect e2e 4건은 incremental 기본 on 으로 통과해야 한다 — 그 자체가 회귀 가드)
|
|
948
|
+
|
|
949
|
+
```bash
|
|
950
|
+
git add scripts/okstra_token_usage/collect.py scripts/okstra_token_usage/cli.py tests/test_okstra_token_usage_incremental.py
|
|
951
|
+
git commit -m "feat(token-usage): collect/CLI 증분 스캔 기본 활성화 + --no-cache 킬스위치"
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
### Task 6: 실측 검증 — Task 0 기준값과 diff + 타이밍
|
|
955
|
+
|
|
956
|
+
mock 아님: Task 0 에서 고정한 **실제 세션 jsonl** 에 대해 변경 후 코드의 (a) 비증분, (b) 증분 cold, (c) 증분 warm 결과가 기준값과 모두 동일해야 한다.
|
|
957
|
+
|
|
958
|
+
- [x] **Step 1: 전후 동일성 diff (3-way)**
|
|
959
|
+
|
|
960
|
+
```bash
|
|
961
|
+
cd /Volumes/Workspaces/workspace/projects/Okstra
|
|
962
|
+
python3 /tmp/p6-snapshot.py > /tmp/p6-new-nocache.json
|
|
963
|
+
OKSTRA_HOME=/tmp/p6-cache python3 /tmp/p6-snapshot.py --incremental > /tmp/p6-new-cold.json
|
|
964
|
+
OKSTRA_HOME=/tmp/p6-cache python3 /tmp/p6-snapshot.py --incremental > /tmp/p6-new-warm.json
|
|
965
|
+
diff /tmp/p6-legacy-snapshot.json /tmp/p6-new-nocache.json && \
|
|
966
|
+
diff /tmp/p6-legacy-snapshot.json /tmp/p6-new-cold.json && \
|
|
967
|
+
diff /tmp/p6-legacy-snapshot.json /tmp/p6-new-warm.json && echo "PARITY OK"
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
Expected: `PARITY OK` (diff 출력 없음). 다르면 **여기서 멈추고 원인 규명** — 캐시 설계 수정 전까지 다음 Task 진행 금지.
|
|
971
|
+
|
|
972
|
+
- [x] **Step 2: warm 성능 측정 기록**
|
|
973
|
+
|
|
974
|
+
```bash
|
|
975
|
+
OKSTRA_HOME=/tmp/p6-cache-t python3 -c "
|
|
976
|
+
import sys, time
|
|
977
|
+
sys.path.insert(0, 'scripts')
|
|
978
|
+
from pathlib import Path
|
|
979
|
+
from okstra_token_usage.claude import claude_session_totals
|
|
980
|
+
f = max(Path('/tmp/p6-fixtures').glob('*.jsonl'), key=lambda p: p.stat().st_size)
|
|
981
|
+
t0 = time.perf_counter(); claude_session_totals(f, incremental=True); t1 = time.perf_counter()
|
|
982
|
+
t2 = time.perf_counter(); claude_session_totals(f, incremental=True); t3 = time.perf_counter()
|
|
983
|
+
print(f'{f.name}: cold={t1-t0:.3f}s warm={t3-t2:.3f}s ({(t1-t0)/(t3-t2):.0f}x)')"
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
Expected: warm 이 cold 보다 유의미하게 빠름 (수치를 CHANGES.md 항목에 기록).
|
|
987
|
+
|
|
988
|
+
### Task 7: 문서/빌드 마무리
|
|
989
|
+
|
|
990
|
+
**Files:**
|
|
991
|
+
- Modify: `docs/kr/performance-improvement-plan-v2.md` (§구현 plan 링크 — P6 항목을 이 문서로)
|
|
992
|
+
- Modify: `docs/kr/architecture.md` (storage 절에 캐시 경로 1줄), `docs/project-structure-overview.md` (`cursor.py` 항목), `docs/kr/cli.md` (`--no-cache`)
|
|
993
|
+
- Modify: `CHANGES.md` (사용자 영향 라인 포함)
|
|
994
|
+
|
|
995
|
+
- [x] **Step 1: v2 문서 링크 갱신**
|
|
996
|
+
|
|
997
|
+
`- P2 / P3 / P4 / P5 / P6: 미작성` → P6 를 분리해 `- P6: docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md` 추가, 나머지는 미작성 유지.
|
|
998
|
+
|
|
999
|
+
- [x] **Step 2: architecture/overview/cli 문서에 캐시 경로·플래그 반영**
|
|
1000
|
+
|
|
1001
|
+
- [x] **Step 3: CHANGES.md 항목 추가** (`사용자 영향:` 라인 + Task 6 측정 수치)
|
|
1002
|
+
|
|
1003
|
+
- [x] **Step 4: critique mode — 전체 diff 리뷰, 신규 식별자 grep 일관성 점검(`okstra_home`, `cursor`, `incremental`, `--no-cache`, `cache/token-usage`), build + 전체 테스트**
|
|
1004
|
+
|
|
1005
|
+
```bash
|
|
1006
|
+
grep -rn "okstra_home\|token-usage.*cache\|no-cache" scripts/ docs/ --include="*.py" --include="*.md" | grep -v __pycache__ | grep -v runtime/
|
|
1007
|
+
npm run build && python3 -m pytest tests/ -q && node bin/okstra --version
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
- [x] **Step 5: Commit**
|
|
1011
|
+
|
|
1012
|
+
```bash
|
|
1013
|
+
git add docs/ CHANGES.md
|
|
1014
|
+
git commit -m "docs(token-usage): P6 증분화 plan/architecture/CHANGES 반영"
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
---
|
|
1018
|
+
|
|
1019
|
+
## 검증·집행 지점 (선언 ≠ 집행 구분)
|
|
1020
|
+
|
|
1021
|
+
| 계약 | 집행 위치 |
|
|
1022
|
+
|---|---|
|
|
1023
|
+
| 윈도우 축소(재실행) 시 합계 불변 | `test_incremental_window_shrink_matches_fresh_scan` |
|
|
1024
|
+
| cold == legacy 전체 스캔 | `test_incremental_cold_equals_legacy_full_scan` + Task 6 실측 diff |
|
|
1025
|
+
| warm 이 기존 구간을 재읽지 않음 | `test_incremental_warm_reads_only_new_bytes` (읽은 구간 파괴 증명) |
|
|
1026
|
+
| tail 이중 집계 금지 | `test_incremental_tail_not_double_counted` |
|
|
1027
|
+
| truncate/교체 폴백 | `test_incremental_truncate_triggers_full_rescan` + cursor 식별자 테스트 |
|
|
1028
|
+
| 다중 세션 aggregate 보존 | `test_collect_cold_warm_nocache_identical` + 기존 collect e2e 무수정 통과 |
|
|
1029
|
+
| 실제 jsonl 전후 동일성 | Task 0 스냅샷 ↔ Task 6 diff (실파일, mock 없음) |
|