cctally 1.65.0 → 1.67.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 (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -0,0 +1,254 @@
1
+ """Pure, I/O-free anonymization kernel for conversation sharing (#281 S4).
2
+
3
+ The single scrub chokepoint for the "share this session" flow. Mirrors the
4
+ ``_lib_share.py`` / ``_lib_conversation_export.py`` purity contract: no DB, no
5
+ filesystem, no env reads, no clock — every input is injected. The whole export
6
+ (and every per-card copy) passes through :func:`scrub_text`, so no field can
7
+ bypass the redaction, and one mechanism serves both the server exports and the
8
+ client-side copies (the TS applier is a dumb executor of :func:`plan_to_wire`).
9
+
10
+ Two passes, in order:
11
+
12
+ 1. **Identity** — a SINGLE pass over the original text. All identity tokens
13
+ (observed project roots → ``project-N``, their dash-encoded dirname variants
14
+ → ``-project-N``, home dirs → ``~``, usernames → ``user``, and project labels
15
+ → their root's ``project-N``) compile into ONE alternation regex; each match
16
+ is resolved through a lookup map. Because a replacement's output is never
17
+ re-scanned, generated tokens cannot be re-matched — this is what makes
18
+ ``scrub_text(scrub_text(x)) == scrub_text(x)`` structural, not aspirational.
19
+ Bounded tokens (labels, usernames) carry an explicit shared ASCII boundary
20
+ (``A-Za-z0-9_.-`` single-char lookarounds, deliberately NOT ``\\b``); the
21
+ trailing ``-`` in that class is what protects a generated ``project-N`` from
22
+ re-matching the bare ``project`` label. A lookup miss (impossible by
23
+ construction) yields the fixed sentinel ``ANON_SENTINEL`` — never passthrough.
24
+
25
+ **Trie compilation (perf, byte-identical).** The tokens are globally ordered
26
+ longest-text-first, so a flat ``t1|t2|…`` alternation is *longest-match-wins*
27
+ (Python ``re`` takes the first listed alternative that matches, and the first
28
+ that can match at any position is the longest, since two equal-length literals
29
+ cannot both match one position). A flat alternation over ~1300 tokens is
30
+ O(text × alternatives), though — every alternative is retried at every
31
+ position. So :func:`_build_identity_pattern` collapses each MAXIMAL
32
+ CONSECUTIVE RUN of same-boundedness tokens into a trie-structured subgroup
33
+ (shared prefixes fold into nested ``(?:…)`` groups; a node that is both
34
+ end-of-word and has children makes the children group optional-greedy so the
35
+ longest branch wins). An unbounded run is a bare trie; a bounded run — whose
36
+ members share the *same* lookarounds — is one trie wrapped in a single
37
+ ``(?<![B])(?:…)(?![B])`` guard (the trailing lookahead backtracks through the
38
+ trie's greedy optionals to the longest boundary-clearing member, exactly as
39
+ the per-singleton flat form would). Bounded runs merge too because real DB
40
+ plans are heavily bounded (~26% project-label tokens), so leaving them
41
+ singleton would keep the identity pass O(text × labels). This is
42
+ byte-identical to the flat alternation *by
43
+ construction*: within a chunk the trie yields the longest matching member;
44
+ across chunks/singletons the first alternative with any match wins, and
45
+ because every token in an earlier chunk is ≥ every later alternative in
46
+ length, "first chunk that matches" is exactly "longest flat alternative that
47
+ matches" (proof + equivalence property test in ``test_conversation_anon``).
48
+ ``plan_to_wire`` and the TS applier are UNTOUCHED (per-card texts are small).
49
+
50
+ 2. **Secrets** — a small documented, high-precision pattern set
51
+ (:data:`SECRET_PATTERNS`), applied AFTER identity. Each pattern is a
52
+ cross-language descriptor (valid in BOTH Python ``re`` and JS ``RegExp``): no
53
+ inline flags, no named groups, no lookbehind, no ``$n`` templates. The
54
+ effective replacement is ``(group 1 if keep_group_1 else "") + [REDACTED:<name>]``.
55
+
56
+ **Honest guarantee (no over-claiming, #280 privacy):** the tested claim is
57
+ "zero *known* identity tokens survive" — observed project roots/labels, home
58
+ dirs, usernames, plus the documented secret patterns. NOT comprehensively
59
+ detected or guaranteed removed in v1: emails, IPs, hostnames, remote URLs,
60
+ session IDs, and any project identity absent from cctally's DB. Best-effort over
61
+ known tokens; review before sharing. See ``docs/commands/transcript.md``.
62
+ """
63
+ from __future__ import annotations
64
+
65
+ import dataclasses
66
+ import re
67
+
68
+ ANON_SENTINEL = "(unknown)"
69
+ # Shared Python/JS boundary class for bounded tokens (deliberately NOT `\b`,
70
+ # whose Unicode behavior differs across runtimes). Single-char lookarounds are
71
+ # fixed-width — legal in Python `re` and identical in JS.
72
+ _BOUNDARY = "A-Za-z0-9_.-"
73
+
74
+
75
+ @dataclasses.dataclass(frozen=True)
76
+ class SecretPattern:
77
+ """A cross-language secret descriptor. ``source`` must compile in BOTH
78
+ Python ``re`` and JS ``RegExp`` (no inline flags / named groups / lookbehind).
79
+ ``ignore_case`` is a language-neutral boolean (``re.IGNORECASE`` / JS ``i``);
80
+ ``keep_group_1`` preserves capture group 1 (the key + separator) before the
81
+ ``[REDACTED:<name>]`` marker."""
82
+ name: str
83
+ source: str
84
+ ignore_case: bool
85
+ keep_group_1: bool
86
+
87
+
88
+ @dataclasses.dataclass(frozen=True)
89
+ class AnonPlan:
90
+ """An ordered identity token list (``(text, replacement, bounded)``,
91
+ longest-text-first, tie lexical) plus the secret-pattern tuple."""
92
+ tokens: tuple
93
+ patterns: tuple
94
+
95
+
96
+ # Ordered: authorization-header BEFORE bearer-token (an ``Authorization: Bearer
97
+ # …`` line must redact the whole value, not just the token after ``Bearer``);
98
+ # anthropic-key BEFORE generic-sk-key (the specific ``sk-ant-…`` name wins).
99
+ SECRET_PATTERNS = (
100
+ SecretPattern("authorization-header", r"(\bAuthorization:[ \t]*)\S[^\r\n]*", True, True),
101
+ SecretPattern("bearer-token", r"(\bBearer[ \t]+)[A-Za-z0-9._~+/=-]{16,}", True, True),
102
+ SecretPattern("anthropic-key", r"sk-ant-[A-Za-z0-9_-]{8,}", False, False),
103
+ SecretPattern("generic-sk-key", r"sk-[A-Za-z0-9_-]{20,}", False, False),
104
+ SecretPattern("github-token", r"(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{16,}", False, False),
105
+ SecretPattern("aws-access-key", r"AKIA[0-9A-Z]{16}", False, False),
106
+ SecretPattern("slack-token", r"xox[baprs]-[A-Za-z0-9-]{10,}", False, False),
107
+ SecretPattern(
108
+ "secret-assignment",
109
+ r"(\b(?:api[_-]?key|secret|token|passwd|password)\b[ \t]*[=:][ \t]*)"
110
+ r"(?:\"[^\"\r\n]{6,}\"|'[^'\r\n]{6,}'|[^\s\"']{6,})", True, True),
111
+ )
112
+
113
+
114
+ def _dash_encode(path: str) -> str:
115
+ """``/a/b`` -> ``-a-b`` — the Claude project-dir encoding (as it appears in
116
+ ``~/.claude/projects/<encoded>`` and scratchpad paths)."""
117
+ return path.replace("/", "-")
118
+
119
+
120
+ def build_anon_plan(*, project_roots, home_dirs, usernames) -> AnonPlan:
121
+ """Build an :class:`AnonPlan` from injected identity sources.
122
+
123
+ ``project_roots`` maps root path -> label. Numbering is deterministic,
124
+ lexical by root path (``project-1..N``), so CLI and HTTP builds from the same
125
+ DB produce identical plans (byte-parity depends on this). Duplicate basenames
126
+ map the bare label to the LOWEST-numbered of their ``project-N`` targets
127
+ (``setdefault`` over the sorted roots).
128
+ """
129
+ tokens: dict = {} # (text, bounded) -> replacement
130
+ label_target: dict = {} # label -> project-N (lowest-N wins)
131
+ for n, root in enumerate(sorted(project_roots), 1):
132
+ if not root:
133
+ continue
134
+ rep = f"project-{n}"
135
+ tokens[(root, False)] = rep
136
+ tokens[(_dash_encode(root), False)] = f"-{rep}"
137
+ label = project_roots[root]
138
+ if label:
139
+ label_target.setdefault(label, rep) # lowest-N claims the label
140
+ for label, rep in label_target.items():
141
+ tokens[(label, True)] = rep
142
+ for h in home_dirs:
143
+ if h:
144
+ tokens.setdefault((h, False), "~") # a root already present wins
145
+ for u in usernames:
146
+ if u:
147
+ tokens.setdefault((u, True), "user")
148
+ ordered = tuple(sorted(
149
+ ((t, rep, b) for (t, b), rep in tokens.items()),
150
+ key=lambda x: (-len(x[0]), x[0])))
151
+ return AnonPlan(tokens=ordered, patterns=SECRET_PATTERNS)
152
+
153
+
154
+ # End-of-word sentinel for the trie (no token is the empty string, so "" cannot
155
+ # collide with a single-character child key).
156
+ _WORD_END = ""
157
+
158
+
159
+ def _trie_to_pattern(node: dict) -> str:
160
+ """Render one trie node to a regex fragment that matches the LONGEST word
161
+ branch reachable from it. Children are keyed by distinct next-characters, so
162
+ the branches of a ``(?:…|…)`` group are mutually exclusive and their order is
163
+ irrelevant. When the node is also end-of-word AND has children, the whole
164
+ descent is wrapped optional-greedy (``(?:…)?``) so the longest branch is tried
165
+ first and a diverging tail backtracks to the shorter complete word. The regex
166
+ therefore only ever matches a COMPLETE inserted word (every accept state is an
167
+ end-of-word node), so ``m.group(0)`` is always a live lookup key."""
168
+ end = _WORD_END in node
169
+ alts = [re.escape(ch) + _trie_to_pattern(node[ch])
170
+ for ch in node if ch != _WORD_END]
171
+ if not alts:
172
+ return "" # pure leaf: the word ends here
173
+ if end:
174
+ return "(?:" + "|".join(alts) + ")?"
175
+ if len(alts) == 1:
176
+ return alts[0]
177
+ return "(?:" + "|".join(alts) + ")"
178
+
179
+
180
+ def _build_identity_pattern(tokens: tuple) -> str:
181
+ """Compile the ordered identity tokens to ONE alternation string. Each MAXIMAL
182
+ CONSECUTIVE RUN of same-boundedness tokens collapses into a trie subgroup
183
+ (:func:`_trie_to_pattern`); an unbounded run stays a bare trie, a bounded run
184
+ is wrapped once in the shared ``(?<![B])(?:…)(?![B])`` boundary guard. Matches
185
+ are byte-identical to a flat longest-first ``t1|t2|…`` alternation of the same
186
+ tokens (unbounded literal, bounded boundary-guarded singleton) — the
187
+ equivalence property test proves it against exactly that flat reference.
188
+
189
+ **Bounded runs merge too (byte-identical).** Every bounded token carries the
190
+ *same* ``_BOUNDARY`` lookarounds, so a run's members all start at the match
191
+ position and share one leading ``(?<![B])``; the trailing ``(?![B])`` sits
192
+ after the trie, and Python backtracks through the trie's greedy optionals to
193
+ the longest member whose trailing char clears the boundary — exactly the
194
+ member a flat ``…|(?<![B])bi(?![B])|…`` alternation (longest-first) would pick.
195
+ This matters on real data: an observed DB plan is ~26% bounded project-label
196
+ tokens (336 of 1286), and leaving those as singletons keeps the identity pass
197
+ O(text × labels) — the very blow-up the trie exists to remove."""
198
+ parts: list = []
199
+ run: list = [] # texts of the current same-boundedness run
200
+ run_bounded = False # boundedness of the current run
201
+
202
+ def _flush():
203
+ if not run:
204
+ return
205
+ root: dict = {}
206
+ for word in run:
207
+ node = root
208
+ for ch in word:
209
+ node = node.setdefault(ch, {})
210
+ node[_WORD_END] = True
211
+ frag = _trie_to_pattern(root)
212
+ if run_bounded:
213
+ frag = f"(?<![{_BOUNDARY}])(?:{frag})(?![{_BOUNDARY}])"
214
+ parts.append(frag)
215
+ run.clear()
216
+
217
+ for t, _rep, bounded in tokens:
218
+ if run and bounded != run_bounded:
219
+ _flush() # boundary between an unbounded/bounded run
220
+ run_bounded = bounded
221
+ run.append(t)
222
+ _flush()
223
+ return "|".join(parts)
224
+
225
+
226
+ def scrub_text(text: str, plan: AnonPlan) -> str:
227
+ """Identity single-pass, then secrets. Idempotent (tested)."""
228
+ if plan.tokens:
229
+ lookup = {t: rep for t, rep, _ in plan.tokens}
230
+ text = re.compile(_build_identity_pattern(plan.tokens)).sub(
231
+ lambda m: lookup.get(m.group(0), ANON_SENTINEL), text)
232
+ for sp in plan.patterns:
233
+ flags = re.IGNORECASE if sp.ignore_case else 0
234
+ rx = re.compile(sp.source, flags)
235
+
236
+ def _repl(m, _sp=sp):
237
+ prefix = m.group(1) if _sp.keep_group_1 else ""
238
+ return f"{prefix}[REDACTED:{_sp.name}]"
239
+
240
+ text = rx.sub(_repl, text)
241
+ return text
242
+
243
+
244
+ def plan_to_wire(plan: AnonPlan) -> dict:
245
+ """Project an :class:`AnonPlan` to the JSON wire contract the TS applier
246
+ executes: ``{"tokens": [{"text","replacement","bounded"}...],
247
+ "patterns": [{"name","source","ignoreCase","keepGroup1"}...]}``."""
248
+ return {
249
+ "tokens": [{"text": t, "replacement": rep, "bounded": b}
250
+ for t, rep, b in plan.tokens],
251
+ "patterns": [{"name": sp.name, "source": sp.source,
252
+ "ignoreCase": sp.ignore_case, "keepGroup1": sp.keep_group_1}
253
+ for sp in plan.patterns],
254
+ }
@@ -2385,6 +2385,72 @@ def get_conversation_export(conn, session_id, scope):
2385
2385
  title=title, session_id=session_id)
