cctally 1.80.4 → 1.82.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 (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +56 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_pricing.py +27 -3
  41. package/bin/_lib_quota.py +81 -4
  42. package/bin/_lib_render.py +19 -5
  43. package/bin/_lib_share.py +25 -0
  44. package/bin/_lib_snapshot_cache.py +8 -0
  45. package/bin/_lib_subscription_weeks.py +16 -2
  46. package/bin/_lib_view_models.py +18 -9
  47. package/bin/cctally +43 -4
  48. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  49. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +4 -1
  52. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  53. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -78,7 +78,7 @@ def _init_paths_from_env() -> None:
78
78
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
79
79
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
80
80
  global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
81
- global CLAUDE_PROJECTS_DIR
81
+ global CLAUDE_PROJECTS_DIR, CLAUDE_JSON_PATH
82
82
  global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
83
83
  global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
84
84
 
@@ -196,6 +196,14 @@ def _init_paths_from_env() -> None:
196
196
 
197
197
  CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
198
198
 
199
+ # Claude's own identity file (#341): `~/.claude.json` carries the
200
+ # `oauthAccount` block (accountUuid / emailAddress / plan) maintained by
201
+ # Claude Code. cctally reads it READ-ONLY for account attribution; never
202
+ # written. Module constant so tests can `monkeypatch.setattr(_cctally_core,
203
+ # "CLAUDE_JSON_PATH", ...)`. Rewritten in place by Claude Code, so every read
204
+ # goes through the stat-read-stat stable-read protocol.
205
+ CLAUDE_JSON_PATH = home / ".claude.json"
206
+
199
207
  # Claude session JSONL root. Production path is `~/.claude/projects`;
200
208
  # exposed as a module-level constant so cross-DB migrations (e.g.
201
209
  # stats migration 008) and the dispatcher's empty-disk fallback can
@@ -323,7 +331,14 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
323
331
  # that ``open_db`` cuts over to the epoch on first open (spec §8). Schema change
324
332
  # = bump ``STATS_INDEX_EPOCH`` (never a new stats migration — the registry is
325
333
  # frozen).
326
- STATS_INDEX_EPOCH = 1000
334
+ #
335
+ # 1000 -> 1001 (#341, multi-account): the stats index gains the account
336
+ # dimension (account_key on every derived table + the accounts registry). An
337
+ # existing epoch-1000 index resolves the mismatch through the account
338
+ # epoch-transition coordinator (``_cctally_journal.run_epoch_transition``):
339
+ # resolve the cutover identity, append the canonical cutover op, then rebuild
340
+ # account-scoped. See docs/superpowers/specs/2026-07-23-multi-account-design.md §2.
341
+ STATS_INDEX_EPOCH = 1001
327
342
  LEGACY_STATS_HEAD = 13
328
343
 
329
344
 
@@ -817,7 +832,7 @@ BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
817
832
  CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
818
833
  CODEX_BUDGET_LEAVES = (
819
834
  "amount_usd", "period", "alerts_enabled", "alert_thresholds",
820
- "projected_enabled",
835
+ "projected_enabled", "accounts",
821
836
  )
822
837
  _BUDGET_DEFAULTS = {
823
838
  "weekly_usd": None, # None = no budget (default)
@@ -827,6 +842,7 @@ _BUDGET_DEFAULTS = {
827
842
  "period": "subscription-week", # Claude period; default = existing behavior
828
843
  "projects": {}, # per-project weekly $ budgets, keyed by git-root
829
844
  "project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
845
+ "accounts": {}, # per-account weekly $ budgets, keyed by account_key (#341)
830
846
  "codex": None, # None = no Codex budget (nested block when set)
831
847
  }
832
848
  _BUDGET_CONFIG_VALID_KEYS = {
@@ -837,10 +853,41 @@ _BUDGET_CONFIG_VALID_KEYS = {
837
853
  "period",
838
854
  "projects",
839
855
  "project_alerts_enabled",
856
+ "accounts",
840
857
  "codex",
841
858
  }
842
859
 
843
860
 
861
+ def _validate_account_budget_map(v: object, label: str) -> "dict[str, float]":
862
+ """Validate a per-account ``{account_key: usd}`` budget map (#341, spec §6).
863
+
864
+ Mirrors the ``budget.projects`` value rule: keys are strings (immutable
865
+ account keys — refs are normalized to keys at ``config set`` write time),
866
+ each value a non-bool finite number > 0. Returns a cleaned copy."""
867
+ if not isinstance(v, dict):
868
+ raise _BudgetConfigError(
869
+ f"{label} must be an object, got {type(v).__name__}"
870
+ )
871
+ cleaned: "dict[str, float]" = {}
872
+ for acc_key, acc_val in v.items():
873
+ if not isinstance(acc_key, str) or not acc_key:
874
+ raise _BudgetConfigError(
875
+ f"{label} keys must be non-empty strings (account keys)"
876
+ )
877
+ if isinstance(acc_val, bool) or not isinstance(acc_val, (int, float)):
878
+ raise _BudgetConfigError(
879
+ f"{label} values must be numbers, "
880
+ f"got {type(acc_val).__name__} for key {acc_key!r}"
881
+ )
882
+ if not math.isfinite(float(acc_val)) or float(acc_val) <= 0:
883
+ raise _BudgetConfigError(
884
+ f"{label} values must be finite numbers > 0, "
885
+ f"got {acc_val!r} for key {acc_key!r}"
886
+ )
887
+ cleaned[acc_key] = float(acc_val)
888
+ return cleaned
889
+
890
+
844
891
  def _get_budget_config(cfg: dict) -> dict:
845
892
  """Return the validated, defaults-filled budget block.
846
893
 
@@ -938,6 +985,11 @@ def _get_budget_config(cfg: dict) -> dict:
938
985
  )
939
986
  out["project_alerts_enabled"] = v
940
987
 
988
+ if "accounts" in block:
989
+ out["accounts"] = _validate_account_budget_map(
990
+ block["accounts"], "budget.accounts"
991
+ )
992
+
941
993
  if "codex" in block:
942
994
  out["codex"] = _validate_codex_budget_block(block["codex"])
943
995
 
@@ -994,15 +1046,23 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
994
1046
  "alert_thresholds": [90, 100],
995
1047
  "projected_enabled": False,
996
1048
  }
997
- # amount_usd — required (a Codex block must define a budget) finite > 0.
998
- # Shares the positive-amount rule with weekly_usd / projects via the helper;
999
- # the message form ("must be a number" / "must be a finite number > 0") is
1000
- # byte-identical to the prior inline checks (code-review #5).
1001
- if "amount_usd" not in v:
1002
- raise _BudgetConfigError("budget.codex.amount_usd is required")
1003
- out["amount_usd"] = _validate_positive_budget_amount(
1004
- v["amount_usd"], "budget.codex.amount_usd"
1049
+ # amount_usd — the vendor-wide Codex budget, finite > 0. Required UNLESS a
1050
+ # non-empty per-account map (`accounts`, #341) is present: a Codex block may
1051
+ # be per-account-only (no vendor-wide amount), in which case amount_usd stays
1052
+ # None. Shares the positive-amount rule with weekly_usd / projects.
1053
+ _codex_accounts_raw = v.get("accounts")
1054
+ _has_codex_accounts = (
1055
+ isinstance(_codex_accounts_raw, dict) and len(_codex_accounts_raw) > 0
1005
1056
  )
1057
+ if "amount_usd" in v and v["amount_usd"] is not None:
1058
+ out["amount_usd"] = _validate_positive_budget_amount(
1059
+ v["amount_usd"], "budget.codex.amount_usd"
1060
+ )
1061
+ elif not _has_codex_accounts:
1062
+ raise _BudgetConfigError(
1063
+ "budget.codex.amount_usd is required (or set a per-account "
1064
+ "budget.codex.accounts map)"
1065
+ )
1006
1066
 
1007
1067
  if "period" in v:
1008
1068
  p = v["period"]
@@ -1035,6 +1095,13 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
1035
1095
  )
1036
1096
  out["projected_enabled"] = pe
1037
1097
 
1098
+ # Per-account Codex budgets (#341, spec §6). Conditionally present so an
1099
+ # existing codex block (no accounts) validates byte-identically.
1100
+ if "accounts" in v:
1101
+ out["accounts"] = _validate_account_budget_map(
1102
+ v["accounts"], "budget.codex.accounts"
1103
+ )
1104
+
1038
1105
  return out
1039
1106
 
1040
1107
 
@@ -1056,6 +1123,11 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1056
1123
  013 calls this same helper for old databases while ``open_db`` calls it for
1057
1124
  fresh installs before the migration dispatcher stamps the migration.
1058
1125
  """
1126
+ # account_key (#341 Task 2): every quota identity is (source_root_key,
1127
+ # account_key)-qualified — the UNIQUEs/PK are extended so two accounts on one
1128
+ # physical window never merge, and each carries `DEFAULT 'unattributed'` so a
1129
+ # NULL/single-account cache renders byte-identically (spec §2). No new epoch
1130
+ # bump: this rides Task 1's STATS_INDEX_EPOCH 1000->1001 rebuild.
1059
1131
  conn.executescript(
1060
1132
  """
1061
1133
  CREATE TABLE IF NOT EXISTS quota_window_blocks (
@@ -1077,11 +1149,12 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1077
1149
  last_line_offset INTEGER NOT NULL,
1078
1150
  generation TEXT NOT NULL,
1079
1151
  orphaned_at TEXT,
1080
- UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1081
- window_minutes, resets_at_utc)
1152
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1153
+ UNIQUE(source, source_root_key, account_key, logical_limit_key,
1154
+ observed_slot, window_minutes, resets_at_utc)
1082
1155
  );
1083
1156
  CREATE INDEX IF NOT EXISTS idx_quota_blocks_active
1084
- ON quota_window_blocks(source, source_root_key, orphaned_at,
1157
+ ON quota_window_blocks(source, source_root_key, account_key, orphaned_at,
1085
1158
  logical_limit_key, observed_slot,
1086
1159
  window_minutes, resets_at_utc);
1087
1160
 
@@ -1100,11 +1173,12 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1100
1173
  high_water_percent INTEGER NOT NULL CHECK(high_water_percent BETWEEN 1 AND 100),
1101
1174
  generation TEXT NOT NULL,
1102
1175
  orphaned_at TEXT,
1103
- UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1104
- window_minutes, resets_at_utc, percent_threshold)
1176
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1177
+ UNIQUE(source, source_root_key, account_key, logical_limit_key,
1178
+ observed_slot, window_minutes, resets_at_utc, percent_threshold)
1105
1179
  );
1106
1180
  CREATE INDEX IF NOT EXISTS idx_quota_milestones_active
1107
- ON quota_percent_milestones(source, source_root_key, orphaned_at,
1181
+ ON quota_percent_milestones(source, source_root_key, account_key, orphaned_at,
1108
1182
  logical_limit_key, observed_slot,
1109
1183
  window_minutes, resets_at_utc,
1110
1184
  percent_threshold);
@@ -1127,21 +1201,24 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1127
1201
  alerted_at TEXT,
1128
1202
  suppressed_at TEXT,
1129
1203
  orphaned_at TEXT,
1204
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1130
1205
  CHECK((disposition = 'alerted' AND alerted_at IS NOT NULL AND suppressed_at IS NULL)
1131
1206
  OR (disposition = 'suppressed_backfill' AND suppressed_at IS NOT NULL AND alerted_at IS NULL)),
1132
- UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1133
- window_minutes, resets_at_utc, threshold)
1207
+ UNIQUE(source, source_root_key, account_key, logical_limit_key,
1208
+ observed_slot, window_minutes, resets_at_utc, threshold)
1134
1209
  );
1135
1210
  CREATE INDEX IF NOT EXISTS idx_quota_threshold_events_active
1136
- ON quota_threshold_events(source, source_root_key, orphaned_at,
1211
+ ON quota_threshold_events(source, source_root_key, account_key, orphaned_at,
1137
1212
  logical_limit_key, observed_slot,
1138
1213
  window_minutes, resets_at_utc, threshold);
1139
1214
 
1140
1215
  CREATE TABLE IF NOT EXISTS quota_projection_state (
1141
- source_root_key TEXT PRIMARY KEY,
1216
+ source_root_key TEXT NOT NULL,
1217
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1142
1218
  generation TEXT NOT NULL,
1143
1219
  physical_signature TEXT NOT NULL,
1144
- completed_at_utc TEXT NOT NULL
1220
+ completed_at_utc TEXT NOT NULL,
1221
+ PRIMARY KEY(source_root_key, account_key)
1145
1222
  );
1146
1223
 
1147
1224
  CREATE TABLE IF NOT EXISTS quota_alert_arming (
@@ -1153,11 +1230,39 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1153
1230
  window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
1154
1231
  rule_fingerprint TEXT NOT NULL,
1155
1232
  activated_at_utc TEXT NOT NULL,
1156
- UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
1157
- window_minutes)
1233
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1234
+ UNIQUE(source, source_root_key, account_key, logical_limit_key,
1235
+ observed_slot, window_minutes)
1158
1236
  );
1159
1237
  """
1160
1238
  )
1239
+ # account_key backstops (#341): idempotent no-ops on the fresh CREATEs above
1240
+ # (which already carry the column), but they keep an already-1001 stats.db
1241
+ # that predates the column consistent. quota_projection_state widened its
1242
+ # PRIMARY KEY to (source_root_key, account_key) — a PK cannot be added by
1243
+ # ALTER, so a pre-#341-quota shape (disposable, re-derivable coherence index)
1244
+ # is DROP+recreated rather than back-filled.
1245
+ _c = sys.modules.get("cctally")
1246
+ add_column_if_missing = _c.add_column_if_missing if _c is not None else None
1247
+ _proj_cols = {
1248
+ str(row[1]) for row in conn.execute("PRAGMA table_info(quota_projection_state)")
1249
+ }
1250
+ if "account_key" not in _proj_cols:
1251
+ conn.execute("DROP TABLE quota_projection_state")
1252
+ conn.execute(
1253
+ "CREATE TABLE quota_projection_state ("
1254
+ " source_root_key TEXT NOT NULL,"
1255
+ " account_key TEXT NOT NULL DEFAULT 'unattributed',"
1256
+ " generation TEXT NOT NULL,"
1257
+ " physical_signature TEXT NOT NULL,"
1258
+ " completed_at_utc TEXT NOT NULL,"
1259
+ " PRIMARY KEY(source_root_key, account_key))"
1260
+ )
1261
+ if add_column_if_missing is not None:
1262
+ for _tbl in ("quota_window_blocks", "quota_percent_milestones",
1263
+ "quota_threshold_events", "quota_alert_arming"):
1264
+ add_column_if_missing(
1265
+ conn, _tbl, "account_key", "TEXT NOT NULL DEFAULT 'unattributed'")
1161
1266
 
1162
1267
 
1163
1268
  def open_db(*, _target_path=None) -> sqlite3.Connection:
@@ -1329,7 +1434,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1329
1434
  weekly_percent REAL NOT NULL,
1330
1435
  page_url TEXT,
1331
1436
  source TEXT NOT NULL DEFAULT 'userscript',
1332
- payload_json TEXT NOT NULL
1437
+ payload_json TEXT NOT NULL,
1438
+ account_key TEXT NOT NULL DEFAULT 'unattributed'
1333
1439
  )
1334
1440
  """
1335
1441
  )
@@ -1353,7 +1459,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1353
1459
  cost_usd REAL NOT NULL,
1354
1460
  source TEXT NOT NULL DEFAULT 'cctally-range-cost',
1355
1461
  mode TEXT NOT NULL DEFAULT 'auto',
1356
- project TEXT
1462
+ project TEXT,
1463
+ account_key TEXT NOT NULL DEFAULT 'unattributed'
1357
1464
  )
1358
1465
  """
1359
1466
  )
@@ -1366,6 +1473,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1366
1473
 
1367
1474
  add_column_if_missing(conn, "weekly_usage_snapshots", "week_start_at", "TEXT")
1368
1475
  add_column_if_missing(conn, "weekly_usage_snapshots", "week_end_at", "TEXT")
1476
+ # account_key (#341): the account dimension rides the STATS_INDEX_EPOCH bump
1477
+ # (a fresh rebuild carries it via the CREATE TABLE above); this backstop keeps
1478
+ # an already-1001 DB that predates the column consistent. DEFAULT
1479
+ # 'unattributed' — every production writer passes the key explicitly (rev 4.1
1480
+ # defensive-backstop rule), enforced by the structural writer-audit test.
1481
+ add_column_if_missing(
1482
+ conn, "weekly_usage_snapshots", "account_key",
1483
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1369
1484
  add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_percent", "REAL")
1370
1485
  add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_resets_at", "TEXT")
1371
1486
  # five_hour_window_key — canonical (10-min-floored epoch) key for
@@ -1425,6 +1540,9 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1425
1540
  add_column_if_missing(conn, "weekly_cost_snapshots", "week_end_at", "TEXT")
1426
1541
  add_column_if_missing(conn, "weekly_cost_snapshots", "range_start_iso", "TEXT")
1427
1542
  add_column_if_missing(conn, "weekly_cost_snapshots", "range_end_iso", "TEXT")
1543
+ add_column_if_missing(
1544
+ conn, "weekly_cost_snapshots", "account_key",
1545
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1428
1546
 
1429
1547
  conn.execute(
1430
1548
  """
@@ -1454,12 +1572,16 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1454
1572
  usage_snapshot_id INTEGER NOT NULL,
1455
1573
  cost_snapshot_id INTEGER NOT NULL,
1456
1574
  reset_event_id INTEGER NOT NULL DEFAULT 0,
1457
- UNIQUE(week_start_date, percent_threshold, reset_event_id)
1575
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1576
+ UNIQUE(account_key, week_start_date, percent_threshold, reset_event_id)
1458
1577
  )
1459
1578
  """
1460
1579
  )
1461
1580
 
1462
1581
  add_column_if_missing(conn, "percent_milestones", "five_hour_percent_at_crossing", "REAL")
1582
+ add_column_if_missing(
1583
+ conn, "percent_milestones", "account_key",
1584
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1463
1585
  # reset_event_id: segment column added by migration 005. Fresh-install
1464
1586
  # DBs get it via the live CREATE TABLE above + the dispatcher
1465
1587
  # fast-stamps the migration. Existing pre-005 DBs trip the migration's
@@ -1495,10 +1617,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1495
1617
  new_week_end_at TEXT NOT NULL,
1496
1618
  effective_reset_at_utc TEXT NOT NULL,
1497
1619
  observed_pre_credit_pct REAL,
1498
- UNIQUE(old_week_end_at, new_week_end_at)
1620
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1621
+ UNIQUE(account_key, old_week_end_at, new_week_end_at)
1499
1622
  )
1500
1623
  """
1501
1624
  )
1625
+ add_column_if_missing(
1626
+ conn, "week_reset_events", "account_key",
1627
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1502
1628
  _backfill_week_reset_events(conn)
1503
1629
 
1504
1630
  # ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
@@ -1533,17 +1659,21 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1533
1659
  prior_percent REAL NOT NULL,
1534
1660
  post_percent REAL NOT NULL,
1535
1661
  effective_reset_at_utc TEXT NOT NULL,
1536
- UNIQUE(five_hour_window_key, effective_reset_at_utc)
1662
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1663
+ UNIQUE(account_key, five_hour_window_key, effective_reset_at_utc)
1537
1664
  )
1538
1665
  """
1539
1666
  )
1667
+ add_column_if_missing(
1668
+ conn, "five_hour_reset_events", "account_key",
1669
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1540
1670
 
1541
1671
  # ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
1542
1672
  conn.execute(
1543
1673
  """
1544
1674
  CREATE TABLE IF NOT EXISTS five_hour_blocks (
1545
1675
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1546
- five_hour_window_key INTEGER NOT NULL UNIQUE,
1676
+ five_hour_window_key INTEGER NOT NULL,
1547
1677
  five_hour_resets_at TEXT NOT NULL,
1548
1678
  block_start_at TEXT NOT NULL,
1549
1679
  first_observed_at_utc TEXT NOT NULL,
@@ -1559,10 +1689,15 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1559
1689
  total_cost_usd REAL NOT NULL DEFAULT 0,
1560
1690
  is_closed INTEGER NOT NULL DEFAULT 0,
1561
1691
  created_at_utc TEXT NOT NULL,
1562
- last_updated_at_utc TEXT NOT NULL
1692
+ last_updated_at_utc TEXT NOT NULL,
1693
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1694
+ UNIQUE(account_key, five_hour_window_key)
1563
1695
  )
1564
1696
  """
1565
1697
  )
1698
+ add_column_if_missing(
1699
+ conn, "five_hour_blocks", "account_key",
1700
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1566
1701
  conn.execute(
1567
1702
  """
1568
1703
  CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
@@ -1588,7 +1723,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1588
1723
  marginal_cost_usd REAL,
1589
1724
  seven_day_pct_at_crossing REAL,
1590
1725
  reset_event_id INTEGER NOT NULL DEFAULT 0,
1591
- UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
1726
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1727
+ UNIQUE(account_key, five_hour_window_key, percent_threshold, reset_event_id),
1592
1728
  FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
1593
1729
  )
1594
1730
  """
@@ -1607,6 +1743,9 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1607
1743
  # "alerts disabled at moment of crossing OR threshold not configured"
1608
1744
  # — never "delivery failed".
1609
1745
  add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
1746
+ add_column_if_missing(
1747
+ conn, "five_hour_milestones", "account_key",
1748
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1610
1749
 
1611
1750
  # reset_event_id: segment column added by migration 006. Fresh-install
1612
1751
  # DBs get it via the live CREATE TABLE above + the dispatcher fast-stamps
@@ -1636,11 +1775,15 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1636
1775
  cache_read_tokens INTEGER NOT NULL DEFAULT 0,
1637
1776
  cost_usd REAL NOT NULL DEFAULT 0,
1638
1777
  entry_count INTEGER NOT NULL DEFAULT 0,
1639
- UNIQUE(five_hour_window_key, model),
1778
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1779
+ UNIQUE(account_key, five_hour_window_key, model),
1640
1780
  FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
1641
1781
  )
1642
1782
  """
1643
1783
  )
1784
+ add_column_if_missing(
1785
+ conn, "five_hour_block_models", "account_key",
1786
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1644
1787
  conn.execute(
1645
1788
  """
1646
1789
  CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_block
@@ -1670,11 +1813,15 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1670
1813
  cache_read_tokens INTEGER NOT NULL DEFAULT 0,
1671
1814
  cost_usd REAL NOT NULL DEFAULT 0,
1672
1815
  entry_count INTEGER NOT NULL DEFAULT 0,
1673
- UNIQUE(five_hour_window_key, project_path),
1816
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1817
+ UNIQUE(account_key, five_hour_window_key, project_path),
1674
1818
  FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
1675
1819
  )
1676
1820
  """
1677
1821
  )
1822
+ add_column_if_missing(
1823
+ conn, "five_hour_block_projects", "account_key",
1824
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1678
1825
  conn.execute(
1679
1826
  """
1680
1827
  CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_block
@@ -1726,10 +1873,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1726
1873
  consumption_pct REAL NOT NULL,
1727
1874
  crossed_at_utc TEXT NOT NULL,
1728
1875
  alerted_at TEXT,
1729
- UNIQUE(vendor, period_start_at, period, threshold)
1876
+ account_key TEXT NOT NULL DEFAULT '*',
1877
+ UNIQUE(vendor, account_key, period_start_at, period, threshold)
1730
1878
  )
1731
1879
  """
1732
1880
  )
1881
+ add_column_if_missing(
1882
+ conn, "budget_milestones", "account_key", "TEXT NOT NULL DEFAULT '*'")
1733
1883
 
1734
1884
  # ── projected_milestones (week-average-pace projection crossings — #121) ──
1735
1885
  # Write-once, forward-only — same posture as `budget_milestones` (no
@@ -1758,10 +1908,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1758
1908
  denominator REAL NOT NULL, -- target_usd (budget / codex_budget) | 100.0 (weekly)
1759
1909
  crossed_at_utc TEXT NOT NULL,
1760
1910
  alerted_at TEXT,
1761
- UNIQUE(week_start_at, period, metric, threshold)
1911
+ account_key TEXT NOT NULL DEFAULT '*', -- '*' for vendor-budget metrics; real account for weekly_pct (Task 3)
1912
+ UNIQUE(account_key, week_start_at, period, metric, threshold)
1762
1913
  )
1763
1914
  """
1764
1915
  )
1916
+ add_column_if_missing(
1917
+ conn, "projected_milestones", "account_key", "TEXT NOT NULL DEFAULT '*'")
1765
1918
 
1766
1919
  # ── project_budget_milestones (per-project equiv-$ budget crossings) ──────
1767
1920
  # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
@@ -1794,10 +1947,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1794
1947
  consumption_pct REAL NOT NULL,
1795
1948
  crossed_at_utc TEXT NOT NULL,
1796
1949
  alerted_at TEXT,
1797
- UNIQUE(week_start_at, project_key, threshold)
1950
+ account_key TEXT NOT NULL DEFAULT '*', -- account-blind this epic (spec §6): always '*'
1951
+ UNIQUE(account_key, week_start_at, project_key, threshold)
1798
1952
  )
1799
1953
  """
1800
1954
  )
1955
+ add_column_if_missing(
1956
+ conn, "project_budget_milestones", "account_key",
1957
+ "TEXT NOT NULL DEFAULT '*'")
1801
1958
 
1802
1959
  # In-place weekly partial-credit floor (issue #209, record-credit M2).
1803
1960
  # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / NO user_version
@@ -1822,7 +1979,39 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1822
1979
  effective_at_utc TEXT NOT NULL,
1823
1980
  observed_pre_credit_pct REAL NOT NULL,
1824
1981
  applied_at_utc TEXT NOT NULL,
1825
- UNIQUE(week_start_date, effective_at_utc)
1982
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
1983
+ UNIQUE(account_key, week_start_date, effective_at_utc)
1984
+ )
1985
+ """
1986
+ )
1987
+ add_column_if_missing(
1988
+ conn, "weekly_credit_floors", "account_key",
1989
+ "TEXT NOT NULL DEFAULT 'unattributed'")
1990
+
1991
+ # ── accounts registry (multi-account epic #341, spec §1/§2) ───────────────
1992
+ # Derived from the journal like all stats.db state: `account_observe` op
1993
+ # lines fold into rows here (via _apply_op_account_observe), `account_label`
1994
+ # ops set the user label. Framework-untracked (plain CREATE TABLE IF NOT
1995
+ # EXISTS, no migration / no user_version bump — same posture as
1996
+ # weekly_credit_floors above), so it never touches `schema_migrations` and
1997
+ # the stats-schema change rides the STATS_INDEX_EPOCH bump + rebuild, not a
1998
+ # new stats migration (the frozen-registry rule). `last_seen_utc` is derived
1999
+ # at fold time from the max `at` of any account-stamped line, NOT carried by
2000
+ # the observe record. `label_source` records provenance for the
2001
+ # user > switcher > auto precedence rule. A new empty table is byte-invisible
2002
+ # to every existing render, preserving the R8 byte-stability contract.
2003
+ conn.execute(
2004
+ """
2005
+ CREATE TABLE IF NOT EXISTS accounts (
2006
+ account_key TEXT PRIMARY KEY,
2007
+ provider TEXT NOT NULL,
2008
+ natural_id TEXT,
2009
+ email TEXT,
2010
+ label TEXT,
2011
+ plan_type TEXT,
2012
+ label_source TEXT NOT NULL DEFAULT 'auto',
2013
+ first_seen_utc TEXT,
2014
+ last_seen_utc TEXT
1826
2015
  )
1827
2016
  """
1828
2017
  )
