@arbiterforge/ca-pi 0.6.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 (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,1035 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — cost/token ledger subsystem for the statusline (extracted T-12).
3
+ #
4
+ # Owns the user-level token/cost accounting the statusline renders: an Anthropic
5
+ # API price table, transcript-tailing accumulation (deduped per requestId), the
6
+ # per-session JSON ledger (~/.codearbiter/ledger.json) with TTL pruning + an
7
+ # atomic write, and the per-call burn samples that feed the sparkline. The
8
+ # statusline imports this for its cost segment; it carries NO rendering concern
9
+ # (no ANSI, no box drawing) so the accounting is unit-testable in isolation.
10
+ #
11
+ # Design principles (mirroring _metricslib.py / _taskboardlib.py):
12
+ # - Stdlib only; no third-party imports ever — runs on stock Python.
13
+ # - Zero side effects at import time: no git calls, no file I/O.
14
+ # - Pure functions are fully testable with synthetic input. ledger_update()
15
+ # and pi_ledger_update() are the only filesystem entry points; everything
16
+ # else is pure or a private bounded persistence helper.
17
+ # - Never raise on malformed user input — every reader degrades to safe blanks.
18
+ #
19
+ # Public API:
20
+ # price_for(model) -> tuple (input,out,c5,c1,cr) USD per 1M tokens
21
+ # api_cost(tok) -> float estimated API-equivalent USD for {model: tokens}
22
+ # ledger_path() -> str resolved ledger file path (env-overridable)
23
+ # _tx_accumulate(rec, tx_path) -> bool tail a transcript into rec; True if offset advanced
24
+ # _agg_reqs(reqs, only=None) -> dict aggregate per-request map -> {model: tokens}
25
+ # _totals(models) -> dict {in,out,cost} display totals for a model map
26
+ # ledger_update(data, sid) -> tuple (rec, session_totals, today_totals)
27
+ # pi_ledger_path() -> str separate user-global Pi ledger path
28
+ # pi_ledger_update(session_key, scan_start, scan_end, facts, path=None) -> dict
29
+ # bounded Pi session/day snapshot
30
+ # burn_samples(rec) -> list[float] recent per-call token-burn values for the sparkline
31
+
32
+ import hashlib
33
+ import json
34
+ import math
35
+ import os
36
+ import re
37
+ import stat
38
+ import sys
39
+ import time
40
+ from datetime import datetime
41
+
42
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
43
+ # _acquire_lock/_release_lock/LOCK_WAIT were hoisted to _hooklib (#271 C-2) so
44
+ # taskwrite.py's board writer can share ONE lock implementation instead of a
45
+ # second hand-rolled copy. Re-exported under their ORIGINAL private names so
46
+ # this module's own call sites (ledger_update/persist_sess_start) and the test
47
+ # suite's `mock.patch.object(L, "_acquire_lock", ...)` / `mock.patch.object(L,
48
+ # "LOCK_WAIT", ...)` seams keep working unchanged — no import-cycle risk:
49
+ # _hooklib imports only hostapi, never _ledgerlib.
50
+ from _hooklib import acquire_lock as _acquire_lock # noqa: E402
51
+ from _hooklib import release_lock as _release_lock # noqa: E402
52
+ from _hooklib import LOCK_WAIT # noqa: E402
53
+ # parse_iso lives in _fmtlib, which already documents it as part of its public
54
+ # API; this module carried a byte-identical second copy (#334 item 3). Same
55
+ # re-export pattern as the lock helpers above: one owner, and `L.parse_iso`
56
+ # stays importable for this module's call sites and the test suite. No cycle
57
+ # risk — _fmtlib imports only _colorlib, never _ledgerlib.
58
+ from _fmtlib import parse_iso # noqa: E402,F401
59
+
60
+ # Tunables (module constants; mirrored from the original inline statusline block).
61
+ SESSION_TTL = 36 * 3600 # prune sessions older than ~1.5 days
62
+ BURN_RING = 40 # recent per-call token-burn samples kept for the sparkline
63
+ TX_MAX_NEW_LINES = 20000 # hot-path bound: transcript lines parsed per render
64
+ # `last_ts` exists only to keep a live record inside SESSION_TTL. Stamping it on
65
+ # EVERY render made every render a writing render (#392) — the statusline runs in
66
+ # a fresh process per refresh, so that was two atomic replacements per refresh
67
+ # forever. Refresh it on a write we are making anyway, or at most once per
68
+ # heartbeat; 5 minutes is ~432x inside the 36-hour TTL.
69
+ LEDGER_HEARTBEAT = 300
70
+
71
+ # Pi's bridge sends only already-extracted usage facts. The bridge chunks long
72
+ # sessions so one call and one lock hold stay predictably small. `session_key` is
73
+ # a caller-derived SHA-256 digest of the stable Pi session identity, never the raw
74
+ # session name/path. These bounds mirror the footer's normalized numeric ceiling.
75
+ PI_MAX_SCAN_ENTRIES = 256
76
+ PI_MAX_SHARD_BYTES = 65_536
77
+ PI_MAX_POSITION = 2_147_483_647
78
+ PI_MAX_TOKENS = 1_000_000_000_000_000
79
+ PI_MAX_COST_USD = 1_000_000_000.0
80
+ PI_MAX_DAYS = 64
81
+ PI_MAX_SHARDS = 256
82
+ PI_MAX_DIRECTORY_ENTRIES = 8192
83
+ PI_MAX_UPDATED_AT = 9_999_999_999
84
+ PI_MAX_TIMESTAMP_CHARS = 64
85
+ PI_MAX_PATH_CHARS = 32_768
86
+ PI_MIN_YEAR = 2000
87
+ PI_MAX_YEAR = 2100
88
+ PI_LEDGER_SCHEMA = "codearbiter.pi-usage-ledger/v1"
89
+ PI_SESSION_SCHEMA = "codearbiter.pi-usage-session/v1"
90
+ PI_SESSION_KEY_RE = re.compile(r"[0-9a-f]{64}\Z")
91
+ PI_SHARD_NAME_RE = re.compile(r"([0-9a-f]{64})\.json\Z")
92
+ PI_DAY_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z")
93
+ PI_TOTAL_KEYS = frozenset({
94
+ "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd",
95
+ })
96
+ PI_FACT_KEYS = frozenset({"position", "timestamp"}) | PI_TOTAL_KEYS
97
+ PI_STATUSES = frozenset({"ok", "invalid", "corrupt", "lock_failed", "write_failed"})
98
+
99
+ # API list prices, USD per 1M tokens (captured 2026-06-10 from Anthropic's
100
+ # pricing pages). Used ONLY to estimate the pay-as-you-go API-equivalent cost of
101
+ # this session's REAL tokens — the bar labels it "api≈"; it is not a bill.
102
+ # Per model family: (input, output, cache_write_5m, cache_write_1h, cache_read).
103
+ # Cache multipliers are the standard ones: write 1.25x/2x input, read 0.1x.
104
+ API_PRICES = {
105
+ "fable": (10.0, 50.0, 12.50, 20.0, 1.00),
106
+ "opus": (5.0, 25.0, 6.25, 10.0, 0.50),
107
+ "sonnet": (3.0, 15.0, 3.75, 6.0, 0.30),
108
+ "haiku": (1.0, 5.0, 1.25, 2.0, 0.10),
109
+ }
110
+
111
+
112
+ # --------------------------------------------------------------------------- coercion
113
+ def num(x, default=0.0):
114
+ """Coerce any host value to float; tolerate strings, None, and containers."""
115
+ try:
116
+ return float(x)
117
+ except (TypeError, ValueError):
118
+ return default
119
+
120
+
121
+ def safe(fn, *a, **k):
122
+ """Run fn; swallow any failure so one bad input can't break the ledger.
123
+ Returns None on error (caller treats it as a no-op / blank)."""
124
+ try:
125
+ return fn(*a, **k)
126
+ except Exception: # noqa: BLE001
127
+ return None
128
+
129
+
130
+ def get(d, *path, default=None):
131
+ cur = d
132
+ for k in path:
133
+ if not isinstance(cur, dict) or k not in cur or cur[k] is None:
134
+ return default
135
+ cur = cur[k]
136
+ return cur
137
+
138
+
139
+ # --------------------------------------------------------------------------- pricing
140
+ def price_for(model):
141
+ ml = str(model).lower()
142
+ for fam, p in API_PRICES.items():
143
+ if fam in ml:
144
+ return p
145
+ return API_PRICES["sonnet"] # reasonable mid default for an unrecognized model
146
+
147
+
148
+ def api_cost(tok):
149
+ """Estimated pay-as-you-go API cost (USD) for accumulated per-model tokens."""
150
+ total = 0.0
151
+ for model, t in (tok or {}).items():
152
+ if not isinstance(t, dict):
153
+ continue
154
+ pin, pout, p5, p1, pr = price_for(model)
155
+ total += (num(t.get("in")) * pin + num(t.get("out")) * pout
156
+ + num(t.get("c5")) * p5 + num(t.get("c1")) * p1
157
+ + num(t.get("cr")) * pr) / 1e6
158
+ return total
159
+
160
+
161
+ # --------------------------------------------------------------------------- ledger
162
+ def ledger_path():
163
+ return os.environ.get("CODEARBITER_LEDGER") or \
164
+ os.path.join(os.path.expanduser("~"), ".codearbiter", "ledger.json")
165
+
166
+
167
+ def _read_json(path, default=None):
168
+ try:
169
+ with open(path, encoding="utf-8") as f:
170
+ value = json.load(f)
171
+ return value
172
+ except (OSError, ValueError):
173
+ return default
174
+
175
+
176
+ def _atomic_json(path, value):
177
+ """Atomically replace one JSON file without sharing a staging pathname."""
178
+ tmp = None
179
+ try:
180
+ parent = os.path.dirname(path)
181
+ if parent:
182
+ os.makedirs(parent, exist_ok=True)
183
+ tmp = f"{path}.{os.getpid()}.{time.time_ns()}.tmp"
184
+ with open(tmp, "w", encoding="utf-8") as f:
185
+ json.dump(value, f)
186
+ os.replace(tmp, path)
187
+ return True
188
+ except OSError:
189
+ return False
190
+ finally:
191
+ if tmp:
192
+ try:
193
+ os.remove(tmp)
194
+ except OSError:
195
+ pass
196
+
197
+
198
+ def _session_dir(path):
199
+ return f"{path}.sessions"
200
+
201
+
202
+ def _session_key(sid):
203
+ return hashlib.sha256(str(sid).encode("utf-8", "replace")).hexdigest()
204
+
205
+
206
+ def _session_file(path, sid):
207
+ return os.path.join(_session_dir(path), f"{_session_key(sid)}.json")
208
+
209
+
210
+ def _start_file(path, sid):
211
+ return os.path.join(_session_dir(path), f"{_session_key(sid)}.start.json")
212
+
213
+
214
+ def _merge_sessions(path):
215
+ """Merge the legacy snapshot with authoritative independently-written shards.
216
+
217
+ Returns (sessions, legacy) where `legacy` is the compatibility snapshot's own
218
+ mapping exactly as it was read from disk (None when the file is absent or
219
+ malformed). Callers compare the merged result against `legacy` to tell whether
220
+ the snapshot on disk is already in sync, so the whole-snapshot rewrite happens
221
+ only when it would actually change bytes (#392). Session records are copied out
222
+ of `legacy` so later in-place edits (the .start.json overlay below, and the
223
+ caller's own record) cannot mutate the comparison baseline.
224
+ """
225
+ led = _read_json(path, {})
226
+ legacy = led.get("sessions") if isinstance(led, dict) else None
227
+ if not isinstance(legacy, dict):
228
+ legacy = None
229
+ sessions = {} if legacy is None else {
230
+ sid: dict(rec) for sid, rec in legacy.items() if isinstance(rec, dict)}
231
+ directory = _session_dir(path)
232
+ try:
233
+ names = os.listdir(directory)
234
+ except OSError:
235
+ names = []
236
+ for name in names:
237
+ if not name.endswith(".json") or name.endswith(".start.json"):
238
+ continue
239
+ enumerated = os.path.join(directory, name)
240
+ item = _read_json(enumerated)
241
+ if not isinstance(item, dict) or not isinstance(item.get("rec"), dict):
242
+ try:
243
+ os.remove(enumerated)
244
+ except OSError:
245
+ pass
246
+ continue
247
+ sid = str(item.get("sid"))
248
+ if os.path.basename(_session_file(path, sid)) != name:
249
+ try:
250
+ os.remove(enumerated)
251
+ except OSError:
252
+ pass
253
+ continue
254
+ rec = item["rec"]
255
+ if time.time() - num(rec.get("last_ts")) > SESSION_TTL:
256
+ for stale in (enumerated, _start_file(path, sid)):
257
+ try:
258
+ os.remove(stale)
259
+ except OSError:
260
+ pass
261
+ sessions.pop(sid, None)
262
+ continue
263
+ sessions[sid] = rec
264
+ for name in names:
265
+ if not name.endswith(".start.json"):
266
+ continue
267
+ enumerated = os.path.join(directory, name)
268
+ item = _read_json(enumerated)
269
+ if not isinstance(item, dict):
270
+ try:
271
+ os.remove(enumerated)
272
+ except OSError:
273
+ pass
274
+ continue
275
+ sid = str(item.get("sid"))
276
+ start = num(item.get("sess_start"), None)
277
+ valid_name = os.path.basename(_start_file(path, sid)) == name
278
+ if valid_name and sid in sessions and start is not None:
279
+ sessions[sid]["sess_start"] = float(start)
280
+ else:
281
+ try:
282
+ os.remove(enumerated)
283
+ except OSError:
284
+ pass
285
+ now = time.time()
286
+ sessions = {sid: rec for sid, rec in sessions.items()
287
+ if isinstance(rec, dict)
288
+ and now - num(rec.get("last_ts")) <= SESSION_TTL}
289
+ return sessions, legacy
290
+
291
+
292
+ def _load_sessions(path):
293
+ """The merged live-session map only (see _merge_sessions for the baseline)."""
294
+ return _merge_sessions(path)[0]
295
+
296
+
297
+ def _write_snapshot(path, sessions):
298
+ return _atomic_json(path, {"sessions": sessions})
299
+
300
+
301
+ def _msg_date(ts):
302
+ """Local calendar date (YYYY-MM-DD) of a transcript message's timestamp, so
303
+ tokens are attributed to the day they were actually burned (a session that
304
+ crosses midnight splits correctly across days)."""
305
+ e = parse_iso(ts) if isinstance(ts, str) else None
306
+ if e is None:
307
+ return datetime.now().strftime("%Y-%m-%d")
308
+ try:
309
+ return datetime.fromtimestamp(e).strftime("%Y-%m-%d")
310
+ except (OSError, OverflowError, ValueError):
311
+ return datetime.now().strftime("%Y-%m-%d")
312
+
313
+
314
+ def _tx_accumulate(rec, tx_path):
315
+ """Tail the session transcript JSONL from the stored byte offset, UPSERTING each
316
+ assistant message's usage into a per-requestId dedup map (the transcript logs a
317
+ single API call several times via streaming/replay; counting each request once
318
+ is what keeps tokens AND cost honest) and pushing a per-call burn sample. Append-
319
+ only -> O(new lines)/render. Returns True if the offset advanced."""
320
+ if not tx_path or not os.path.isfile(tx_path):
321
+ return False
322
+ try:
323
+ size = os.path.getsize(tx_path)
324
+ except OSError:
325
+ return False
326
+ if not isinstance(rec.get("reqs"), dict):
327
+ rec["reqs"] = {} # fresh record, or migrating from an earlier schema
328
+ rec["tx_off"] = 0
329
+ rec.pop("days", None)
330
+ rec.pop("tok", None)
331
+ if not isinstance(rec.get("burn"), list):
332
+ rec["burn"] = []
333
+ off = int(num(rec.get("tx_off")))
334
+ # New transcript for this session, or truncation/rotation -> reparse from start.
335
+ if rec.get("tx_path") != tx_path or off > size:
336
+ off, rec["reqs"], rec["burn"], rec["tx_path"] = 0, {}, [], tx_path
337
+ if off >= size:
338
+ return False
339
+ try:
340
+ with open(tx_path, "rb") as f:
341
+ f.seek(off)
342
+ chunk = f.read()
343
+ except OSError:
344
+ return False
345
+ new_off = size
346
+ # A writer may flush mid-line; keep a trailing partial line for next render.
347
+ if chunk and not chunk.endswith(b"\n"):
348
+ cut = chunk.rfind(b"\n")
349
+ if cut < 0:
350
+ return False # no complete line yet
351
+ new_off = off + cut + 1
352
+ chunk = chunk[:cut]
353
+ parsed = 0
354
+ for raw in chunk.split(b"\n"):
355
+ if not raw.strip():
356
+ continue
357
+ parsed += 1
358
+ if parsed > TX_MAX_NEW_LINES:
359
+ break
360
+ try:
361
+ o = json.loads(raw.decode("utf-8", "replace"))
362
+ except ValueError:
363
+ continue
364
+ if not isinstance(o, dict) or o.get("type") != "assistant":
365
+ continue
366
+ m = o.get("message")
367
+ u = m.get("usage") if isinstance(m, dict) else None
368
+ if not isinstance(u, dict):
369
+ continue
370
+ model = m.get("model") or "?"
371
+ i = num(u.get("input_tokens"))
372
+ cr = num(u.get("cache_read_input_tokens"))
373
+ cc = u.get("cache_creation")
374
+ if isinstance(cc, dict):
375
+ c5 = num(cc.get("ephemeral_5m_input_tokens"))
376
+ c1 = num(cc.get("ephemeral_1h_input_tokens"))
377
+ else:
378
+ c5 = c1 = 0.0
379
+ cw = num(u.get("cache_creation_input_tokens"))
380
+ if cw and not (c5 or c1): # 5m/1h split absent -> treat all as 5m
381
+ c5 = cw
382
+ out = num(u.get("output_tokens"))
383
+ ts = o.get("timestamp")
384
+ e = parse_iso(ts) if isinstance(ts, str) else None
385
+ if e is not None: # earliest message ts = true session start
386
+ t0 = rec.get("t0")
387
+ if not isinstance(t0, (int, float)) or e < t0:
388
+ rec["t0"] = e
389
+ # Dedupe by requestId: the transcript logs each API call multiple times
390
+ # (streaming/replay), so UPSERT each request's final usage exactly once
391
+ # instead of summing every line, which 2-3x over-counts BOTH tokens and cost.
392
+ key = o.get("requestId") or m.get("id") or f"_p{int(off) + parsed}"
393
+ is_new = key not in rec["reqs"]
394
+ rec["reqs"][key] = {"d": _msg_date(ts), "m": model,
395
+ "in": i, "cr": cr, "c5": c5, "c1": c1, "out": out}
396
+ if is_new:
397
+ rec["burn"].append(i + c5 + c1 + out) # fresh input + output (cache reads excluded)
398
+ if len(rec["burn"]) > BURN_RING:
399
+ rec["burn"] = rec["burn"][-BURN_RING:]
400
+ rec["tx_off"] = new_off
401
+ return True
402
+
403
+
404
+ def _agg_reqs(reqs, only=None):
405
+ """Aggregate the per-request dedup map into {model: tokens}; if `only` is a
406
+ date, include just that local-calendar-day's requests (for the Today totals)."""
407
+ out = {}
408
+ for r in (reqs or {}).values():
409
+ if not isinstance(r, dict):
410
+ continue
411
+ if only is not None and r.get("d") != only:
412
+ continue
413
+ o = out.setdefault(r.get("m") or "?",
414
+ {"in": 0.0, "cr": 0.0, "c5": 0.0, "c1": 0.0, "out": 0.0})
415
+ for k in o:
416
+ o[k] += num(r.get(k))
417
+ return out
418
+
419
+
420
+ def _totals(models):
421
+ """Display totals + API-equivalent cost for a model map. The displayed "in" is
422
+ FRESH input (uncached input + cache writes) — cache READS are excluded from the
423
+ token count (they re-serve already-sent context every turn and would inflate it
424
+ 30-100x), but they ARE still priced into the cost via api_cost()."""
425
+ tin = tout = 0.0
426
+ for t in (models or {}).values():
427
+ if isinstance(t, dict):
428
+ tin += num(t.get("in")) + num(t.get("c5")) + num(t.get("c1"))
429
+ tout += num(t.get("out"))
430
+ return {"in": tin, "out": tout, "cost": api_cost(models)}
431
+
432
+
433
+ def ledger_update(data, sid):
434
+ blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
435
+ if not sid:
436
+ return {}, dict(blank), dict(blank)
437
+ path = ledger_path()
438
+ lock = _acquire_lock(path)
439
+ if lock is None:
440
+ return {}, dict(blank), dict(blank)
441
+ try:
442
+ return _ledger_update_unlocked(data, sid, path)
443
+ finally:
444
+ _release_lock(lock)
445
+
446
+
447
+ def _ledger_update_unlocked(data, sid, path):
448
+ """Read-modify-write the per-session ledger. Accumulate the session's TRUE token
449
+ COUNTS by tailing its transcript (deduped per requestId), take the COST from the
450
+ host's cost.total_cost_usd, and return (session record, this-session totals,
451
+ today's totals across sessions). Best-effort; safe blanks on any failure.
452
+
453
+ The statusline re-renders in a fresh process on every refresh, so this is the
454
+ product's hottest filesystem path: each write below is skipped unless it would
455
+ actually change bytes on disk (#392). The on-disk FORMAT is unchanged — the
456
+ per-session shard and the whole-ledger compatibility snapshot are both still
457
+ written exactly as before, just no longer unconditionally."""
458
+ blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
459
+ now = time.time()
460
+ today = datetime.now().strftime("%Y-%m-%d")
461
+
462
+ sessions, legacy = _merge_sessions(path)
463
+
464
+ rec = sessions.get(sid)
465
+ dirty = not isinstance(rec, dict)
466
+ if dirty:
467
+ rec = {}
468
+ stored_ts = num(rec.get("last_ts"))
469
+ if "first_ts" not in rec:
470
+ rec["first_ts"] = now
471
+ dirty = True
472
+ if rec.get("last_day") != today:
473
+ rec["last_day"] = today
474
+ dirty = True
475
+ host_cost = num(get(data, "cost", "total_cost_usd"))
476
+ if rec.get("host_cost") != host_cost:
477
+ rec["host_cost"] = host_cost
478
+ dirty = True
479
+
480
+ tx = data.get("transcript_path") if isinstance(data, dict) else None
481
+ if safe(_tx_accumulate, rec, tx):
482
+ dirty = True
483
+ sess = _totals(_agg_reqs(rec.get("reqs"))) # tokens: this session, all requests (deduped)
484
+ # Cost = Claude Code's authoritative cost.total_cost_usd — it already prices every
485
+ # call (including subagents in separate transcripts) exactly as your bill does, so
486
+ # it is far more accurate than recomputing tokens*price. api_cost is fallback only.
487
+ if rec["host_cost"] > 0:
488
+ sess["cost"] = rec["host_cost"]
489
+ bucket = dict(_totals(_agg_reqs(rec.get("reqs"), only=today)), date=today)
490
+ if rec.get("today") != bucket:
491
+ rec["today"] = bucket
492
+ dirty = True
493
+ if "tot" in rec: # retire the batch-1 whole-session cache key
494
+ del rec["tot"]
495
+ dirty = True
496
+ # Heartbeat: `last_ts` is pure liveness for the TTL sweep, so it rides along
497
+ # with a write we are already making rather than forcing one of its own.
498
+ if dirty or now - stored_ts >= LEDGER_HEARTBEAT:
499
+ rec["last_ts"] = now
500
+ dirty = True
501
+ sessions[sid] = rec
502
+
503
+ for k in list(sessions.keys()):
504
+ v = sessions[k]
505
+ if not isinstance(v, dict) or now - num(v.get("last_ts")) > SESSION_TTL:
506
+ del sessions[k]
507
+ # Each session owns one independently replaced shard. A writer for another
508
+ # session therefore cannot replace this record with an older snapshot. The
509
+ # merged map above already reflects every shard read under this same lock, so
510
+ # there is nothing a re-read could learn — no concurrent writer can have run.
511
+ if dirty:
512
+ _atomic_json(_session_file(path, sid), {"sid": sid, "rec": rec})
513
+ if sessions != legacy:
514
+ # Compatibility/readability cache; shards are truth. Rewritten only when
515
+ # it has drifted from the merged truth — which still covers TTL pruning,
516
+ # a new session, and a legacy-only record that a shard has superseded.
517
+ _write_snapshot(path, sessions)
518
+
519
+ # Today = each session's TODAY bucket (tokens whose transcript timestamp falls
520
+ # on the current local day), summed across sessions — not whole-session totals.
521
+ day = dict(blank)
522
+ for v in sessions.values():
523
+ if not isinstance(v, dict):
524
+ continue
525
+ t = v.get("today") if isinstance(v.get("today"), dict) else None
526
+ if t and t.get("date") == today: # tokens: true per-calendar-day buckets
527
+ day["in"] += num(t.get("in"))
528
+ day["out"] += num(t.get("out"))
529
+ if v.get("last_day") == today: # cost: host per-session total, day-attributed
530
+ day["cost"] += num(v.get("host_cost"))
531
+ return rec, sess, day
532
+
533
+
534
+ def persist_sess_start(sid, value):
535
+ """Write a resolved wall-clock session-start epoch into the ledger record for
536
+ `sid` so later renders read it from the ledger instead of re-scanning the host's
537
+ session-metadata directory (the statusline fast path). Best-effort and idempotent:
538
+ a no-op if already stored, and a silent skip on any I/O error — it must never
539
+ break a render. Returns True iff the ledger was written."""
540
+ if not sid or not value:
541
+ return False
542
+ path = ledger_path()
543
+ lock = _acquire_lock(path)
544
+ if lock is None:
545
+ return False
546
+ try:
547
+ return _persist_sess_start_unlocked(sid, value, path)
548
+ finally:
549
+ _release_lock(lock)
550
+
551
+
552
+ def _persist_sess_start_unlocked(sid, value, path):
553
+ sessions, legacy = _merge_sessions(path)
554
+ rec = sessions.get(sid)
555
+ if not isinstance(rec, dict):
556
+ return False
557
+ if num(rec.get("sess_start"), None) == float(value):
558
+ return False # already cached — nothing to do
559
+ # Cache metadata has its own file, so it cannot overwrite a concurrently
560
+ # refreshed token/cost shard for the same session.
561
+ if not _atomic_json(_start_file(path, sid),
562
+ {"sid": sid, "sess_start": float(value)}):
563
+ return False
564
+ # Applying the value we just persisted is exactly what a re-merge would
565
+ # produce — the start-file overlay in _merge_sessions — without the reread.
566
+ rec["sess_start"] = float(value)
567
+ if sessions != legacy:
568
+ _write_snapshot(path, sessions)
569
+ return True
570
+
571
+
572
+ # --------------------------------------------------------------------------- Pi usage ledger
573
+ def pi_ledger_path():
574
+ """Return Pi's separate user-global usage ledger anchor.
575
+
576
+ This path is fixed: runtime environment cannot redirect Pi accounting into
577
+ Claude's `ledger.json` schema or session namespace. Tests inject an explicit
578
+ isolated anchor through `pi_ledger_update(..., path=...)` instead.
579
+ """
580
+ return os.path.join(
581
+ os.path.expanduser("~"), ".codearbiter", "pi-usage-ledger.json"
582
+ )
583
+
584
+
585
+ def pi_blank_totals():
586
+ """Fresh bounded empty Pi totals for fail-soft results."""
587
+ return {
588
+ "inputTokens": 0,
589
+ "outputTokens": 0,
590
+ "cacheReadTokens": 0,
591
+ "cacheWriteTokens": 0,
592
+ "costUsd": 0.0,
593
+ }
594
+
595
+
596
+ def _pi_result(status, session=None, today=None, high_water=-1,
597
+ accepted_through=-1):
598
+ """Return durable state plus a distinct acknowledgment for this call."""
599
+ if status not in PI_STATUSES:
600
+ status = "corrupt"
601
+ return {
602
+ "status": status,
603
+ "session": dict(session) if isinstance(session, dict) else pi_blank_totals(),
604
+ "today": dict(today) if isinstance(today, dict) else pi_blank_totals(),
605
+ "acceptedThrough": accepted_through if type(accepted_through) is int else -1,
606
+ "highWater": high_water if type(high_water) is int else -1,
607
+ }
608
+
609
+
610
+ def _pi_session_file(path, session_key):
611
+ """A validated digest is both the bounded identity and safe shard basename."""
612
+ return os.path.join(f"{path}.sessions", f"{session_key}.json")
613
+
614
+
615
+ def _pi_canonical_path(path):
616
+ """Resolve symlinks/junctions in the existing prefix of a future path."""
617
+ if not isinstance(path, str) or not path or len(path) > PI_MAX_PATH_CHARS \
618
+ or path != path.strip() \
619
+ or any(ord(char) < 32 or ord(char) == 127 for char in path):
620
+ return None
621
+ try:
622
+ absolute = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
623
+ except (OSError, TypeError, ValueError):
624
+ return None
625
+ if len(absolute) > PI_MAX_PATH_CHARS:
626
+ return None
627
+ probe = absolute
628
+ suffix = []
629
+ try:
630
+ while not os.path.lexists(probe):
631
+ parent, name = os.path.split(probe)
632
+ if not name or parent == probe:
633
+ return None
634
+ suffix.append(name)
635
+ probe = parent
636
+ if suffix and not os.path.isdir(probe):
637
+ return None
638
+ resolved = os.path.realpath(probe)
639
+ except (OSError, TypeError, ValueError):
640
+ return None
641
+ for name in reversed(suffix):
642
+ resolved = os.path.join(resolved, name)
643
+ return os.path.normcase(os.path.normpath(resolved))
644
+
645
+
646
+ def _pi_paths_overlap(left, right):
647
+ try:
648
+ common = os.path.commonpath((left, right))
649
+ except (OSError, TypeError, ValueError):
650
+ return False
651
+ return common == left or common == right
652
+
653
+
654
+ def _pi_resolve_path(path):
655
+ """Return an isolated canonical Pi anchor, or None before lock/write."""
656
+ explicit = path is not None
657
+ candidate = pi_ledger_path() if path is None else path
658
+ if explicit and (not isinstance(candidate, str) or not os.path.isabs(candidate)):
659
+ return None
660
+ try:
661
+ if os.path.isdir(candidate):
662
+ return None
663
+ except (OSError, TypeError, ValueError):
664
+ return None
665
+ pi_anchor = _pi_canonical_path(candidate)
666
+ pi_sessions = _pi_canonical_path(f"{candidate}.sessions") \
667
+ if isinstance(candidate, str) else None
668
+ pi_lock = _pi_canonical_path(f"{candidate}.lock") \
669
+ if isinstance(candidate, str) else None
670
+ claude = ledger_path()
671
+ claude_anchor = _pi_canonical_path(claude)
672
+ claude_sessions = _pi_canonical_path(f"{claude}.sessions") \
673
+ if isinstance(claude, str) else None
674
+ claude_lock = _pi_canonical_path(f"{claude}.lock") \
675
+ if isinstance(claude, str) else None
676
+ if None in (
677
+ pi_anchor, pi_sessions, pi_lock,
678
+ claude_anchor, claude_sessions, claude_lock):
679
+ return None
680
+ if any(_pi_paths_overlap(pi_path, claude_path)
681
+ for pi_path in (pi_anchor, pi_sessions, pi_lock)
682
+ for claude_path in (claude_anchor, claude_sessions, claude_lock)):
683
+ return None
684
+ return pi_anchor
685
+
686
+
687
+ def _pi_timestamp_day(value):
688
+ if not isinstance(value, str) or not value or len(value) > PI_MAX_TIMESTAMP_CHARS:
689
+ return None
690
+ if value != value.strip() \
691
+ or any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value):
692
+ return None
693
+ source = value[:-1] + "+00:00" if value.endswith("Z") else value
694
+ try:
695
+ parsed = datetime.fromisoformat(source)
696
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
697
+ return None
698
+ local = parsed.astimezone()
699
+ except (OSError, OverflowError, ValueError):
700
+ return None
701
+ if not PI_MIN_YEAR <= parsed.year <= PI_MAX_YEAR \
702
+ or not PI_MIN_YEAR <= local.year <= PI_MAX_YEAR:
703
+ return None
704
+ return local.date().isoformat()
705
+
706
+
707
+ def _pi_token(value):
708
+ return type(value) is int and 0 <= value <= PI_MAX_TOKENS
709
+
710
+
711
+ def _pi_cost(value):
712
+ if type(value) is int:
713
+ return 0 <= value <= PI_MAX_COST_USD
714
+ return type(value) is float and math.isfinite(value) \
715
+ and 0 <= value <= PI_MAX_COST_USD
716
+
717
+
718
+ def _pi_totals_valid(value):
719
+ return isinstance(value, dict) and set(value) == PI_TOTAL_KEYS \
720
+ and all(_pi_token(value[key]) for key in PI_TOTAL_KEYS if key != "costUsd") \
721
+ and _pi_cost(value["costUsd"])
722
+
723
+
724
+ def _pi_day_valid(value):
725
+ if not isinstance(value, str) or PI_DAY_RE.fullmatch(value) is None:
726
+ return False
727
+ try:
728
+ parsed = datetime.strptime(value, "%Y-%m-%d")
729
+ except ValueError:
730
+ return False
731
+ return PI_MIN_YEAR <= parsed.year <= PI_MAX_YEAR
732
+
733
+
734
+ def _pi_updated_at(value):
735
+ return type(value) is int and 0 <= value <= PI_MAX_UPDATED_AT
736
+
737
+
738
+ def _pi_now():
739
+ try:
740
+ return max(0, min(PI_MAX_UPDATED_AT, int(time.time())))
741
+ except (OverflowError, TypeError, ValueError):
742
+ return 0
743
+
744
+
745
+ def _pi_normalize_chunk(session_key, scan_start, scan_end, facts):
746
+ """Validate the entire boundary before any lock or filesystem operation."""
747
+ if not isinstance(session_key, str) \
748
+ or PI_SESSION_KEY_RE.fullmatch(session_key) is None \
749
+ or type(scan_start) is not int or type(scan_end) is not int \
750
+ or not 0 <= scan_start <= scan_end <= PI_MAX_POSITION \
751
+ or scan_end - scan_start + 1 > PI_MAX_SCAN_ENTRIES \
752
+ or not isinstance(facts, list) or len(facts) > PI_MAX_SCAN_ENTRIES:
753
+ return None
754
+ normalized = []
755
+ previous = -1
756
+ for fact in facts:
757
+ if not isinstance(fact, dict) or set(fact) != PI_FACT_KEYS:
758
+ return None
759
+ position = fact["position"]
760
+ if type(position) is not int or not 0 <= position <= PI_MAX_POSITION \
761
+ or not scan_start <= position <= scan_end or position <= previous:
762
+ return None
763
+ day = _pi_timestamp_day(fact["timestamp"])
764
+ totals = {key: fact[key] for key in PI_TOTAL_KEYS}
765
+ if day is None or not _pi_totals_valid(totals):
766
+ return None
767
+ totals["costUsd"] = round(float(totals["costUsd"]), 9)
768
+ normalized.append((position, day, totals))
769
+ previous = position
770
+ return normalized
771
+
772
+
773
+ def _pi_add_totals(left, right):
774
+ if not _pi_totals_valid(left) or not _pi_totals_valid(right):
775
+ return None
776
+ output = {}
777
+ for key in PI_TOTAL_KEYS:
778
+ if key == "costUsd":
779
+ total = round(float(left[key]) + float(right[key]), 9)
780
+ if not _pi_cost(total):
781
+ return None
782
+ else:
783
+ total = left[key] + right[key]
784
+ if not _pi_token(total):
785
+ return None
786
+ output[key] = total
787
+ return output
788
+
789
+
790
+ def _pi_shard_valid(value, session_key):
791
+ if not isinstance(value, dict) or set(value) != {
792
+ "schema", "sessionKey", "highWater", "updatedAt", "totals", "days"}:
793
+ return False
794
+ high_water = value.get("highWater")
795
+ days = value.get("days")
796
+ return value.get("schema") == PI_SESSION_SCHEMA \
797
+ and value.get("sessionKey") == session_key \
798
+ and type(high_water) is int and -1 <= high_water <= PI_MAX_POSITION \
799
+ and _pi_updated_at(value.get("updatedAt")) \
800
+ and _pi_totals_valid(value.get("totals")) \
801
+ and isinstance(days, dict) and len(days) <= PI_MAX_DAYS \
802
+ and all(_pi_day_valid(day) and _pi_totals_valid(totals)
803
+ for day, totals in days.items())
804
+
805
+
806
+ def _pi_file_kind(path):
807
+ """Classify a path without following links or opening special objects."""
808
+ try:
809
+ info = os.lstat(path)
810
+ except FileNotFoundError:
811
+ return "missing"
812
+ except OSError:
813
+ return "error"
814
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x0400)
815
+ if not stat.S_ISREG(info.st_mode) \
816
+ or getattr(info, "st_file_attributes", 0) & reparse_flag:
817
+ return "special"
818
+ return "regular"
819
+
820
+
821
+ def _pi_read_shard(path, session_key):
822
+ kind = _pi_file_kind(path)
823
+ if kind == "missing":
824
+ return None, "missing"
825
+ if kind != "regular":
826
+ return None, "corrupt"
827
+ try:
828
+ with open(path, "rb") as stream:
829
+ raw = stream.read(PI_MAX_SHARD_BYTES + 1)
830
+ except OSError:
831
+ return None, "corrupt"
832
+ if len(raw) > PI_MAX_SHARD_BYTES:
833
+ return None, "corrupt"
834
+ try:
835
+ value = json.loads(raw.decode("utf-8", "strict"))
836
+ except (UnicodeDecodeError, ValueError, RecursionError):
837
+ return None, "corrupt"
838
+ if not _pi_shard_valid(value, session_key):
839
+ return None, "corrupt"
840
+ return value, "ok"
841
+
842
+
843
+ def _pi_apply_facts(shard, facts, scan_end):
844
+ """Return a new shard, or None if bounded cumulative state would overflow."""
845
+ updated = {
846
+ "schema": shard["schema"],
847
+ "sessionKey": shard["sessionKey"],
848
+ "highWater": shard["highWater"],
849
+ "updatedAt": shard["updatedAt"],
850
+ "totals": dict(shard["totals"]),
851
+ "days": {day: dict(totals) for day, totals in shard["days"].items()},
852
+ }
853
+ for position, day, totals in facts:
854
+ if position <= updated["highWater"]:
855
+ continue
856
+ session = _pi_add_totals(updated["totals"], totals)
857
+ day_totals = _pi_add_totals(updated["days"].get(day, pi_blank_totals()), totals)
858
+ if session is None or day_totals is None:
859
+ return None
860
+ updated["totals"] = session
861
+ updated["days"][day] = day_totals
862
+ while len(updated["days"]) > PI_MAX_DAYS:
863
+ del updated["days"][min(updated["days"])]
864
+ updated["highWater"] = scan_end
865
+ return updated
866
+
867
+
868
+ def _pi_collect_shards(path):
869
+ """Return valid regular Pi shards only; unrelated/temp entries are ignored."""
870
+ directory = f"{path}.sessions"
871
+ shards = []
872
+ corrupt = False
873
+ try:
874
+ with os.scandir(directory) as entries:
875
+ recognized = 0
876
+ for entry in entries:
877
+ match = PI_SHARD_NAME_RE.fullmatch(entry.name)
878
+ if match is None:
879
+ continue
880
+ recognized += 1
881
+ if recognized > PI_MAX_DIRECTORY_ENTRIES:
882
+ return [], True
883
+ kind = _pi_file_kind(entry.path)
884
+ if kind in {"missing", "special"}:
885
+ continue
886
+ if kind != "regular":
887
+ corrupt = True
888
+ continue
889
+ session_key = match.group(1)
890
+ shard, state = _pi_read_shard(entry.path, session_key)
891
+ if state != "ok":
892
+ corrupt = True
893
+ continue
894
+ shards.append((entry.path, shard))
895
+ except FileNotFoundError:
896
+ return [], False
897
+ except OSError:
898
+ return [], True
899
+ return shards, corrupt
900
+
901
+
902
+ def _pi_retain_shards(path, current_key):
903
+ """Prune valid shards deterministically, always retaining the current shard."""
904
+ shards, corrupt = _pi_collect_shards(path)
905
+ ranked = sorted(
906
+ shards,
907
+ key=lambda item: (
908
+ 0 if item[1]["sessionKey"] == current_key else 1,
909
+ -item[1]["updatedAt"],
910
+ item[1]["sessionKey"],
911
+ ),
912
+ )
913
+ write_failed = False
914
+ for shard_path, _shard in ranked[max(1, PI_MAX_SHARDS):]:
915
+ try:
916
+ os.remove(shard_path)
917
+ except OSError:
918
+ write_failed = True
919
+ retained, after_corrupt = _pi_collect_shards(path)
920
+ return retained, corrupt or after_corrupt, write_failed
921
+
922
+
923
+ def _pi_today_from_shards(path, today, current_key):
924
+ """Retain, reload, then sum today's authoritative fixed totals."""
925
+ shards, corrupt, write_failed = _pi_retain_shards(path, current_key)
926
+ total = pi_blank_totals()
927
+ for _shard_path, shard in shards:
928
+ day_totals = shard["days"].get(today)
929
+ if day_totals is None:
930
+ continue
931
+ added = _pi_add_totals(total, day_totals)
932
+ if added is None:
933
+ corrupt = True
934
+ continue
935
+ total = added
936
+ return total, corrupt, write_failed
937
+
938
+
939
+ def _write_pi_snapshot(path, today_date, today):
940
+ """Atomically refresh Pi's bounded root readability cache."""
941
+ try:
942
+ return _atomic_json(path, {
943
+ "schema": PI_LEDGER_SCHEMA, "date": today_date, "today": today,
944
+ })
945
+ except Exception: # noqa: BLE001 - cache failures have one fixed status
946
+ return False
947
+
948
+
949
+ def _pi_finish_update(path, shard, current_key, accepted_through):
950
+ """Prune, aggregate, cache, and shape one new-or-replayed result."""
951
+ today_date = datetime.now().astimezone().date().isoformat()
952
+ today, aggregate_corrupt, retain_failed = _pi_today_from_shards(
953
+ path, today_date, current_key
954
+ )
955
+ status = "write_failed" if retain_failed else (
956
+ "corrupt" if aggregate_corrupt else "ok"
957
+ )
958
+ if status == "ok" and not _write_pi_snapshot(path, today_date, today):
959
+ status = "write_failed"
960
+ return _pi_result(
961
+ status,
962
+ shard["totals"],
963
+ today,
964
+ shard["highWater"],
965
+ accepted_through if status == "ok" else -1,
966
+ )
967
+
968
+
969
+ def pi_ledger_update(session_key, scan_start, scan_end, facts, path=None):
970
+ """Add one bounded, sorted Pi usage-fact chunk and return session/day totals.
971
+
972
+ `scan_start..scan_end` acknowledges one contiguous bounded slice of the raw
973
+ append-only Pi session-entry array. Usage facts are sparse within that range
974
+ because non-assistant entries are omitted. A successful atomic shard replace
975
+ advances the cursor to `scan_end`, including for an empty fact list. No
976
+ message, path, command, environment, output, or raw session identity is
977
+ accepted or persisted.
978
+ """
979
+ normalized = _pi_normalize_chunk(session_key, scan_start, scan_end, facts)
980
+ if normalized is None:
981
+ return _pi_result("invalid")
982
+ path = _pi_resolve_path(path)
983
+ if path is None:
984
+ return _pi_result("invalid")
985
+ try:
986
+ lock = _acquire_lock(path)
987
+ except Exception: # noqa: BLE001 - lock/path failures are a fixed fail-soft status
988
+ return _pi_result("lock_failed")
989
+ if lock is None:
990
+ return _pi_result("lock_failed")
991
+ try:
992
+ return _pi_ledger_update_unlocked(
993
+ path, session_key, scan_start, scan_end, normalized
994
+ )
995
+ except Exception: # noqa: BLE001 - a footer usage snapshot is always fail-soft
996
+ return _pi_result("corrupt")
997
+ finally:
998
+ _release_lock(lock)
999
+
1000
+
1001
+ def _pi_ledger_update_unlocked(path, session_key, scan_start, scan_end, facts):
1002
+ shard_path = _pi_session_file(path, session_key)
1003
+ shard, state = _pi_read_shard(shard_path, session_key)
1004
+ if state == "corrupt":
1005
+ return _pi_result("corrupt")
1006
+ if state == "missing":
1007
+ shard = {
1008
+ "schema": PI_SESSION_SCHEMA,
1009
+ "sessionKey": session_key,
1010
+ "highWater": -1,
1011
+ "updatedAt": 0,
1012
+ "totals": pi_blank_totals(),
1013
+ "days": {},
1014
+ }
1015
+ if scan_end <= shard["highWater"]:
1016
+ return _pi_finish_update(path, shard, session_key, scan_end)
1017
+ if scan_start != shard["highWater"] + 1:
1018
+ return _pi_result("invalid")
1019
+ updated = _pi_apply_facts(shard, facts, scan_end)
1020
+ if updated is None:
1021
+ return _pi_result("invalid")
1022
+ updated["updatedAt"] = _pi_now()
1023
+ if state == "missing" or updated != shard:
1024
+ if not _atomic_json(shard_path, updated):
1025
+ return _pi_result("write_failed")
1026
+ return _pi_finish_update(path, updated, session_key, scan_end)
1027
+
1028
+
1029
+ def burn_samples(rec):
1030
+ """Recent per-message token-burn values (most-recent window) for the sparkline —
1031
+ real per-API-call totals accumulated from the transcript, not a time-extrapolated
1032
+ estimate. Returns [] when there is too little data to draw a line. The statusline
1033
+ turns this list into a colored sparkline; this lib stays render-free."""
1034
+ b = [num(x) for x in (rec.get("burn") or []) if isinstance(x, (int, float))]
1035
+ return b[-24:] if len(b) >= 2 else []