cctally 1.90.1 → 1.92.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 (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +2 -2
  3. package/bin/_cctally_cache.py +863 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +53 -8
  6. package/bin/_cctally_dashboard.py +146 -5
  7. package/bin/_cctally_dashboard_conversation.py +164 -18
  8. package/bin/_cctally_dashboard_envelope.py +69 -12
  9. package/bin/_cctally_dashboard_sources.py +27 -1
  10. package/bin/_cctally_db.py +372 -10
  11. package/bin/_cctally_doctor.py +18 -1
  12. package/bin/_cctally_journal.py +535 -13
  13. package/bin/_cctally_journal_repair.py +6 -0
  14. package/bin/_cctally_parser.py +6 -0
  15. package/bin/_cctally_quota.py +171 -55
  16. package/bin/_cctally_record.py +13 -1
  17. package/bin/_cctally_rederive.py +4 -0
  18. package/bin/_cctally_store.py +311 -6
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_lib_cache_report.py +8 -3
  21. package/bin/_lib_cache_report_wire.py +8 -20
  22. package/bin/_lib_codex_conversation.py +959 -81
  23. package/bin/_lib_codex_conversation_query.py +2792 -167
  24. package/bin/_lib_codex_find_projection.py +370 -0
  25. package/bin/_lib_codex_harness_preamble.py +176 -0
  26. package/bin/_lib_codex_hooks.py +5 -3
  27. package/bin/_lib_codex_js_scan.py +254 -0
  28. package/bin/_lib_codex_landmarks.py +309 -0
  29. package/bin/_lib_codex_reasoning_headings.py +73 -0
  30. package/bin/_lib_codex_segments.py +259 -0
  31. package/bin/_lib_codex_title_clean.py +116 -0
  32. package/bin/_lib_conversation_dispatch.py +153 -21
  33. package/bin/_lib_conversation_watch.py +4 -2
  34. package/bin/_lib_dashboard_sources.py +33 -32
  35. package/bin/_lib_doctor.py +64 -0
  36. package/bin/_lib_quota_alert_axes.py +31 -34
  37. package/bin/_lib_stats_damage.py +523 -0
  38. package/bin/cctally +5 -0
  39. package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
  40. package/dashboard/static/assets/index-DnWdv8um.css +1 -0
  41. package/dashboard/static/dashboard.html +2 -2
  42. package/package.json +9 -1
  43. package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
  44. package/dashboard/static/assets/index-CRogVlEC.js +0 -92
@@ -33,7 +33,10 @@ CapabilityStatus = Literal[
33
33
  # field. Additive and omitted-when-zero, so the normal payload is byte-identical
34
34
  # — but the same `execvp` transition applies, so the bump ships as the signal it
35
35
  # has always been rather than as a mechanism the client branches on.
36
- SOURCE_SCHEMA_VERSION = 3
36
+ # 3 -> 4 (#465): the Codex cache report retired its transitional
37
+ # `cache_hit_percent` alias and changed structurally inapplicable figures from
38
+ # numeric placeholders to null.
39
+ SOURCE_SCHEMA_VERSION = 4
37
40
  DEFAULT_SOURCE = "claude"
38
41
  SOURCE_ORDER = ("claude", "codex", "all")
39
42
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -392,13 +395,11 @@ def _combined_metrics(
392
395
  ) -> Mapping[str, object] | None:
393
396
  if not (_coherent_provider(claude) and _coherent_provider(codex)):
394
397
  return None
395
- # #350 spec §3.5: a stale-cycle hero is NOT combinable. Retaining it would
396
- # let a sum over stale evidence be published while `compose_all_state` marks
397
- # the result fresh — and the All hero carries no Snapshot row or staleness
398
- # marker at all, so the staleness would be silently undisclosed. The
399
- # combined NUMBER therefore behaves exactly as it does today.
400
- if _stale_cycle_providers(claude, codex):
401
- return None
398
+ # #359: the hero counters are backward-looking accounting actuals. A stale
399
+ # but still-live quota boundary pauses projections; it does not invalidate
400
+ # the retained cost/token sums that each provider already keeps visible.
401
+ # Composition therefore retains the compatible number and discloses the
402
+ # stale boundary through All's hero-domain freshness + local warning.
402
403
  for state in (claude, codex):
403
404
  hero_capability = state.capabilities.get("hero")
404
405
  if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
@@ -462,33 +463,33 @@ def compose_all_state(
462
463
  raise ValueError("all composition requires Claude and Codex provider states")
463
464
  combined = _combined_metrics(claude, codex)
464
465
  providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
465
- # #350 spec §3.5: "All behaves exactly as today" is true only of the combined
466
- # NUMBER. Under §3.4 both providers stay coherent, so All now publishes
467
- # partial/fresh with no provider warning to explain it — the status chip
468
- # would fall through to a generic `degraded` and the All hero fallback would
469
- # claim a provider is degraded while both provider envelopes say otherwise.
470
- # This All-LOCAL warning states the real reason without touching either
471
- # provider envelope. It is emitted only when the providers are otherwise
472
- # coherent; an incoherent provider already publishes its own reason.
466
+ stale_cycle_providers = (
467
+ _stale_cycle_providers(claude, codex) if providers_coherent else ()
468
+ )
469
+ # #359: the warning qualifies a retained combined actual. It stays
470
+ # All-local and keeps the composed source partial so the header status also
471
+ # names the caveat; provider envelopes remain independently coherent.
473
472
  all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
473
+ if stale_cycle_providers:
474
+ all_local_warnings = (SourceDashboardWarning(
475
+ "combined_totals_stale",
476
+ f"{' and '.join(stale_cycle_providers)} quota evidence is stale; "
477
+ "combined totals use retained actuals.",
478
+ "hero",
479
+ ),)
474
480
  if providers_coherent:
475
- stale_cycle_providers = _stale_cycle_providers(claude, codex)
476
481
  if stale_cycle_providers:
477
- all_local_warnings = (SourceDashboardWarning(
478
- "combined_totals_withheld",
479
- f"{' and '.join(stale_cycle_providers)} quota evidence is stale, "
480
- "so combined totals are withheld.",
481
- "hero",
482
- ),)
483
- availability: Availability = (
484
- "partial"
485
- if combined is None or "partial" in (claude.availability, codex.availability)
486
- else (
487
- "empty"
488
- if claude.availability == "empty" and codex.availability == "empty"
489
- else "ok"
482
+ availability: Availability = "partial"
483
+ else:
484
+ availability = (
485
+ "partial"
486
+ if combined is None or "partial" in (claude.availability, codex.availability)
487
+ else (
488
+ "empty"
489
+ if claude.availability == "empty" and codex.availability == "empty"
490
+ else "ok"
491
+ )
490
492
  )
491
- )
492
493
  freshness: Freshness = "fresh"
493
494
  else:
494
495
  availability = "partial"
@@ -519,7 +520,7 @@ def compose_all_state(
519
520
  # All-LOCAL warnings lead: `warningForSource` on the client falls back to
520
521
  # the FIRST warning, so a merely-partial provider warning (e.g.
521
522
  # `codex_metadata_incomplete`) would otherwise pre-empt the chip label
522
- # and hide the real reason combined totals are withheld.
523
+ # and hide the stale qualification on the combined actual.
523
524
  warnings=tuple((*all_local_warnings, *claude.warnings, *codex.warnings)),
524
525
  data_version=data_version,
525
526
  last_success_at=last_success_at,
@@ -210,6 +210,11 @@ class DoctorState:
210
210
  # walk, so `files_failed`/`files_deferred_torn` are both zero, no `blocked`
211
211
  # record is ever written, and the ingest-backlog leg reads a drained store.
212
212
  codex_replay_deferred: Optional[dict] = None
213
+ # #485: privacy-safe durable records written when cache.db and/or
214
+ # conversations.db refused to treat an invalid Codex root as deletion
215
+ # evidence. None is the normal state; each record contains counts/reasons
216
+ # but never configured paths or provider identifiers.
217
+ codex_prune_refusals: Optional[list[dict]] = None
213
218
  # #279 S2 (F5b): PRAGMA quick_check(1) results, gathered ONLY under
214
219
  # doctor_gather_state(deep=True) (CLI cmd_doctor) — the dashboard
215
220
  # rebuild loop calls the gather every rebuild and quick_check on a
@@ -1236,6 +1241,64 @@ def _check_data_codex_replay(s: DoctorState) -> CheckResult:
1236
1241
  )
1237
1242
 
1238
1243
 
1244
+ def _check_data_codex_prune_safety(s: DoctorState) -> CheckResult:
1245
+ """Surface a fail-closed orphan-prune decision instead of silent loss."""
1246
+ records = [
1247
+ record for record in (s.codex_prune_refusals or [])
1248
+ if isinstance(record, dict)
1249
+ ]
1250
+ if not records:
1251
+ return CheckResult(
1252
+ id="data.codex_prune_safety",
1253
+ title="Codex orphan pruning",
1254
+ severity="ok",
1255
+ summary="no refused prune",
1256
+ remediation=None,
1257
+ details={"refusalCount": 0, "stores": []},
1258
+ )
1259
+ stores = sorted({
1260
+ str(record.get("store"))
1261
+ for record in records
1262
+ if record.get("store") in {"cache", "conversations"}
1263
+ })
1264
+ preserved_files = max(
1265
+ (int(record.get("preservedFileCount") or 0) for record in records),
1266
+ default=0,
1267
+ )
1268
+ since_values = sorted({
1269
+ str(record["since"])
1270
+ for record in records
1271
+ if isinstance(record.get("since"), str) and record["since"]
1272
+ })
1273
+ reasons = sorted({
1274
+ str(reason)
1275
+ for record in records
1276
+ for reason in (
1277
+ record.get("reasons") if isinstance(record.get("reasons"), list) else []
1278
+ )
1279
+ if isinstance(reason, str)
1280
+ })
1281
+ return CheckResult(
1282
+ id="data.codex_prune_safety",
1283
+ title="Codex orphan pruning",
1284
+ severity="warn",
1285
+ summary=(
1286
+ f"refused unsafe prune; preserved {preserved_files} tracked file(s)"
1287
+ ),
1288
+ remediation=(
1289
+ "Check that every $CODEX_HOME root is mounted and contains Codex "
1290
+ "rollout JSONL, then run `cctally cache-sync --source codex`"
1291
+ ),
1292
+ details={
1293
+ "refusalCount": len(records),
1294
+ "stores": stores,
1295
+ "since": since_values[0] if since_values else None,
1296
+ "reasons": reasons,
1297
+ "preservedFileCount": preserved_files,
1298
+ },
1299
+ )
1300
+
1301
+
1239
1302
  def _check_data_codex_quota_verification(s: DoctorState) -> CheckResult:
1240
1303
  """WARN when the detached Codex quota verification is not landing.
1241
1304
 
@@ -2935,6 +2998,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
2935
2998
  # tests/test_doctor_codex_project_metadata.py), so the replay leg goes
2936
2999
  # after the pair rather than between them.
2937
3000
  ("data.codex_project_metadata", "_check_data_codex_project_metadata"),
3001
+ ("data.codex_prune_safety", "_check_data_codex_prune_safety"),
2938
3002
  ("data.codex_replay", "_check_data_codex_replay"),
2939
3003
  ("data.codex_ingest_backlog", "_check_data_codex_ingest_backlog"),
2940
3004
  ("data.codex_quota", "_check_data_codex_quota"),
@@ -23,9 +23,10 @@ in ``quota_window_snapshots`` moving at all:
23
23
  and becomes eligible when wall time passes it with no mutation to observe.
24
24
  Persisting that boundary and treating ``now >= boundary`` as dirty is what
25
25
  closes it. Unlike axes 2 and 3 this one fires on WALL CLOCK rather than on a
26
- configuration change, which is why the hook path defers it
27
- (``defer_scheduled``) instead of paying an unannounced whole-history pass on
28
- a blocking tick.
26
+ configuration change. An ownership schedule lets the hook evaluate only the
27
+ complete roots whose deadlines matured; scalar-only legacy state still
28
+ defers rather than paying an unannounced whole-history pass on a blocking
29
+ tick.
29
30
  5. **Durable lifecycle state** — the existing arming rows and terminal events,
30
31
  unchanged. Represented here only as the fingerprints axis 2 compares.
31
32
 
@@ -80,6 +81,7 @@ def alert_dirty_scope(
80
81
  gate_after: bool,
81
82
  now: dt.datetime,
82
83
  next_evaluation_at: "dt.datetime | None",
84
+ scheduled_roots: "Iterable[str] | None" = None,
83
85
  defer_scheduled: bool = False,
84
86
  ) -> AlertDirtyScope:
85
87
  """Resolve the five axes into one decision.
@@ -89,14 +91,11 @@ def alert_dirty_scope(
89
91
  observed_slot, window_minutes)``; the ROOT is element 1, which is what an
90
92
  exact-rule change is scoped to.
91
93
 
92
- ``defer_scheduled`` is the hook path's (``full_pass="defer"``). Axes 2 and 3
93
- are driven by a configuration change the user just made, so widening for
94
- them is bounded and expected; axis 4 is driven by WALL CLOCK, which makes it
95
- the one route into a whole-history pass that can land on a blocking hook
96
- tick with nothing to have predicted it. Under this flag it is recorded as
97
- ``REASON_SCHEDULED_DEFERRED`` and does NOT strengthen the scope — and the
98
- caller owes the stored boundary a carry-through, because a deferral that
99
- lets the boundary be recomputed is a silent drop.
94
+ ``scheduled_roots`` is the validated ownership retained with axis 4. When
95
+ present, a matured instant scopes to those roots even on the hook path.
96
+ ``None`` is the legacy/unavailable-ownership shape; only that shape needs
97
+ ``defer_scheduled`` to avoid an unannounced whole-history hook pass, and the
98
+ caller then owes the scalar boundary a carry-through.
100
99
  """
101
100
  reasons: list[str] = []
102
101
  if not gate_after:
@@ -132,11 +131,19 @@ def alert_dirty_scope(
132
131
  reasons.append("rule_changed")
133
132
 
134
133
  if next_evaluation_at is not None and now >= next_evaluation_at:
135
- # A future-clocked observation just became eligible. Which identity it
136
- # belongs to is not recorded — only the instant — so the honest scope is
137
- # everything, and on the hook path "everything" is precisely what may
138
- # not run.
139
- if defer_scheduled:
134
+ # Epoch 1007 records the roots owning each scheduled instant. A complete
135
+ # semantic pass over those roots is bounded enough for the hook path and
136
+ # is all axis 4 needs. ``None`` means legacy/unavailable ownership, where
137
+ # the only honest scope remains everything (and therefore deferral on a
138
+ # hook tick). An empty known set means the owning roots are not lifecycle
139
+ # eligible on this tick; the stored axis remains due for a later tick.
140
+ if scheduled_roots is not None:
141
+ due_roots = {str(root) for root in scheduled_roots if str(root)}
142
+ if due_roots:
143
+ scope = _strongest(scope, SCOPE_ROOTS)
144
+ roots |= due_roots
145
+ reasons.append("scheduled")
146
+ elif defer_scheduled:
140
147
  reasons.append(REASON_SCHEDULED_DEFERRED)
141
148
  else:
142
149
  scope = _strongest(scope, SCOPE_ALL)
@@ -154,12 +161,12 @@ def next_evaluation_boundary(
154
161
  ) -> "dt.datetime | None":
155
162
  """The earliest still-future capture the projector must come back for.
156
163
 
157
- A bounded pass only sees the dirty windows, so the STORED boundary is
164
+ This is the legacy scalar helper. A bounded pass only sees dirty windows, so
165
+ the STORED boundary is
158
166
  retained whenever it is still in the future: dropping it would forget a
159
167
  future-clocked observation sitting in a window this pass never loaded. Once
160
- wall time passes it the axis fires, the pass widens to everything, and the
161
- boundary is recomputed from complete evidence — so a retained value can only
162
- ever cost one extra pass, never a missed one.
168
+ wall time passes it the axis fires and the caller decides whether it has
169
+ enough ownership to scope the pass.
163
170
 
164
171
  ``retain_due`` keeps a boundary that is ALREADY due, which is the case where
165
172
  "recomputed from complete evidence" is a lie: a reporting-only pass never
@@ -167,20 +174,10 @@ def next_evaluation_boundary(
167
174
  the widening deliberately did not look. Either would otherwise retire the
168
175
  axis on behalf of an evaluation nobody performed.
169
176
 
170
- A due value sorts before every future candidate, so it stays until a pass
171
- that genuinely looked at everything retires it — in practice a hook tick
172
- that widened to whole-history for axis 2 or 3, since carrying alert
173
- eligibility is what separates such a pass from a reporting-only one and the
174
- hook is the only production caller that carries it.
175
-
176
- It does NOT stay "until a pass that can act on it does", and that gap is
177
- open rather than closed: on a hook-only install with a steady enabled gate,
178
- unchanged rules and a quiet ledger, no qualifying pass ever runs and the
179
- instant is retained indefinitely. The cost is bounded — the tick stays
180
- bounded and fast, and the window is re-evaluated as soon as it goes
181
- ledger-dirty again, which for a live window is continuous — so the exposure
182
- is a future-clocked capture in a window that then goes permanently quiet
183
- never qualifying a threshold. Under-alerting, never a stall or a burst.
177
+ Epoch 1007's per-root map is maintained by the projector rather than this
178
+ helper. It closes the quiet-window gap by letting a hook tick replace only
179
+ the roots it evaluated; scalar-only legacy state still uses ``retain_due``
180
+ and the conservative full/deferred path.
184
181
  """
185
182
  candidates = [value for value in capture_times if value > now]
186
183
  if stored is not None and (retain_due or stored > now):