okstra 0.64.1 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/okstra +1 -0
  2. package/docs/kr/architecture.md +2 -0
  3. package/docs/kr/cli.md +11 -3
  4. package/docs/kr/performance-improvement-plan-v2.md +2 -1
  5. package/docs/project-structure-overview.md +1 -0
  6. package/docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md +1029 -0
  7. package/docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md +168 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +3 -1
  11. package/runtime/agents/workers/claude-worker.md +1 -1
  12. package/runtime/agents/workers/codex-worker.md +1 -0
  13. package/runtime/agents/workers/gemini-worker.md +1 -0
  14. package/runtime/bin/lib/okstra/cli.sh +4 -0
  15. package/runtime/bin/lib/okstra/globals.sh +1 -0
  16. package/runtime/bin/lib/okstra/usage.sh +4 -1
  17. package/runtime/bin/okstra.sh +1 -0
  18. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +96 -37
  20. package/runtime/python/okstra_ctl/context_cost.py +86 -8
  21. package/runtime/python/okstra_ctl/locks.py +32 -0
  22. package/runtime/python/okstra_ctl/migrate.py +45 -6
  23. package/runtime/python/okstra_ctl/pr_template.py +2 -7
  24. package/runtime/python/okstra_ctl/run.py +58 -44
  25. package/runtime/python/okstra_ctl/run_context.py +3 -8
  26. package/runtime/python/okstra_ctl/seeding.py +25 -18
  27. package/runtime/python/okstra_ctl/wizard.py +8 -10
  28. package/runtime/python/okstra_ctl/worktree.py +13 -0
  29. package/runtime/python/okstra_project/dirs.py +10 -1
  30. package/runtime/python/okstra_token_usage/claude.py +226 -61
  31. package/runtime/python/okstra_token_usage/cli.py +10 -1
  32. package/runtime/python/okstra_token_usage/collect.py +34 -27
  33. package/runtime/python/okstra_token_usage/cursor.py +93 -0
  34. package/runtime/python/okstra_token_usage/paths.py +29 -2
  35. package/runtime/skills/okstra-coding-preflight/clean-code.md +15 -0
  36. package/runtime/skills/okstra-inspect/SKILL.md +16 -11
  37. package/runtime/skills/okstra-run/templates/pr-body.template.md +13 -16
  38. package/runtime/validators/lib/fixtures.sh +73 -10
  39. package/runtime/validators/lib/runners.sh +4 -0
  40. package/runtime/validators/validate-run.py +53 -0
  41. package/runtime/validators/validate_session_conformance.py +430 -0
  42. 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 없음) |