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