cctally 1.98.0 → 1.99.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.
@@ -37,10 +37,12 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
37
37
  """
38
38
  from __future__ import annotations
39
39
 
40
+ import contextlib
40
41
  import datetime as dt
41
42
  import os
42
43
  import pathlib
43
44
  import sys
45
+ import threading
44
46
  from dataclasses import dataclass
45
47
  from typing import Any, Callable, Iterable
46
48
 
@@ -476,13 +478,16 @@ def _aggregate_codex_buckets(
476
478
  })
477
479
  order = models_order.setdefault(key, [])
478
480
 
479
- cost = _calculate_codex_entry_cost(
480
- entry.model,
481
- entry.input_tokens,
482
- entry.cached_input_tokens,
483
- entry.output_tokens,
484
- entry.reasoning_output_tokens,
485
- speed=speed,
481
+ cost = (
482
+ entry.cost_usd if entry.cost_usd is not None
483
+ else _calculate_codex_entry_cost(
484
+ entry.model,
485
+ entry.input_tokens,
486
+ entry.cached_input_tokens,
487
+ entry.output_tokens,
488
+ entry.reasoning_output_tokens,
489
+ speed=speed,
490
+ )
486
491
  )
487
492
 
488
493
  bucket["input"] += entry.input_tokens
@@ -588,9 +593,108 @@ def _aggregate_codex_weekly(
588
593
  return _aggregate_codex_buckets(entries, key_fn=_week_key, speed=speed)
589
594
 
590
595
 
596
+ _CODEX_PATH_SCOPE = threading.local()
597
+
598
+
599
+ class CodexPathScope:
600
+ """One source build's Codex path-identity memo (#566 §5.1 item 1).
601
+
602
+ Holds the ordered session roots captured when the build opened, plus one
603
+ parsed identity per distinct ``source_path``. It holds plain tuples only —
604
+ never a connection, cursor, iterator or open transaction (spec §5.4) — and
605
+ dies with the ``codex_path_scope`` block that created it, so a
606
+ ``$CODEX_HOME`` or configuration change between builds can never be served
607
+ from it.
608
+
609
+ ``misses`` counts the parses actually performed, which is the bounded-work
610
+ quantity the suite asserts on: it can never exceed the number of distinct
611
+ session paths the build touched.
612
+ """
613
+
614
+ __slots__ = ("_roots", "_parts", "misses")
615
+
616
+ def __init__(self, roots: "Iterable[Any] | None" = None) -> None:
617
+ self._roots: "tuple[Any, ...] | None" = (
618
+ None if roots is None else tuple(roots)
619
+ )
620
+ self._parts: dict[str, tuple[str, str, str]] = {}
621
+ self.misses = 0
622
+
623
+ @property
624
+ def roots(self) -> "tuple[Any, ...]":
625
+ """The session roots this build resolved, resolved on FIRST USE.
626
+
627
+ Lazy, not eager. ``_codex_session_roots()`` runs an ``is_dir()`` syscall
628
+ per configured root, and the rooted fallback session view is
629
+ contractually filesystem-free — it derives identity lexically from
630
+ ``(source_root_key, source_path)`` and must never discover anything.
631
+ Resolving at scope entry made every build touch the filesystem even
632
+ when nothing in it ever asked for a path identity, which broke that
633
+ contract. Resolving on the first miss still pins one answer for the
634
+ whole build, which is what the memo key needs.
635
+ """
636
+ if self._roots is None:
637
+ self._roots = tuple(_cctally()._codex_session_roots())
638
+ return self._roots
639
+
640
+ def parts(self, source_path: str) -> tuple[str, str, str]:
641
+ hit = self._parts.get(source_path)
642
+ if hit is None:
643
+ hit = _session_path_parts_for_roots(self.roots, source_path)
644
+ self._parts[source_path] = hit
645
+ self.misses += 1
646
+ return hit
647
+
648
+
649
+ def active_codex_path_scope() -> "CodexPathScope | None":
650
+ """The scope this thread is building under, or None outside a build."""
651
+ return getattr(_CODEX_PATH_SCOPE, "scope", None)
652
+
653
+
654
+ @contextlib.contextmanager
655
+ def codex_path_scope(roots: "Iterable[Any] | None" = None):
656
+ """Open a build-scoped Codex path memo on this thread.
657
+
658
+ The roots are resolved ONCE per scope rather than once per entry, which is
659
+ the whole point: ``_codex_session_roots()`` runs an ``is_dir()`` syscall per
660
+ configured root, and the per-entry form ran it 191,304 times on a store
661
+ holding 2,324 distinct session files. Resolution is deferred to the first
662
+ path the scope is actually asked about, so a build that never needs a path
663
+ identity — the rooted fallback session view, which is contractually
664
+ filesystem-free — still touches nothing.
665
+
666
+ Scopes nest and restore, and the memo is thread-local, so the dashboard's
667
+ sync thread and its HTTP threads never share one.
668
+ """
669
+ scope = CodexPathScope(roots)
670
+ previous = getattr(_CODEX_PATH_SCOPE, "scope", None)
671
+ _CODEX_PATH_SCOPE.scope = scope
672
+ try:
673
+ yield scope
674
+ finally:
675
+ _CODEX_PATH_SCOPE.scope = previous
676
+
677
+
591
678
  def _session_path_parts(source_path: str) -> tuple[str, str, str]:
592
679
  """Return (session_id_path, session_file, directory) from a full path.