@@ -2056,28 +2245,38 @@ def _get_latest_row_for_week(
2056
2245
  table_name: str,
2057
2246
  week_ref: WeekRef,
2058
2247
  as_of_utc: str | None = None,
2248
+ *,
2249
+ account_key: str | None = None,
2059
2250
  ) -> sqlite3.Row | None:
2251
+ """Latest week row from a weekly snapshot table.
2252
+
2253
+ ``account_key`` (#341): ``None`` = the account-blind merged read (today's
2254
+ behavior, byte-stable); a real key scopes to that account's rows — the
2255
+ ``--account`` render consumers and the write-path milestone-cost read (P2-1)
2256
+ pass it explicitly."""
2257
+ acct_pred = "" if account_key is None else " AND account_key = ?"
2258
+ acct_params: tuple = () if account_key is None else (account_key,)
2060
2259
  if as_of_utc is None:
2061
2260
  return conn.execute(
2062
2261
  f"""
2063
2262
  SELECT *
2064
2263
  FROM {table_name}
2065
- WHERE week_start_date = ?
2264
+ WHERE week_start_date = ?{acct_pred}
2066
2265
  ORDER BY captured_at_utc DESC, id DESC
2067
2266
  LIMIT 1
2068
2267
  """,
2069
- (week_ref.week_start.isoformat(),),
2268
+ (week_ref.week_start.isoformat(),) + acct_params,
2070
2269
  ).fetchone()
