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.
- package/CHANGELOG.md +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +13 -1
- 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
|
+
)
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -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
|
|
663
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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.
|