593
680
 
681
+ Inside a ``codex_path_scope`` the answer comes from that build's memo,
682
+ keyed on ``(scope roots, source_path)``. Outside one the roots are
683
+ resolved per call, which is the historic behaviour every CLI caller keeps.
684
+ """
685
+ scope = getattr(_CODEX_PATH_SCOPE, "scope", None)
686
+ if scope is not None:
687
+ return scope.parts(source_path)
688
+ return _session_path_parts_for_roots(
689
+ _cctally()._codex_session_roots(), source_path,
690
+ )
691
+
692
+
693
+ def _session_path_parts_for_roots(
694
+ roots: Iterable[Any], source_path: str,
695
+ ) -> tuple[str, str, str]:
696
+ """Return (session_id_path, session_file, directory) under ``roots``.
697
+
594
698
  session_id_path = relative path under the matched $CODEX_HOME session
595
699
  root with .jsonl stripped (e.g. "2025/12/25/rollout-...").
596
700
  session_file = basename without .jsonl extension.
@@ -604,7 +708,6 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
604
708
  files stay free of maintainer absolute paths), then basename. Direct-JSONL
605
709
  roots yield an id relative to <entry> itself (no sessions/ prefix).
606
710
  """
607
- roots = _cctally()._codex_session_roots()
608
711
  p = pathlib.Path(source_path)
609
712
  rel: pathlib.PurePath | None = None
610
713
  for root in roots:
@@ -659,9 +762,12 @@ def _aggregate_codex_sessions_keyed(
659
762
  "cost": 0.0, "models": {}, "models_order": [],
660
763
  "last": entry.timestamp,
661
764
  })
662
- cost = _calculate_codex_entry_cost(
663
- entry.model, entry.input_tokens, entry.cached_input_tokens,
664
- entry.output_tokens, entry.reasoning_output_tokens, speed=speed,
765
+ cost = (
766
+ entry.cost_usd if entry.cost_usd is not None
767
+ else _calculate_codex_entry_cost(
768
+ entry.model, entry.input_tokens, entry.cached_input_tokens,
769
+ entry.output_tokens, entry.reasoning_output_tokens, speed=speed,
770
+ )
665
771
  )
666
772
  sess["input"] += entry.input_tokens
667
773
  sess["cached_input"] += entry.cached_input_tokens
@@ -187,3 +187,63 @@ def compute_budget_status(inputs: BudgetInputs) -> BudgetStatus:
187
187
  low_confidence=low_confidence,
188
188
  crossed_thresholds=crossed,
189
189
  )
190
+
191
+
192
+ def budget_status_payload(
193
+ *,
194
+ period: str,
195
+ window_start_at: dt.datetime,
196
+ window_end_at: dt.datetime,
197
+ target_usd: float,
198
+ spent_usd: float,
199
+ recent_24h_usd: float,
200
+ now: dt.datetime,
201
+ alert_thresholds,
202
+ ) -> dict:
203
+ """The one dashboard-wire budget status, from ALREADY-RESOLVED bounds.
204
+
205
+ #556 S5 §3.1/§3.3. Both providers publish this exact object, so a single
206
+ client component renders either — which is only true if there is one
207
+ producer. It is a pure operation over resolved bounds and supplied spend
208
+ facts: it opens no database, reads no configuration, and never calls a bare
209
+ host-local ``astimezone()``.
210
+
211
+ Window resolution deliberately stays OUTSIDE this kernel. ``display.tz =
212
+ local`` has no DST-aware stdlib handle, so its per-instant resolution lives
213
+ in ``_cctally_forecast._resolve_local_calendar_window``, and
214
+ subscription-week resolution opens stats.db. Both are injected by the
215
+ caller; re-deriving either here would shift a window that straddles a DST
216
+ transition (issue #136) or make the kernel impure.
217
+
218
+ ``alert_thresholds`` is emitted as the normalized tuple the kernel consumed,
219
+ not as the caller's raw sequence.
220
+ """
221
+ inputs = BudgetInputs(
222
+ target_usd=float(target_usd),
223
+ spent_usd=float(spent_usd),
224
+ recent_24h_usd=float(recent_24h_usd),
225
+ week_start_at=window_start_at,
226
+ week_end_at=window_end_at,
227
+ now=now,
228
+ alert_thresholds=tuple(int(value) for value in alert_thresholds),
229
+ )
230
+ status = compute_budget_status(inputs)
231
+ return {
232
+ "period": period,
233
+ "budget_usd": inputs.target_usd,
234
+ "spent_usd": status.spent_usd,
235
+ "remaining_usd": status.remaining_usd,
236
+ "consumption_pct": status.consumption_pct,
237
+ "verdict": status.verdict,
238
+ "low_confidence": status.low_confidence,
239
+ "window_start_at": window_start_at.astimezone(dt.timezone.utc).isoformat(),
240
+ "window_end_at": window_end_at.astimezone(dt.timezone.utc).isoformat(),
241
+ "recent_24h_usd": inputs.recent_24h_usd,
242
+ "alert_thresholds": inputs.alert_thresholds,
243
+ "pace": {
244
+ "daily_usd": status.daily_pace_usd,
245
+ "projected_low_usd": status.projected_eow_low_usd,
246
+ "projected_high_usd": status.projected_eow_high_usd,
247
+ "week_avg_projection_usd": status.week_avg_projection_usd,
248
+ },
249
+ }
@@ -0,0 +1,259 @@
1
+ """Operator attribution of recorded Codex quota windows (pure kernel).
2
+
3
+ Spec: ``docs/superpowers/specs/2026-08-14-500-codex-window-attribution-design.md``
4
+
5
+ The operator asserts that a physical Codex quota window group belongs to an
6
+ account. This kernel decides, and only decides: which current group each
7
+ assertion owns, and what each observation's account becomes as a result. Every
8
+ SQL read and every write stays in the glue layer.
9
+
10
+ Two phases, and they are not interchangeable (spec §6.4.1). RESOLUTION decides
11
+ which current group an assertion owns and must always run against COMPLETE group
12
+ evidence, because witness matching is population-dependent. APPLICATION maps the
13
+ resolved ownership onto whatever rows the caller actually asked for. A bounded
14
+ read may show fewer rows than a full read; it must never show a different owner.
15
+
16
+ Binding is the four normalized axes plus the tolerance-connected component
17
+ witnessed by raw reset values (spec §5.1). The canonical anchor is NEVER matched
18
+ on: a later bridging observation can union two components and retire it.
19
+
20
+ A pure leaf: stdlib only, no ``_cctally_*`` import, no I/O, no writes — the same
21
+ contract ``_lib_codex_account_adoption`` carries, and for the same reason.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, replace
26
+ from typing import Iterable, Mapping
27
+
28
+ # The ONE leaf import this module makes, and it exists to avoid a fourth
29
+ # spelling of two constants that already have three (#500 review round 2,
30
+ # finding R2-3). ``_lib_codex_account_adoption`` imports nothing but the
31
+ # standard library, so binding it here introduces no cycle, no I/O and no
32
+ # ``_cctally_*`` dependency — the leaf contract is about those, not about
33
+ # whether one leaf may name another. ``_lib_journal`` and
34
+ # ``_lib_codex_account_adoption`` must stay respelled with respect to each other
35
+ # (the journal module is loaded on paths this kernel is not), which is what
36
+ # ``test_the_two_window_constants_have_one_value_across_both_leaves`` pins.
37
+ import _lib_codex_account_adoption as _adoption
38
+
39
+
40
+ #: Native length of the account-level Codex weekly quota window, in minutes.
41
+ ACCOUNT_WEEKLY_WINDOW_MINUTES = _adoption.ACCOUNT_WEEKLY_WINDOW_MINUTES
42
+
43
+ #: The reserved "account could not be determined" sentinel
44
+ #: (``_lib_accounts.UNATTRIBUTED``).
45
+ UNATTRIBUTED_SENTINEL = _adoption.UNATTRIBUTED_SENTINEL
46
+
47
+ #: Resolution outcomes. ``RESOLVED`` is the only one that applies anything.
48
+ RESOLVED = "resolved"
49
+ DORMANT = "dormant"
50
+ SPLIT = "split"
51
+ SUPPRESSED_NATIVE = "suppressed_native"
52
+ SUPPRESSED_CONFLICT = "suppressed_conflict"
53
+ #: Covers BOTH out-of-scope shapes the spec's precedence table pairs on one row:
54
+ #: a model-scoped pool (#373) and a window that is not account weekly quota.
55
+ #: They are one outcome because they have one remedy and one meaning — "this
56
+ #: window is not account-level standard quota, so no assertion can file it as
57
+ #: such" — and separating them would put two codes on a distinction no operator
58
+ #: acts on differently.
59
+ SUPPRESSED_MODEL_SCOPED = "suppressed_model_scoped"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class WindowAssertion:
64
+ """One active operator assertion, as the derived table holds it."""
65
+
66
+ op_id: str
67
+ account_key: str
68
+ source_root_key: str
69
+ logical_limit_key: str
70
+ observed_slot: str
71
+ window_minutes: int
72
+ raw_resets_at_utc: "frozenset[str]"
73
+
74
+ def __post_init__(self) -> None:
75
+ object.__setattr__(
76
+ self, "raw_resets_at_utc", frozenset(self.raw_resets_at_utc))
77
+ if not self.raw_resets_at_utc:
78
+ raise ValueError("an assertion must carry at least one witness")
79
+
80
+ @property
81
+ def axes(self) -> tuple:
82
+ return (self.source_root_key, self.logical_limit_key,
83
+ self.observed_slot, self.window_minutes)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class WindowGroup:
88
+ """One current physical window group, as the loader's complete evidence
89
+ describes it.
90
+
91
+ ``identified_accounts`` is the set of non-sentinel accounts natively present.
92
+ ``model_scoped`` is the authoritative ``_lib_codex_pools`` verdict, re-checked
93
+ at every evaluation because ``limit_name`` sits outside identity equality and
94
+ can change on re-materialization (spec §7).
95
+ """
96
+
97
+ group_key: tuple
98
+ source_root_key: str
99
+ logical_limit_key: str
100
+ observed_slot: str
101
+ window_minutes: int
102
+ raw_resets_at_utc: "frozenset[str]"
103
+ identified_accounts: "frozenset[str]" = frozenset()
104
+ model_scoped: bool = False
105
+
106
+ def __post_init__(self) -> None:
107
+ object.__setattr__(
108
+ self, "raw_resets_at_utc", frozenset(self.raw_resets_at_utc))
109
+ object.__setattr__(
110
+ self, "identified_accounts", frozenset(self.identified_accounts))
111
+
112
+ @property
113
+ def axes(self) -> tuple:
114
+ return (self.source_root_key, self.logical_limit_key,
115
+ self.observed_slot, self.window_minutes)
116
+
117
+ @property
118
+ def in_scope(self) -> bool:
119
+ return (self.window_minutes == ACCOUNT_WEEKLY_WINDOW_MINUTES
120
+ and not self.model_scoped)
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class AssertionResolution:
125
+ """What one assertion resolves to against the current population."""
126
+
127
+ op_id: str
128
+ account_key: str
129
+ outcome: str
130
+ group_key: "tuple | None" = None
131
+ matched_group_count: int = 0
132
+ conflicting_account_keys: "frozenset[str]" = frozenset()
133
+
134
+ def __post_init__(self) -> None:
135
+ object.__setattr__(
136
+ self, "conflicting_account_keys",
137
+ frozenset(self.conflicting_account_keys))
138
+
139
+ @property
140
+ def applies(self) -> bool:
141
+ return self.outcome == RESOLVED
142
+
143
+
144
+ def resolve_window_attributions(
145
+ assertions: Iterable[WindowAssertion],
146
+ groups: Iterable[WindowGroup],
147
+ ) -> "tuple[tuple[AssertionResolution, ...], Mapping[tuple, str]]":
148
+ """Resolve every assertion against COMPLETE current group evidence.
149
+
150
+ Returns ``(resolutions, ownership)`` where ``ownership`` maps a group key to
151
+ the account that assertion supplies. Only ``RESOLVED`` assertions appear in
152
+ ``ownership``.
153
+
154
+ Precedence, in order (spec §7):
155
+
156
+ 1. Zero matching groups is DORMANT — recorded, honest, applying to nothing.
157
+ This is the correct outcome when the underlying rollout evidence has
158
+ evaporated; the alternative would be fabricating a window to attach to.
159
+ 2. More than one matching group is SPLIT. The component has since divided in
160
+ a way the assertion cannot adjudicate, so it applies nothing.
161
+ 3. Model-scoped and non-weekly groups are out of scope entirely (#373).
162
+ 4. Native evidence always wins. A group already naming a real account is
163
+ authoritative and is never re-assigned, so the assertion is suppressed
164
+ rather than fighting it. This holds whether the native account agrees or
165
+ disagrees; a matching one simply has nothing to do.
166
+ 5. Two assertions naming DIFFERENT accounts for one group FAIL CLOSED and
167
+ neither applies. Journal order is the wrong tiebreaker for an operator
168
+ assertion: a stale or mistaken second assertion would silently displace a
169
+ correct first one with no signal. A visible conflict forces a retraction.
170
+
171
+ Order-independent in its verdicts: the conflict pass below runs after every
172
+ assertion has been matched, so two assertions disagreeing about one group
173
+ reach ``SUPPRESSED_CONFLICT`` whichever order they arrive in.
174
+ """
175
+ group_list = tuple(groups)
176
+ resolutions: "list[AssertionResolution]" = []
177
+ claims: "dict[tuple, set[str]]" = {}
178
+
179
+ for assertion in assertions:
180
+ candidates = [
181
+ group for group in group_list
182
+ if group.axes == assertion.axes
183
+ and (group.raw_resets_at_utc & assertion.raw_resets_at_utc)
184
+ ]
185
+ if not candidates:
186
+ resolutions.append(AssertionResolution(
187
+ op_id=assertion.op_id, account_key=assertion.account_key,
188
+ outcome=DORMANT))
189
+ continue
190
+ if len(candidates) > 1:
191
+ resolutions.append(AssertionResolution(
192
+ op_id=assertion.op_id, account_key=assertion.account_key,
193
+ outcome=SPLIT, matched_group_count=len(candidates)))
194
+ continue
195
+ group = candidates[0]
196
+ if not group.in_scope:
197
+ resolutions.append(AssertionResolution(
198
+ op_id=assertion.op_id, account_key=assertion.account_key,
199
+ outcome=SUPPRESSED_MODEL_SCOPED, group_key=group.group_key,
200
+ matched_group_count=1))
201
+ continue
202
+ if group.identified_accounts:
203
+ resolutions.append(AssertionResolution(
204
+ op_id=assertion.op_id, account_key=assertion.account_key,
205
+ outcome=SUPPRESSED_NATIVE, group_key=group.group_key,
206
+ matched_group_count=1,
207
+ conflicting_account_keys=group.identified_accounts))
208
+ continue
209
+ claims.setdefault(group.group_key, set()).add(assertion.account_key)
210
+ resolutions.append(AssertionResolution(
211
+ op_id=assertion.op_id, account_key=assertion.account_key,
212
+ outcome=RESOLVED, group_key=group.group_key, matched_group_count=1))
213
+
214
+ final: "list[AssertionResolution]" = []
215
+ ownership: "dict[tuple, str]" = {}
216
+ for resolution in resolutions:
217
+ if not resolution.applies:
218
+ final.append(resolution)
219
+ continue
220
+ claimants = claims.get(resolution.group_key, set())
221
+ if len(claimants) > 1:
222
+ final.append(replace(
223
+ resolution, outcome=SUPPRESSED_CONFLICT,
224
+ conflicting_account_keys=frozenset(claimants)))
225
+ continue
226
+ ownership[resolution.group_key] = resolution.account_key
227
+ final.append(resolution)
228
+ return tuple(final), ownership
229
+
230
+
231
+ def apply_resolution(
232
+ ownership: "Mapping[tuple, str]",
233
+ observations: Iterable,
234
+ group_key_of,
235
+ account_key_of,
236
+ with_account,
237
+ ) -> tuple:
238
+ """Stamp resolved ownership onto currently-unattributed observations.
239
+
240
+ Deliberately caller-parameterized on ``group_key_of`` / ``account_key_of`` /
241
+ ``with_account`` so this leaf never imports the observation type. Runs
242
+ BEFORE the ordinary continuity fold, which then finds nothing left to adopt
243
+ in an attributed group.
244
+
245
+ An already-identified observation is never re-stamped, matching
246
+ ``adopt_unidentified_observations``. ``None`` and the empty string are
247
+ admitted as unattributed alongside the literal sentinel, because the cache
248
+ column is nullable and all three spellings mean the same thing on the read
249
+ path (``entry_is_unattributed`` states the same rule for the spend axis).
250
+ """
251
+ result = []
252
+ for observation in observations:
253
+ account = account_key_of(observation)
254
+ if account and account != UNATTRIBUTED_SENTINEL:
255
+ result.append(observation)
256
+ continue
257
+ owner = ownership.get(group_key_of(observation))
258
+ result.append(with_account(observation, owner) if owner else observation)
259
+ return tuple(result)
@@ -68,7 +68,32 @@ CapabilityStatus = Literal[
68
68
  # remains present, so a pre-v7 client reading `created_at` keeps working — but
69
69
  # the VALUE it reads changed on two of the three Codex legs, which is why this
70
70
  # is a version bump and not a silent addition.
71
- SOURCE_SCHEMA_VERSION = 7
71
+ # 7 -> 8 (#556 S5): the Claude provider's `capabilities.budget` detail changed
72
+ # MEANING. It said `subscription-week` unconditionally, as a constant, while
73
+ # Codex advertised `calendar-period`; it now names the CONFIGURED period, so on
74
+ # an install with `budget.period = calendar-month` the same field reads
75
+ # `calendar-month`. `docs/cli-contract.md:58` says an optional additive key
76
+ # alone does not bump but a changed value or meaning does, which is exactly how
77
+ # S1, S2 and S3 justified theirs. The Claude source also gained an optional
78
+ # `data.budget.status` — the same object Codex publishes — plus its
79
+ # `status_unavailable` / `not_configured` siblings, and the Codex budget domain
80
+ # gained the same optional `status_unavailable` sibling. Those are additive and
81
+ # omitted when inapplicable, so an install with no budget configured publishes
82
+ # byte-identical bytes and the bump rests on the capability detail alone.
83
+ # `sources.all.capabilities.budget` is a DIFFERENT field and is unchanged at
84
+ # `not_applicable` / `provider-native`: rendering two provider-native child
85
+ # budgets side by side does not create an All-level budget quantity.
86
+ # 8 -> 9 (#564): on a decorated Codex provider, a card whose totals do not come
87
+ # from a live weekly cycle — a real account whose boundary is not live, and the
88
+ # unattributed sentinel — now covers one native cycle width ending at `now`
89
+ # instead of the whole ~30-day accounting range. `accounts[].spendUsd`, its five
90
+ # token siblings, and the decorated `hero.cost_usd` / `hero.total_tokens` summed
91
+ # from them therefore changed VALUE without changing shape, which is the same
92
+ # class of change the 4 -> 5 entry records. Those cards additionally publish the
93
+ # optional `spendWindow` bounds, which a cycle-bounded card omits. No client
94
+ # branches on this number; after an in-place `execvp` update a still-loaded old
95
+ # client renders the new figures under old copy until it reloads.
96
+ SOURCE_SCHEMA_VERSION = 9
72
97
  DEFAULT_SOURCE = "claude"
73
98
  SOURCE_ORDER = ("claude", "codex", "all")
74
99
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -3351,6 +3351,75 @@ def _check_accounts_codex_reset_anchors(s: DoctorState) -> CheckResult:
3351
3351
  )
3352
3352
 
3353
3353
 
3354
+ #: The `accounts.codex_window_attribution` conditions, in the order the summary
3355
+ #: names them. Each is resolved by retracting or re-asserting, which is why the
3356
+ #: leg is WARN in every case rather than FAIL: none of them is a broken install.
3357
+ _WINDOW_ATTRIBUTION_CONDITIONS: tuple[tuple[str, str], ...] = (
3358
+ ("cursor_behind", "the derived index is behind the journal"),
3359
+ ("dormant", "dormant assertion(s) matching no current window group"),
3360
+ ("split", "assertion(s) matching more than one group after a split"),
3361
+ ("conflicting", "assertion(s) in conflict with native evidence or each other"),
3362
+ ("model_scoped", "assertion(s) over a window that is not account weekly quota"),
3363
+ ("unrecoverable_baselines",
3364
+ "rollout file(s) whose per-file decision cannot be recovered"),
3365
+ )
3366
+
3367
+
3368
+ def _check_accounts_codex_window_attribution(s: DoctorState) -> CheckResult:
3369
+ """Operator attribution of recorded Codex windows (#500 §9).
3370
+
3371
+ WARN in every case. The four spec conditions are a stale derived cursor
3372
+ (attribution is being UNDER-applied), a dormant assertion, a split
3373
+ assertion, and an assertion in conflict; the fifth and sixth are a
3374
+ model-scoped window an assertion can never file as account weekly quota, and
3375
+ a rollout file whose per-file baseline cannot be recovered — which matters
3376
+ because a retraction restores spend to that baseline, so an unrecoverable
3377
+ one is silently indistinguishable from "no decision was ever made".
3378
+ """
3379
+ st = (s.accounts_state or {}).get("codex_window_attribution")
3380
+ if not isinstance(st, dict):
3381
+ # No cache.db, a cache too old to carry the derived table, or a probe
3382
+ # this run declined. Nothing to under-apply, so nothing to report.
3383
+ return CheckResult(
3384
+ id="accounts.codex_window_attribution",
3385
+ title="Codex window attribution", severity="ok",
3386
+ summary="no operator window attribution recorded",
3387
+ remediation=None, details={"active": 0, "retracted": 0},
3388
+ )
3389
+ details = dict(st)
3390
+ findings = []
3391
+ for name, phrase in _WINDOW_ATTRIBUTION_CONDITIONS:
3392
+ value = st.get(name)
3393
+ if name == "cursor_behind":
3394
+ if value:
3395
+ findings.append(phrase)
3396
+ continue
3397
+ try:
3398
+ count = int(value or 0)
3399
+ except (TypeError, ValueError):
3400
+ count = 0
3401
+ if count > 0:
3402
+ findings.append(f"{count} {phrase}")
3403
+ if not findings:
3404
+ return CheckResult(
3405
+ id="accounts.codex_window_attribution",
3406
+ title="Codex window attribution", severity="ok",
3407
+ summary=(f"{int(st.get('active') or 0)} active window "
3408
+ "attribution(s), all applying cleanly"),
3409
+ remediation=None, details=details,
3410
+ )
3411
+ return CheckResult(
3412
+ id="accounts.codex_window_attribution",
3413
+ title="Codex window attribution", severity="warn",
3414
+ summary="; ".join(findings),
3415
+ remediation=(
3416
+ "Run `cctally account attribute <ref> --since <iso> --retract` to "
3417
+ "clear an assertion that no longer applies, or re-assert it over "
3418
+ "the group it should name"),
3419
+ details=details,
3420
+ )
3421
+
3422
+
3354
3423
  _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
3355
3424
  ("install", "Install", (
3356
3425
  ("install.mode", "_check_install_dev_mode"),
@@ -3423,6 +3492,8 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
3423
3492
  ("accounts.freshness", "_check_accounts_freshness"),
3424
3493
  ("accounts.attribution", "_check_accounts_attribution"),
3425
3494
  ("accounts.codex_reset_anchors", "_check_accounts_codex_reset_anchors"),
3495
+ ("accounts.codex_window_attribution",
3496
+ "_check_accounts_codex_window_attribution"),
3426
3497
  )),
3427
3498
  ("pricing", "Pricing", (
3428
3499
  ("pricing.coverage", "_check_pricing_coverage"),