2071
2270
  return conn.execute(
2072
2271
  f"""
2073
2272
  SELECT *
2074
2273
  FROM {table_name}
2075
2274
  WHERE week_start_date = ?
2076
- AND captured_at_utc <= ?
2275
+ AND captured_at_utc <= ?{acct_pred}
2077
2276
  ORDER BY captured_at_utc DESC, id DESC
2078
2277
  LIMIT 1
2079
2278
  """,
2080
- (week_ref.week_start.isoformat(), as_of_utc),
2279
+ (week_ref.week_start.isoformat(), as_of_utc) + acct_params,
2081
2280
  ).fetchone()
2082
2281
 
2083
2282
 
@@ -2086,11 +2285,19 @@ def _reset_aware_floor(
2086
2285
  week_start_date: str,
2087
2286
  week_start_at: str,
2088
2287
  week_end_at: str,
2288
+ *,
2289
+ account_key: str | None,
2089
2290
  ) -> str | None:
2090
2291
  """Return the latest in-week clamp floor (an ISO timestamp) across BOTH
2091
2292
  `week_reset_events` and `weekly_credit_floors`, or None when neither has a
2092
2293
  row for this week.
2093
2294
 
2295
+ ``account_key`` (#341, review finding 11): MANDATORY account context — no
2296
+ silent global fallback. A real key scopes both legs to that account's
2297
+ reset/credit rows (so one account's mid-week credit never clamps another's
2298
+ week); ``None`` is the explicit "all accounts" (merged) read used by the
2299
+ analytics floor path, byte-identical to today on a single-account install.
2300
+
2094
2301
  This is the single chokepoint the four MAX-clamp sites consult to floor the
2095
2302
  current 7d % to the most-recent in-place credit / reset effective moment
2096
2303
  (record-credit M2, issue #209, spec §4a):
@@ -2109,27 +2316,32 @@ def _reset_aware_floor(
2109
2316
  spellings (`Z` / `+00:00`), and a lexical MAX would silently mis-order them
2110
2317
  on a non-UTC host (the same gotcha as the statusline clamp / 5h-block
2111
2318
  cross-reset flag; see the comment at bin/_cctally_statusline.py)."""
