cctally 1.68.0 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -247,39 +247,55 @@ def _looks_like_command_plumbing(text) -> bool:
247
247
  return bool(text) and _CMD_FAMILY_RE.fullmatch(text) is not None
248
248
 
249
249
 
250
- def _session_titles_map(conn, session_ids):
251
- """{sid: title} for the first non-marker, non-blank MAIN-session human line
252
- per session (read-time, no migration). Windowed to the earliest 12 human
253
- rows/session (rides idx_conv_session_ts); Python skips system markers. A
254
- session whose first 12 human rows are all markers/blank is simply absent
255
- (caller falls back). NOTE (Codex P1.2): the window ranks the full per-session
256
- human partition before rn<=12confirmed index-ordered + bounded by the page
257
- (≤200 sessions); per-session human counts are modest. If EXPLAIN QUERY PLAN
258
- ever shows a temp B-tree sort here, switch to a per-session correlated
259
- LIMIT 12 candidate fetch."""
250
+ def _session_ai_titles_map(conn, session_ids):
251
+ """{sid: ai_title} for sessions carrying a TRUTHY ``conversation_ai_titles``
252
+ row (#193). The indexed lookup that was ``_session_titles_map``'s first step,
253
+ extracted so the fill (first-prompt only) and the rail's live overlay share
254
+ ONE source (#302 Q2-B, so stored-vs-live can never drift).
255
+
256
+ Falsey (empty-string) titles are DROPPED ``ai_title`` is ``NOT NULL`` but
257
+ not non-empty-constrained, so this preserves the prior ``if at:`` guard
258
+ (Codex P1-3) and never suppresses the first-prompt fallback. Tolerates the
259
+ table being absent (pre-migration / :memory:) by returning {}."""
260
260
  if not session_ids:
261
261
  return {}
262
- titles = {}
263
- # #193: AI title wins when present. Query the dedicated table first; the
264
- # existing first-prompt scan below fills only sessions WITHOUT one (its
265
- # ``if sid in titles: continue`` guard skips ai-title sessions for free).
262
+ out = {}
266
263
  try:
267
- ph0 = ",".join("?" for _ in session_ids)
264
+ ph = ",".join("?" for _ in session_ids)
268
265
  for sid, at in conn.execute(
269
266
  f"SELECT session_id, ai_title FROM conversation_ai_titles "
270
- f"WHERE session_id IN ({ph0})", tuple(session_ids)
267
+ f"WHERE session_id IN ({ph})", tuple(session_ids)
271
268
  ).fetchall():
272
269
  if at:
273
- titles[sid] = at
270
+ out[sid] = at
274
271
  except sqlite3.OperationalError:
275
- pass # table absent (pre-migration / :memory:) -> fall through to first-prompt
276
- # While 005's reingest is pending, a stale `human` row may actually be an
277
- # injected skill body (a SessionStart skill can even lead the transcript) —
278
- # skip those as title candidates so the rail never shows "Base directory for
279
- # this skill: …" until the next sync reclassifies them to `meta` (which the
280
- # entry_type='human' filter below then excludes). Gated on the flag for the
281
- # same reason as the render fallback: a genuine post-reingest human prompt
282
- # starting with the preamble stays a normal title (Codex code-review P2).
272
+ pass # table absent -> {} (caller falls back to the first-prompt title)
273
+ return out
274
+
275
+
276
+ def _session_first_prompt_titles_map(conn, session_ids):
277
+ """{sid: title} from the first non-marker, non-blank MAIN-session human line
278
+ per session the expensive windowed ``conversation_messages`` scan #302
279
+ materializes onto the rollup. Windowed to the earliest 12 human rows/session
280
+ (rides idx_conv_session_ts); Python skips system markers / command plumbing /
281
+ compaction / notification / bash-echo / (while reingest is pending) skill
282
+ preamble, then strips the remote-control stamp. A session whose first 12
283
+ human rows are all markers/blank is simply absent (caller falls back).
284
+
285
+ Keeps its OWN first-wins guard (``if sid in titles: continue``, re-localized
286
+ to this helper's result dict): once a sid resolves a first-prompt title, later
287
+ rows in its 12-row window do not overwrite it (Codex P1-3).
288
+
289
+ NOTE (Codex P1.2): the window ranks the full per-session human partition before
290
+ rn<=12 — confirmed index-ordered + bounded by the page (≤200 sessions);
291
+ per-session human counts are modest. Honors ``_reingest_pending(conn)``'s
292
+ skip_skill_titles gate: while 005's reingest is pending a stale ``human`` row
293
+ may actually be an injected skill body, so skip those as title candidates
294
+ (gated on the flag so a genuine post-reingest human prompt starting with the
295
+ preamble stays a normal title — Codex code-review P2)."""
296
+ if not session_ids:
297
+ return {}
298
+ titles = {}
283
299
  skip_skill_titles = _reingest_pending(conn)