2386
2386
 
2387
2387
 
2388
+ def conversation_exists(conn, session_id) -> bool:
2389
+ """Cheap existence probe (#281 S4) — True iff any ``conversation_messages``
2390
+ row carries ``session_id``. Mirrors ``_assemble_session``'s own existence
2391
+ check; the ``/anon-map`` route uses it for the sibling-parity 404 on an
2392
+ unknown id. An ``OperationalError`` (no table) reads as absent."""
2393
+ try:
2394
+ row = conn.execute(
2395
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
2396
+ (session_id,)).fetchone()
2397
+ except sqlite3.OperationalError:
2398
+ return False
2399
+ return row is not None
2400
+
2401
+
2402
+ def build_anon_plan_for_db(conn, *, home_dir):
2403
+ """Assemble an :class:`_lib_conversation_anon.AnonPlan` from the DB's OBSERVED
2404
+ identity (#281 S4, spec §4). Thin I/O glue: SELECTs + ``_project_label`` +
2405
+ one ``build_anon_plan`` call — the ONE helper both the HTTP handlers and the
2406
+ CLI route through, so CLI↔HTTP byte-parity is structural.
2407
+
2408
+ Sources: distinct non-empty ``conversation_messages.cwd`` (the conversation
2409
+ layer's source of truth) UNION ``session_files.project_path`` (legacy/lossy
2410
+ fallback only). Labels derive per root via ``_project_label`` (observed CWDs
2411
+ — no git-root resolution in v1). Home-dir prefixes = the caller-supplied
2412
+ ``home_dir`` (``os.path.expanduser('~')`` at the call site — the pure kernel
2413
+ never reads env) plus ``/Users/<x>`` / ``/home/<x>`` prefixes derived from
2414
+ observed CWDs; usernames are the basenames of those home dirs.
2415
+ """
2416
+ from _lib_conversation_anon import build_anon_plan
2417
+ cwds: set = set()
2418
+ try:
2419
+ for (cwd,) in conn.execute(
2420
+ "SELECT DISTINCT cwd FROM conversation_messages "
2421
+ "WHERE cwd IS NOT NULL AND cwd != ''"):
2422
+ if cwd:
2423
+ cwds.add(cwd)
2424
+ except sqlite3.OperationalError:
2425
+ pass
2426
+ try:
2427
+ for (pp,) in conn.execute(
2428
+ "SELECT DISTINCT project_path FROM session_files "
2429
+ "WHERE project_path IS NOT NULL AND project_path != ''"):
2430
+ if pp:
2431
+ cwds.add(pp)
2432
+ except sqlite3.OperationalError:
2433
+ pass
2434
+ project_roots = {c: _project_label(c) for c in cwds}
2435
+ home_dirs: set = set()
2436
+ usernames: set = set()
2437
+ if home_dir:
2438
+ home_dirs.add(home_dir)
2439
+ u = os.path.basename(home_dir.rstrip("/"))
2440
+ if u:
2441
+ usernames.add(u)
2442
+ for c in cwds:
2443
+ for prefix in ("/Users/", "/home/"):
2444
+ if c.startswith(prefix):
2445
+ user = c[len(prefix):].split("/", 1)[0]
2446
+ if user:
2447
+ home_dirs.add(prefix + user)
2448
+ usernames.add(user)
2449
+ return build_anon_plan(
2450
+ project_roots=project_roots,
2451
+ home_dirs=sorted(home_dirs), usernames=sorted(usernames))
2452
+
2453
+
2388
2454
  _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