2319
+ acct_pred = "" if account_key is None else " AND account_key = ?"
2320
+ reset_params: tuple = (week_start_at, week_end_at) + (
2321
+ () if account_key is None else (account_key,))
2322
+ floor_params: tuple = (week_start_date,) + (
2323
+ () if account_key is None else (account_key,))
2112
2324
  row = conn.execute(
2113
- """
2325
+ f"""
2114
2326
  SELECT floor_at FROM (
2115
2327
  SELECT effective_reset_at_utc AS floor_at
2116
2328
  FROM week_reset_events
2117
2329
  WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
2118
- AND unixepoch(effective_reset_at_utc) < unixepoch(?)
2330
+ AND unixepoch(effective_reset_at_utc) < unixepoch(?){acct_pred}
2119
2331
  UNION ALL
2120
2332
  SELECT effective_at_utc AS floor_at
2121
2333
  FROM weekly_credit_floors
2122
- WHERE week_start_date = ?
2334
+ WHERE week_start_date = ?{acct_pred}
2123
2335
  )
2124
2336
  ORDER BY unixepoch(floor_at) DESC
2125
2337
  LIMIT 1
2126
2338
  """,
2127
- (week_start_at, week_end_at, week_start_date),
2339
+ reset_params + floor_params,
2128
2340
  ).fetchone()
2129
2341
  return row[0] if row and row[0] else None
2130
2342
 
2131
2343
 
2132
- def _floored_week_max(conn, rows):
2344
+ def _floored_week_max(conn, rows, *, account_key=None):
2133
2345
  """Return {week_key -> per-week reset-aware-floored maximum weekly_percent}.
2134
2346
 
2135
2347
  ``rows`` is an iterable of
@@ -2174,7 +2386,11 @@ def _floored_week_max(conn, rows):
2174
2386
  # Pass 2: resolve floor once per week, drop pre-floor captures, take max.
2175
2387
  result: dict = {}
2176
2388
  for wk, b in buckets.items():
2177
- floor_iso = _reset_aware_floor(conn, b["wsd"], b["ws_at"], b["we_at"])
2389
+ # Analytics floor path: ``account_key=None`` merges across accounts
2390
+ # byte-identical to today on a single-account install (#341); a real key
2391
+ # scopes the floor to that account (the ``forecast --account`` dpp read).
2392
+ floor_iso = _reset_aware_floor(
2393
+ conn, b["wsd"], b["ws_at"], b["we_at"], account_key=account_key)
2178
2394
  floor_epoch = None
2179
2395
  if floor_iso:
2180
2396
  try:
@@ -2205,11 +2421,90 @@ def _floored_week_max(conn, rows):
2205
2421
  return result
2206
2422
 
2207
2423
 
2424
+ # --------------------------------------------------------------------------
2425
+ # Active Claude account resolution (#341, spec §1 observe-and-stamp) — the
2426
+ # identity source for record-usage / statusline / hook-tick obs stamping. A
2427
+ # stat-read-stat over ``~/.claude.json`` (rewritten in place by Claude Code),
2428
+ # mtime-cached so a hot status-line loop reads it at most once per file change.
2429
+ # --------------------------------------------------------------------------
2430
+
2431
+ _ACTIVE_CLAUDE_ACCOUNT_CACHE: dict = {"sig": None, "identity": None}
2432
+
2433
+
2434
+ def _resolve_active_claude_identity() -> dict:
2435
+ """Resolve the active Claude identity from ``~/.claude.json`` via the
2436
+ stable-read protocol, mtime-cached. Returns a dict
2437
+ ``{account_key, natural_id, email, plan_type}`` — ``account_key`` is the
2438
+ reserved ``unattributed`` sentinel and the rest ``None`` when stably-absent /
2439
+ api-key mode / torn mid-write. Never raises; never invents a guess."""
2440
+ import json as _json
2441
+ import _lib_accounts
2442
+
2443
+ path = str(CLAUDE_JSON_PATH)
2444
+ try:
2445
+ st = os.stat(path)
2446
+ sig = (st.st_ino, st.st_size, st.st_mtime_ns)
2447
+ except OSError:
2448
+ sig = None
2449
+ cache = _ACTIVE_CLAUDE_ACCOUNT_CACHE
2450
+ if sig is not None and cache.get("sig") == sig:
2451
+ return cache["identity"]
2452
+
2453
+ def _reader(data: bytes):
2454
+ try:
2455
+ obj = _json.loads(data)
2456
+ except (ValueError, TypeError):
2457
+ raise _lib_accounts.TornRead()
2458
+ if not isinstance(obj, dict):
2459
+ raise _lib_accounts.TornRead()
2460
+ oauth = obj.get("oauthAccount")
2461
+ nat = _lib_accounts.claude_natural_id(oauth)
2462
+ if nat is None:
2463
+ return None
2464
+ return {
2465
+ "natural_id": nat,
2466
+ "email": _lib_accounts.claude_email(oauth),
2467
+ "plan_type": (oauth.get("plan") if isinstance(oauth, dict) else None),
2468
+ }
2469
+
2470
+ result = _lib_accounts.stable_read_identity(path, _reader)
2471
+ if result.status == "identified":
2472
+ info = result.value
2473
+ identity = {
2474
+ "account_key": _lib_accounts.account_key("claude", info["natural_id"]),
2475
+ "natural_id": info["natural_id"],
2476
+ "email": info.get("email"),
2477
+ "plan_type": info.get("plan_type"),
2478
+ # The three-valued read status (spec §1). ``account_key`` collapses
2479
+ # torn+stably_absent to the sentinel; consumers that must tell a
2480
+ # RESOLVED absence (single-account / api-key -> unattributed) from a
2481
+ # TRANSIENT torn read (defer / exit 2 for record-credit) read this.
2482
+ "status": "identified",
2483
+ }
2484
+ else:
2485
+ identity = {"account_key": _lib_accounts.UNATTRIBUTED,
2486
+ "natural_id": None, "email": None, "plan_type": None,
2487
+ "status": result.status}
2488
+ if sig is not None:
2489
+ cache["sig"] = sig
2490
+ cache["identity"] = identity
2491
+ return identity
2492
+
2493
+
2494
+ def _resolve_active_claude_account() -> str:
2495
+ """The active Claude ``account_key`` (or ``unattributed``). Thin key-only
2496
+ accessor over :func:`_resolve_active_claude_identity` (mtime-cached)."""
2497
+ return _resolve_active_claude_identity()["account_key"]
2498
+
2499
+
2208
2500
  def get_latest_usage_for_week(
2209
2501
  conn: sqlite3.Connection,
2210
2502
  week_ref: WeekRef,
2211
2503
  as_of_utc: str | None = None,
2504
+ *,
2505
+ account_key: str | None = None,
2212
2506
  ) -> sqlite3.Row | None:
2213
2507
  return _get_latest_row_for_week(
2214
2508
  conn, "weekly_usage_snapshots", week_ref, as_of_utc=as_of_utc,
2509
+ account_key=account_key,
2215
2510
  )