284
300
  ph = ",".join("?" for _ in session_ids)
285
301
  rows = conn.execute(
@@ -295,7 +311,7 @@ def _session_titles_map(conn, session_ids):
295
311
  ).fetchall()
296
312
  for sid, text in rows:
297
313
  if sid in titles:
298
- continue # already resolved to the first non-marker
314
+ continue # first-wins: already resolved a title
299
315
  if _is_system_marker(text) or _looks_like_command_plumbing(text):
300
316
  continue
301
317
  if (_is_compaction_body(text) or _is_notification_body(text)
@@ -309,6 +325,21 @@ def _session_titles_map(conn, session_ids):
309
325
  return titles
310
326
 
311
327
 
328
+ def _session_titles_map(conn, session_ids):
329
+ """{sid: title} — the TRUTHY AI title wins, else the first-prompt title.
330
+ Contract UNCHANGED (the live/degraded rail branch still calls this as-is); now
331
+ single-sources the two seams (``_session_ai_titles_map`` +
332
+ ``_session_first_prompt_titles_map``) so the fill (first-prompt only) and the
333
+ rail overlay (AI live) cannot drift (#302). #193: the AI title wins when
334
+ present; the first-prompt scan fills only sessions WITHOUT one."""
335
+ if not session_ids:
336
+ return {}
337
+ titles = dict(_session_ai_titles_map(conn, session_ids))
338
+ for sid, t in _session_first_prompt_titles_map(conn, session_ids).items():
339
+ titles.setdefault(sid, t) # AI title (truthy) wins; else first-prompt
340
+ return titles
341
+
342
+
312
343
  def _project_label(cwd) -> str:
313
344
  """Basename of the project cwd (dashboard label posture — no reveal). Falls
314
345
  back to the raw path for root-ish cwds, '' when absent."""
@@ -1065,8 +1096,10 @@ def _model_clause(conn, families):
1065
1096
  to everything. Family classification goes through ``_chip_for_model`` — the
1066
1097
  SAME classifier ``list_conversation_facets`` uses — so a family's facet count
1067
1098
  equals the rows its chip yields, and 'any appearance' semantics + correct
1068
- 'other'-exclusion come for free. 'other' is unreachable as a selectable
1069
- family (never emitted by the client) and is simply never collected here.
1099
+ 'other'-exclusion come for free. 'other' is never emitted by the client, but a
1100
+ hand-crafted models=other would collect unknown-family model ids
1101
+ (``_chip_for_model`` returns 'other' for unrecognized ids); it is simply not a
1102
+ normal selectable family.
1070
1103
 
1071
1104
  The fragment ANDs into any WHERE that has a ``session_id`` column, so the
1072
1105
  IDENTICAL string composes into both the ``conversation_sessions`` rollup and
@@ -1085,9 +1118,18 @@ def _model_clause(conn, families):
1085
1118
  # Present-but-empty: match nothing (NOT ("", []) which would be unfiltered).
1086
1119
  return "session_id IN (SELECT session_id FROM conversation_messages WHERE 0)", []
1087
1120
  ph = ",".join("?" for _ in ids)
1121
+ # #301: pin the partial covering index idx_conversation_messages_model_session so
1122
+ # this ?models= filter is an index-only model seek, never a heap-walk. The explicit
1123
+ # `model IS NOT NULL AND model != ''` makes the partial index eligible (SQLite won't
1124
+ # infer it from `model IN (...)`) — a no-op since ids are all non-null/non-empty.
1125
+ # DISTINCT dropped: this is the RHS of `session_id IN (...)`, where duplicates don't
1126
+ # affect membership, so dropping it is output-identical and removes a temp b-tree.
1127
+ # INDEXED BY makes the covering plan deterministic even if the DB has been ANALYZEd.
1088
1128
  return (
1089
- "session_id IN (SELECT DISTINCT session_id FROM conversation_messages "
1090
- "WHERE session_id IS NOT NULL AND model IN (%s))" % ph,
1129
+ "session_id IN (SELECT session_id FROM conversation_messages "
1130
+ "INDEXED BY idx_conversation_messages_model_session "
1131
+ "WHERE session_id IS NOT NULL AND model IS NOT NULL AND model != '' "
1132
+ "AND model IN (%s))" % ph,
1091
1133
  ids,
1092
1134
  )
1093
1135
 
@@ -1199,17 +1241,21 @@ def _list_session_rows_rollup(conn, order, limit, offset, filters=None,
1199
1241
  model_clause=("", [])):
1200
1242
  """FAST path: read the pre-aggregated rail rows straight from the
1201
1243
  conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
1202
- ``recent`` sort. Returns (session_id, msg_count, started, last_activity)
1203
- tuples the same shape the live aggregate yields, so the downstream
1204
- assembly is identical. The optional ``filters`` dict pushes the four-axis
1205
- browse predicate (date/project/cost/rebuild — all stored columns) into the
1206
- WHERE BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over
1207
- the filtered set. ``model_clause`` (#278 Theme C) is the precomputed model
1244
+ ``recent`` sort. Returns (session_id, msg_count, started, last_activity,
1245
+ cost_usd, project_label, git_branch, models_json, title) tuples the 4
1246
+ structural columns PLUS the #302 materialized enrichment the rail displays,
1247
+ riding along on rows already fetched (same WHERE/ORDER BY/LIMIT/OFFSET). The
1248
+ live aggregate yields only the 4 structural columns, so list_conversations
1249
+ unpacks per branch. The optional ``filters`` dict pushes the four-axis browse
1250
+ predicate (date/project/cost/rebuild — all stored columns) into the WHERE
1251
+ BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over the
1252
+ filtered set. ``model_clause`` (#278 Theme C) is the precomputed model
1208
1253
  predicate folded into the same WHERE."""
1209
1254
  where, params = _rollup_where(filters or _empty_filters(), model_clause)
1210
1255
  return conn.execute(
1211
1256
  "SELECT session_id, msg_count, "
1212
- " started_utc AS started, last_activity_utc AS last_activity "
1257
+ " started_utc AS started, last_activity_utc AS last_activity, "
1258
+ " cost_usd, project_label, git_branch, models_json, title "
1213
1259
  "FROM conversation_sessions"
1214
1260
  + where +
1215
1261
  " ORDER BY " + order + " LIMIT ? OFFSET ?",
@@ -1255,16 +1301,22 @@ def list_conversation_facets(conn) -> dict:
1255
1301
  """Distinct projects (+ conversation counts) AND per-model-family session
1256
1302
  counts for the browse filter multi-selects (spec §2 + #278 Theme C).
1257
1303
 
1258
- ``projects`` reads the rollup; cheap GROUP BY. Empty/NULL project labels are
1259
- dropped (a no-cwd session stores '' and a not-yet-filled row stores NULL —
1260
- neither is a real selectable project). Sorted ascending by label so the
1261
- popover renders a stable list.
1262
-
1263
- ``models`` folds distinct ``(session_id, model)`` pairs from
1264
- ``conversation_messages`` (where ``session_id IS NOT NULL``matching the
1265
- browse/live row sources, so a null-session row can't inflate a count above
1266
- the filterable sessions) to distinct ``(session_id, family)`` via
1267
- ``_chip_for_model``, then counts sessions per family. Fold-then-count, NOT
1304
+ ``projects`` reads the ``conversation_sessions`` rollup; a cheap GROUP BY over
1305
+ the stored ``project_label`` column. Empty/NULL project labels are dropped (a
1306
+ no-cwd session stores '' and a not-yet-filled row stores NULL neither is a
1307
+ real selectable project). Sorted ascending by label so the popover renders a
1308
+ stable list.
1309
+
1310
+ ``models``, by contrast, does NOT touch the rollup it scans
1311
+ ``conversation_messages`` directly. It folds distinct ``(session_id, model)``
1312
+ pairs (where ``session_id IS NOT NULL AND model IS NOT NULL AND model != ''``
1313
+ matching the browse/live row sources, so a null-session row can't inflate a
1314
+ count above the filterable sessions) to distinct ``(session_id, family)`` via
1315
+ ``_chip_for_model``, then counts sessions per family. Since #301 that DISTINCT
1316
+ is an index-only walk of the partial covering index
1317
+ ``idx_conversation_messages_model_session`` on
1318
+ ``conversation_messages(model, session_id)`` (a full index->heap walk of the
1319
+ whole table — ~22s cold on a large cache — before the index existed). Fold-then-count, NOT
1268
1320
  sum-of-per-model-distinct: a session that used two Opus point-releases counts
1269
1321
  ONCE under opus, and an Opus+Haiku session counts under BOTH. Families are
1270
1322
  emitted present-only, excluding ``other``, in a fixed display order so the
@@ -1338,7 +1390,11 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1338
1390
  model_clause = _model_clause(conn, filters["models"])
1339
1391
  degraded = False
1340
1392
  sort_degraded = False
1341
- if _rollup_authoritative(conn):
1393
+ # Compute the authoritative decision ONCE and reuse it for BOTH the row
1394
+ # source and the enrichment split — the same predicate must key both, or a
1395
+ # race between them could unpack a rollup 9-tuple as a live 4-tuple.
1396
+ authoritative = _rollup_authoritative(conn)
1397
+ if authoritative:
1342
1398
  order = _SORTS.get(sort, _SORTS["recent"])
1343
1399
  rows = _list_session_rows_rollup(conn, order, limit, offset, filters,
1344
1400
  model_clause)
@@ -1355,25 +1411,58 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1355
1411
  has_more = len(rows) > limit
1356
1412
  rows = rows[:limit]
1357
1413
  session_ids = [r[0] for r in rows]
1358
- costs = _session_cost_map(conn, session_ids)
1359
- models = _session_models_map(conn, session_ids)
1360
- # cwd/git_branch as the latest non-null (reader posture), NOT a lexical MAX().
1361
- meta = _session_latest_meta_map(conn, session_ids)
1362
- titles = _session_titles_map(conn, session_ids)
1363
- conversations = [
1364
- {
1365
- "session_id": sid,
1366
- "title": titles.get(sid) or _project_label(meta.get(sid, (None, None))[0]) or sid,
1367
- "project_label": _project_label(meta.get(sid, (None, None))[0]),
1368
- "git_branch": meta.get(sid, (None, None))[1],
1369
- "started_utc": started,
1370
- "last_activity_utc": last_activity,
1371
- "msg_count": msg_count,
1372
- "cost_usd": round(costs.get(sid, 0.0), 6),
1373
- "models": models.get(sid, []),
1374
- }
1375
- for (sid, msg_count, started, last_activity) in rows
1376
- ]
1414
+ if authoritative:
1415
+ # FAST PATH (#302): cost/project/git_branch/models are materialized on
1416
+ # the rollup rows, and title is the stored STABLE first-prompt title. The
1417
+ # ONLY per-page read besides the rollup rows is the cheap indexed
1418
+ # conversation_ai_titles overlay — NO _session_cost_map /
1419
+ # _session_models_map / _session_latest_meta_map /
1420
+ # _session_first_prompt_titles_map (each an O(messages-on-page)
1421
+ # conversation_messages re-read). By `or`-associativity the emitted dict
1422
+ # is byte-identical to the live branch below: rollup_title is the stored
1423
+ # first-prompt title and project_label is the stored _project_label(cwd),
1424
+ # so `ai or rollup_title or project_label or sid` == the live
1425
+ # `titles.get(sid) or _project_label(cwd) or sid` (AI title wins live too).
1426
+ ai = _session_ai_titles_map(conn, session_ids)
1427
+ conversations = [
1428
+ {
1429
+ "session_id": sid,
1430
+ "title": ai.get(sid) or rollup_title or project_label or sid,
1431
+ "project_label": project_label,
1432
+ "git_branch": git_branch,
1433
+ "started_utc": started,
1434
+ "last_activity_utc": last_activity,
1435
+ "msg_count": msg_count,
1436
+ # cost_usd is already 6dp-rounded at fill; keep the round for safety.
1437
+ "cost_usd": round(cost_usd or 0.0, 6),
1438
+ "models": _json.loads(models_json) if models_json else [],
1439
+ }
1440
+ for (sid, msg_count, started, last_activity, cost_usd,
1441
+ project_label, git_branch, models_json, rollup_title) in rows
1442
+ ]
1443
+ else:
1444
+ # LIVE/DEGRADED branch (backfill pending / --no-sync): UNCHANGED — recompute
1445
+ # enrichment at request time via the four retained maps over
1446
+ # conversation_messages. Correct, possibly slower, never an empty rail.
1447
+ costs = _session_cost_map(conn, session_ids)
1448
+ models = _session_models_map(conn, session_ids)
1449
+ # cwd/git_branch as the latest non-null (reader posture), NOT a lexical MAX().
1450
+ meta = _session_latest_meta_map(conn, session_ids)
1451
+ titles = _session_titles_map(conn, session_ids)
1452
+ conversations = [
1453
+ {
1454
+ "session_id": sid,
1455
+ "title": titles.get(sid) or _project_label(meta.get(sid, (None, None))[0]) or sid,
1456
+ "project_label": _project_label(meta.get(sid, (None, None))[0]),
1457
+ "git_branch": meta.get(sid, (None, None))[1],
1458
+ "started_utc": started,
1459
+ "last_activity_utc": last_activity,
1460
+ "msg_count": msg_count,
1461
+ "cost_usd": round(costs.get(sid, 0.0), 6),
1462
+ "models": models.get(sid, []),
1463
+ }
1464
+ for (sid, msg_count, started, last_activity) in rows
1465
+ ]
1377
1466
  page = {
1378
1467
  "next_offset": offset + len(conversations) if has_more else None,
1379
1468
  "has_more": has_more,
@@ -199,6 +199,11 @@ class DoctorState:
199
199
  # DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
200
200
  # + forced-checkpoint machinery has genuinely failed to contain the WAL.
201
201
  cache_db_wal_bytes: Optional[int] = None
202
+ # #294 S2: root-qualified physical Codex quota freshness, per-root native
203
+ # hook state, and lifecycle activity are gathered by _cctally_doctor.
204
+ codex_quota_windows: Optional[list[dict]] = None
205
+ codex_hook_roots: Optional[list[dict]] = None
206
+ codex_lifecycle_activity_24h: Optional[dict] = None
202
207
 
203
208
 
204
209
  @dataclasses.dataclass(frozen=True)
@@ -763,6 +768,200 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
763
768
  )
764
769
 
765
770
 
771
+ def _check_data_codex_quota(s: DoctorState) -> CheckResult:
772
+ """Report local physical Codex quota evidence without fabricating a window."""
773
+ raw_windows = s.codex_quota_windows or []
774
+ if not raw_windows:
775
+ has_unsafe_corpus = bool(s.codex_jsonl_present)
776
+ return CheckResult(
777
+ id="data.codex_quota", title="Codex quota",
778
+ severity="warn" if has_unsafe_corpus else "ok",
779
+ summary=("no safely interpreted windows" if has_unsafe_corpus
780
+ else "not applicable"),
781
+ remediation=("Run `cctally cache-sync --source codex --rebuild`"
782
+ if has_unsafe_corpus else None),
783
+ details={
784
+ "window_count": 0,
785
+ "latest_capture_at": None,
786
+ "freshness_state": "unavailable",
787
+ "age_seconds": None,
788
+ "stale_after_seconds": None,
789
+ "responsible_identity": None,
790
+ "windows": [],
791
+ },
792
+ )
793
+
794
+ def identity_key(row: dict) -> tuple[str, str, str, str, int]:
795
+ identity = row.get("identity") or {}
796
+ return (
797
+ str(identity.get("source") or ""),
798
+ str(identity.get("source_root_key") or ""),
799
+ str(identity.get("logical_limit_key") or ""),
800
+ str(identity.get("observed_slot") or ""),
801
+ int(identity.get("window_minutes") or 0),
802
+ )
803
+
804
+ freshness_order = {"future": 0, "stale": 1, "unavailable": 2, "fresh": 3}
805
+ windows: list[dict] = []
806
+ for row in sorted(raw_windows, key=identity_key):
807
+ captured_at = row.get("latest_capture_at")
808
+ windows.append({
809
+ "identity": row.get("identity"),
810
+ "latest_capture_at": (_iso_z(captured_at)
811
+ if isinstance(captured_at, dt.datetime)
812
+ else captured_at),
813
+ "freshness_state": row.get("freshness_state") or "unavailable",
814
+ "age_seconds": row.get("age_seconds"),
815
+ "stale_after_seconds": row.get("stale_after_seconds"),
816
+ })
817
+ worst_rank = min(freshness_order.get(row["freshness_state"], 2) for row in windows)
818
+ responsible = next(
819
+ row for row in windows
820
+ if freshness_order.get(row["freshness_state"], 2) == worst_rank
821
+ )
822
+ captures = [
823
+ row.get("latest_capture_at") for row in raw_windows
824
+ if isinstance(row.get("latest_capture_at"), dt.datetime)
825
+ ]
826
+ latest_capture_at = _iso_z(max(captures)) if captures else None
827
+ state = responsible["freshness_state"]
828
+ return CheckResult(
829
+ id="data.codex_quota", title="Codex quota",
830
+ severity="ok" if state == "fresh" else "warn",
831
+ summary=f"{len(windows)} window(s); {state}",
832
+ remediation=(None if state == "fresh"
833
+ else "Run `cctally cache-sync --source codex` after Codex activity."),
834
+ details={
835
+ "window_count": len(windows),
836
+ "latest_capture_at": latest_capture_at,
837
+ "freshness_state": state,
838
+ "age_seconds": responsible["age_seconds"],
839
+ "stale_after_seconds": responsible["stale_after_seconds"],
840
+ "responsible_identity": responsible["identity"],
841
+ "windows": windows,
842
+ },
843
+ )
844
+
845
+
846
+ def _check_hooks_codex_installed(s: DoctorState) -> CheckResult:
847
+ """Summarize every configured Codex root without masking a bad sibling."""
848
+ rows = sorted(
849
+ (row for row in (s.codex_hook_roots or []) if isinstance(row, dict)),
850
+ key=lambda row: str(row.get("source_root_key") or ""),
851
+ )
852
+ states = [
853
+ {"source_root_key": row.get("source_root_key"), "state": row.get("state")}
854
+ for row in rows
855
+ ]
856
+ installed_states = {
857
+ "installed_review_required", "installed_trust_unobservable",
858
+ }
859
+ installed = [row for row in rows if row.get("state") in installed_states]
860
+ requires_review = (
861
+ True if any(row.get("state") == "installed_review_required" for row in rows)
862
+ else None if installed
863
+ else False
864
+ )
865
+ trust_state = (
866
+ "not-applicable" if not rows
867
+ else "review-required" if requires_review is True
868
+ else "unobservable" if installed
869
+ else "not-installed"
870
+ )
871
+ unhealthy = any(row.get("state") not in installed_states for row in rows)
872
+ return CheckResult(
873
+ id="hooks.codex_installed", title="Codex hooks installed",
874
+ severity="warn" if unhealthy else "ok",
875
+ summary=("not applicable" if not rows
876
+ else f"{len(installed)}/{len(rows)} root(s) installed"),
877
+ remediation=("Run `cctally setup`, then review the handler in Codex /hooks."
878
+ if unhealthy else None),
879
+ details={
880
+ "root_count": len(rows),
881
+ "installed_root_count": len(installed),
882
+ "states": states,
883
+ "requires_review": requires_review,
884
+ "trust_state": trust_state,
885
+ },
886
+ )
887
+
888
+
889
+ def _check_hooks_codex_recent_activity(s: DoctorState) -> CheckResult:
890
+ """Aggregate 24-hour lifecycle records only for installed root handlers."""
891
+ installed_states = {
892
+ "installed_review_required", "installed_trust_unobservable",
893
+ }
894
+ installed_keys = sorted(
895
+ str(row.get("source_root_key"))
896
+ for row in (s.codex_hook_roots or [])
897
+ if isinstance(row, dict) and row.get("state") in installed_states
898
+ and row.get("source_root_key")
899
+ )
900
+ if not installed_keys:
901
+ return CheckResult(
902
+ id="hooks.codex_recent_activity", title="Codex recent activity",
903
+ severity="ok", summary="not applicable",
904
+ remediation=None,
905
+ details={
906
+ "activity_state": "not-applicable",
907
+ "last_tick_at": None,
908
+ "age_seconds": None,
909
+ "success_count_24h": 0,
910
+ "error_count_24h": 0,
911
+ "responsible_root_key": None,
912
+ "roots": [],
913
+ },
914
+ )
915
+
916
+ activity = s.codex_lifecycle_activity_24h or {}
917
+ roots: list[dict] = []
918
+ for key in installed_keys:
919
+ row = activity.get(key) if isinstance(activity, dict) else None
920
+ row = row if isinstance(row, dict) else {}
921
+ last_tick_at = row.get("last_tick_at")
922
+ if isinstance(last_tick_at, dt.datetime):
923
+ age_seconds = max(0, int((s.now_utc - last_tick_at).total_seconds()))
924
+ tick_wire = _iso_z(last_tick_at)
925
+ else:
926
+ age_seconds = None
927
+ tick_wire = None
928
+ if tick_wire is None:
929
+ state = "never"
930
+ elif age_seconds is not None and age_seconds > 24 * 3600:
931
+ state = "stale"
932
+ else:
933
+ state = "recent"
934
+ roots.append({
935
+ "source_root_key": key,
936
+ "activity_state": state,
937
+ "last_tick_at": tick_wire,
938
+ "age_seconds": age_seconds,
939
+ "success_count_24h": int(row.get("success_count_24h") or 0),
940
+ "error_count_24h": int(row.get("error_count_24h") or 0),
941
+ })
942
+ worst_order = {"never": 0, "stale": 1, "recent": 2}
943
+ worst_rank = min(worst_order[row["activity_state"]] for row in roots)
944
+ responsible = next(
945
+ row for row in roots if worst_order[row["activity_state"]] == worst_rank
946
+ )
947
+ return CheckResult(
948
+ id="hooks.codex_recent_activity", title="Codex recent activity",
949
+ severity="ok" if responsible["activity_state"] == "recent" else "warn",
950
+ summary=f"{len(roots)} installed root(s); {responsible['activity_state']}",
951
+ remediation=(None if responsible["activity_state"] == "recent"
952
+ else "Trigger Codex activity, then verify `cctally setup` hooks."),
953
+ details={
954
+ "activity_state": responsible["activity_state"],
955
+ "last_tick_at": responsible["last_tick_at"],
956
+ "age_seconds": responsible["age_seconds"],
957
+ "success_count_24h": responsible["success_count_24h"],
958
+ "error_count_24h": responsible["error_count_24h"],
959
+ "responsible_root_key": responsible["source_root_key"],
960
+ "roots": roots,
961
+ },
962
+ )
963
+
964
+
766
965
  def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
767
966
  """Invariant: for every row with ``week_start_at IS NOT NULL``,
768
967
  ``week_start_date == substr(week_start_at, 1, 10)``.
@@ -1393,6 +1592,8 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1393
1592
  ("hooks.installed", "_check_hooks_installed"),
1394
1593
  ("hooks.recent_activity_24h", "_check_hooks_recent_activity_24h"),
1395
1594
  ("hooks.last_fire_age", "_check_hooks_last_fire_age"),
1595
+ ("hooks.codex_installed", "_check_hooks_codex_installed"),
1596
+ ("hooks.codex_recent_activity", "_check_hooks_codex_recent_activity"),
1396
1597
  )),
1397
1598
  ("auth", "Auth", (
1398
1599
  ("oauth.token_present", "_check_oauth_token_present"),
@@ -1411,6 +1612,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1411
1612
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
1412
1613
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
1413
1614
  ("data.codex_cache", "_check_data_codex_cache"),
1615
+ ("data.codex_quota", "_check_data_codex_quota"),
1414
1616
  ("data.parse_health", "_check_data_parse_health"),
1415
1617
  ("data.forked_buckets", "_check_data_forked_buckets"),
1416
1618
  ("data.post_credit_milestones", "_check_data_post_credit_milestones"),