2389
2455
 
2390
2456
 
@@ -0,0 +1,133 @@
1
+ """Credit-plan decision kernel for cctally's record-credit path.
2
+
3
+ Pure-fn layer (no I/O at import time): holds the percent-ingress
4
+ sanitizer and the `record-credit` plan builder — values in, a frozen
5
+ `CreditPlan` (or a `ValueError`) out. Every DB read, file write, and
6
+ `eprint` for the in-place-credit machinery stays in the I/O sibling
7
+ `bin/_cctally_record.py`; this module is imported by that sibling (and
8
+ re-exported on the `cctally` namespace via `bin/cctally`), so the
9
+ existing `ns["_build_credit_plan"]` / `ns["_normalize_percent"]` /
10
+ `cctally.CreditPlan` read paths resolve unchanged.
11
+
12
+ `_normalize_percent` is the single chokepoint that flushes IEEE 754 ULP
13
+ noise out of ingress percents; `_build_credit_plan` validates the
14
+ record-credit inputs and floors the effective moment to the hour. Both
15
+ are pure and stdlib-only apart from two kernel imports
16
+ (`parse_iso_datetime`, `_canonicalize_optional_iso` from `_cctally_core`)
17
+ and the pure hour-floor from `_lib_blocks` — no cross-sibling I/O and no
18
+ `cctally` back-import.
19
+
20
+ Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import datetime as dt
25
+ from dataclasses import dataclass
26
+
27
+ from _cctally_core import parse_iso_datetime, _canonicalize_optional_iso
28
+ from _lib_blocks import _floor_to_hour
29
+
30
+
31
+ _PERCENT_NORMALIZE_DECIMALS = 10
32
+
33
+
34
+ def _normalize_percent(value: "float | int | None") -> "float | None":
35
+ """Flush IEEE 754 ULP noise out of an ingress percent value.
36
+
37
+ Single chokepoint applied at every site where a raw percent enters
38
+ cctally's runtime path (OAuth fetch, hook-tick OAuth refresh, and
39
+ the cmd_record_usage CLI ingress). Downstream consumers — HWM
40
+ files, ``weekly_usage_snapshots.{weekly,five_hour}_percent`` REAL
41
+ columns, ``five_hour_blocks.final_five_hour_percent``, milestone
42
+ crossing values, and the SSE envelope's ``used_percent`` field —
43
+ all read the cleaned value, so a single round here stops
44
+ ``5h=7.000000000000001`` style strings from reaching any log or
45
+ serialized surface.
46
+
47
+ ``None`` is the canonical absent-percent sentinel; preserve it
48
+ unchanged so the optional-5h branches stay simple.
49
+ """
50
+ if value is None:
51
+ return None
52
+ return round(float(value), _PERCENT_NORMALIZE_DECIMALS)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CreditPlan:
57
+ week_start_date: str
58
+ week_start_at: str
59
+ week_end_at: str
60
+ cur_end_canon: str
61
+ from_pct: float
62
+ from_source: str # "hwm" | "explicit" | "prior_credit"
63
+ to_pct: float
64
+ effective_iso: str # weekly_credit_floors.effective_at_utc (floored to hour)
65
+ captured_iso: str # synthetic snapshot captured_at_utc (un-floored), 'Z'
66
+
67
+
68
+ def _parse_credit_at(value, now):
69
+ """Parse --at as an aware UTC datetime; default to `now`. Naive => UTC
70
+ (mirrors bin/_cctally_five_hour.py:88), NOT parse_iso_datetime (host-local)."""
71
+ if value is None:
72
+ return now
73
+ try:
74
+ parsed = dt.datetime.fromisoformat(value)
75
+ except ValueError as e:
76
+ raise ValueError(f"--at: {e}") from e
77
+ if parsed.tzinfo is None:
78
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
79
+ return parsed.astimezone(dt.timezone.utc)
80
+
81
+
82
+ def _build_credit_plan(*, week_start_date, week_start_at, week_end_at,
83
+ from_pct, from_source, to_pct, at_dt, now,
84
+ effective_override=None):
85
+ """Validate inputs and build a CreditPlan. Pure (no DB/file I/O).
86
+ Raises ValueError(msg) on any violation — caller maps to exit 2.
87
+
88
+ `effective_override` (an ISO string) is the completion-path reuse of an
89
+ EXISTING `weekly_credit_floors.effective_at_utc` (spec §4a): when present,
90
+ the plan's effective is that value rather than `floor_to_hour(at)`, so a
91
+ rerun of a half-applied credit at a later wall-clock keeps the original
92
+ floor moment and never leaks a stale pre-credit replay into the floored
93
+ MAX. The synthetic snapshot's captured timestamp stays the un-floored
94
+ `at` either way."""
95
+ to_pct = _normalize_percent(to_pct)
96
+ from_pct = _normalize_percent(from_pct)
97
+ # Defensive None-guard (issue #212 N3). `_normalize_percent` returns None
98
+ # for a None input. The CLI never reaches here with None (`--to` is
99
+ # required + type=float; `--from` always resolves to a float or the caller
100
+ # errors out first), but this is a public pure helper called directly by
101
+ # tests and any future non-CLI caller — surface a None as a clear ValueError
102
+ # (caller -> exit 2) instead of a TypeError from the `0.0 <= None` compare
103
+ # immediately below.
104
+ if to_pct is None or from_pct is None:
105
+ raise ValueError("--to/--from must be numeric")
106
+ if not (0.0 <= to_pct <= 100.0) or not (0.0 <= from_pct <= 100.0):
107
+ raise ValueError("--to/--from must be in [0, 100]")
108
+ if to_pct >= from_pct:
109
+ raise ValueError(f"not a credit: to >= from ({to_pct} >= {from_pct})")
110
+ ws = parse_iso_datetime(week_start_at, "week_start_at")
111
+ we = parse_iso_datetime(week_end_at, "week_end_at")
112
+ if not (ws <= at_dt < we):
113
+ raise ValueError(f"--at {at_dt.isoformat()} is outside the week window")
114
+ if at_dt > now:
115
+ raise ValueError("--at is in the future")
116
+ if effective_override is not None:
117
+ effective_iso = parse_iso_datetime(
118
+ effective_override, "effective_override"
119
+ ).astimezone(dt.timezone.utc).isoformat(timespec="seconds")
120
+ else:
121
+ effective_iso = _floor_to_hour(at_dt).isoformat(timespec="seconds")
122
+ cur_end_canon = _canonicalize_optional_iso(week_end_at, "record-credit.week_end")
123
+ return CreditPlan(
124
+ week_start_date=week_start_date,
125
+ week_start_at=week_start_at,
126
+ week_end_at=week_end_at,
127
+ cur_end_canon=cur_end_canon,
128
+ from_pct=from_pct,
129
+ from_source=from_source,
130
+ to_pct=to_pct,
131
+ effective_iso=effective_iso,
132
+ captured_iso=at_dt.isoformat(timespec="seconds").replace("+00:00", "Z"),
133
+ )
@@ -130,6 +130,7 @@ from _cctally_core import (
130
130
  open_db,
131
131
  _command_as_of,
132
132
  _canonicalize_optional_iso,
133
+ _floored_week_max,
133
134
  parse_iso_datetime,
134
135
  )
135
136
 
@@ -659,8 +660,9 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
659
660
 
660
661
  "avg" fires for windows spanning >= 2 full weeks (e.g., last-30d
661
662
  over 4-5 weeks): we average max(weekly_percent) across the weeks
662
- that have snapshot rows. If any subscription week in [start, end)
663
- lacks a snapshot, mode is downgraded to `n/a`.
663
+ that have snapshot rows reset-aware floored per week (#290), so a
664
+ credited week contributes its post-credit value. If any subscription
665
+ week in [start, end) lacks a snapshot, mode is downgraded to `n/a`.
664
666
 
665
667
  The "exact" lookup is constrained to the target week's
666
668
  `week_start_date` so a window with no recorded snapshots correctly
@@ -693,14 +695,24 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
693
695
  except Exception:
694
696
  return None, "n/a"
695
697
  try:
696
- rows = conn.execute(
697
- "SELECT week_start_date, MAX(weekly_percent) "
698
+ # #290: reset-aware-floor each week's peak so a credited week
699
+ # contributes its post-credit value (not the stale pre-credit peak),
700
+ # consistent with the exact branch + every other used-% surface.
701
+ # Raw rows (NO NULL-bounds filter: legacy NULL rows still count via
702
+ # the helper's NULL tolerance); helper reduces to floored per-week
703
+ # max keyed on week_start_date.
704
+ raw = conn.execute(
705
+ "SELECT week_start_date, week_start_at, week_end_at, "
706
+ " captured_at_utc, weekly_percent "
698
707
  "FROM weekly_usage_snapshots "
699
- "WHERE captured_at_utc >= ? AND captured_at_utc < ? "
700
- "GROUP BY week_start_date",
708
+ "WHERE captured_at_utc >= ? AND captured_at_utc < ?",
701
709
  (_iso_z(window.start_utc), _iso_z(window.end_utc)),
702
710
  ).fetchall()
703
- vals = [r[1] for r in rows if r[1] is not None]
711
+ floored = _floored_week_max(
712
+ conn,
713
+ [(r[0], r[0], r[1], r[2], r[3], r[4]) for r in raw],
714
+ )
715
+ vals = list(floored.values())
704
716
  if len(vals) < window.full_weeks_count:
705
717
  # Spec §9.3: missing-coverage weeks would skew the avg —
706
718
  # downgrade to n/a instead of reporting a misleading number.