cctally 1.66.0 → 1.67.1

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.
@@ -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
 
@@ -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.
@@ -81,6 +81,33 @@ def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
81
81
  )
82
82
 
83
83
 
84
+ def _round_to_ten_minutes(d: dt.datetime) -> dt.datetime:
85
+ """Round a datetime to the NEAREST 10-minute boundary (half up).
86
+
87
+ The *display*-side companion to ``_floor_to_ten_minutes``. Anthropic
88
+ ``rate_limits.5h.resets_at`` (and the derived ``block_start_at``,
89
+ ``block_start = reset - 5h``) carries sub-10-minute capture jitter, so
90
+ a reset that truly lands on ``:40`` can be recorded as ``:39``.
91
+ Flooring that for display shows ``:30`` — off by a full bucket — so
92
+ every user-facing 5h-block clock time rounds to the nearest boundary
93
+ instead.
94
+
95
+ Rounds the ABSOLUTE instant (epoch, tz-independent) rather than the
96
+ local minute, so it is correct regardless of the display zone's offset
97
+ (the reset is a fixed UTC instant). Returns a UTC-aware datetime;
98
+ callers hand it to ``format_display_dt`` for zone conversion.
99
+
100
+ NEVER use this for keys / partitioning / lookups — those require the
101
+ exact stored timestamp (issue #76). This is a render-only normalizer.
102
+ """
103
+ if d.tzinfo is None:
104
+ d = d.replace(tzinfo=dt.timezone.utc)
105
+ bucket = _FIVE_HOUR_JITTER_FLOOR_SECONDS # 600s = 10 min
106
+ # floor(x + 0.5) is predictable half-UP (Python's round() is banker's).
107
+ rounded = math.floor(d.timestamp() / bucket + 0.5) * bucket
108
+ return dt.datetime.fromtimestamp(rounded, dt.timezone.utc)
109
+
110
+
84
111
  def _canonical_5h_window_key(
85
112
  resets_at_epoch: int,
86
113
  prior_epoch: int | None = None,
@@ -245,7 +245,14 @@ def _render_title_banner(title: str, *, unicode_ok: bool, color: bool) -> str:
245
245
 
246
246
 
247
247
  def _fmt_block_time_local(ts: dt.datetime, tz: "ZoneInfo | None") -> str:
248
- """Block-start timestamp in display tz, ccusage `toLocaleString`-style."""
248
+ """Block-start timestamp in display tz, ccusage `toLocaleString`-style.
249
+
250
+ ``ts`` is always a 5h-block boundary (start_time / end_time — every
251
+ caller passes one), so it is rounded to the nearest 10-minute boundary
252
+ to normalize Anthropic's reset-capture jitter (e.g. 4:39:59 → 4:40:00)
253
+ before rendering.
254
+ """
255
+ ts = _cctally()._round_to_ten_minutes(ts)
249
256
  local = ts.astimezone(tz)
250
257
  hour_12 = local.hour % 12 or 12
251
258
  ampm = "a.m." if local.hour < 12 else "p.m."
@@ -1329,8 +1329,14 @@ def build_blocks_view(
1329
1329
  per_model.items(), key=lambda kv: -kv[1],
1330
1330
  )
1331
1331
  ]
1332
+ # Round the DISPLAYED start to the nearest 10-min boundary
1333
+ # (reset-jitter normalization); ``start_at`` / ``end_at``
1334
+ # below stay exact — they key the React row and the
1335
+ # ``/api/block/:start_at`` lookup, which matches by exact
1336
+ # equality (issue #76).
1332
1337
  local_label = c.format_display_dt(
1333
- b.start_time, display_tz, fmt="%H:%M %b %d", suffix=True,
1338
+ c._round_to_ten_minutes(b.start_time),
1339
+ display_tz, fmt="%H:%M %b %d", suffix=True,
1334
1340
  )
1335
1341
  rows.append(BlocksPanelRow(
1336
1342
  start_at=b.start_time.astimezone(dt.timezone.utc).isoformat(),
package/bin/cctally CHANGED
@@ -151,6 +151,7 @@ SETUP_SYMLINK_NAMES = (
151
151
  "cctally-refresh-usage",
152
152
  "cctally-statusline",
153
153
  "cctally-sync-week",
154
+ "cctally-transcript",
154
155
  "cctally-tui",
155
156
  "cctally-update",
156
157
  )
@@ -214,6 +215,7 @@ _canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
214
215
  make_week_ref = _cctally_core.make_week_ref
215
216
  _get_latest_row_for_week = _cctally_core._get_latest_row_for_week
216
217
  _reset_aware_floor = _cctally_core._reset_aware_floor
218
+ _floored_week_max = _cctally_core._floored_week_max
217
219
  get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
218
220
  # Re-exported so the telemetry kernel's ``c._is_dev_checkout()`` accessor
219
221
  # call resolves on the ``cctally`` module (and tests can monkeypatch it
@@ -404,6 +406,7 @@ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
404
406
  _lib_five_hour = _load_sibling("_lib_five_hour")
405
407
  _FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
406
408
  _floor_to_ten_minutes = _lib_five_hour._floor_to_ten_minutes
409
+ _round_to_ten_minutes = _lib_five_hour._round_to_ten_minutes
407
410
  _canonical_5h_window_key = _lib_five_hour._canonical_5h_window_key
408
411
 
409
412
  _lib_subscription_weeks = _load_sibling("_lib_subscription_weeks")
@@ -2565,6 +2568,11 @@ _cctally_diff = _load_sibling("_cctally_diff")
2565
2568
  cmd_diff = _cctally_diff.cmd_diff # parser: c.cmd_diff (_cctally_parser.py:2032)
2566
2569
  _emit_diff_debug_samples = _cctally_diff._emit_diff_debug_samples # test_debug_sample_emission mod._emit_diff_debug_samples
2567
2570
 
2571
+ # #281 S4: the `transcript export|search` command module. Eager re-export so the
2572
+ # parser's set_defaults(func=c.cmd_transcript) resolves at build_parser time.
2573
+ _cctally_transcript = _load_sibling("_cctally_transcript")
2574
+ cmd_transcript = _cctally_transcript.cmd_transcript # parser: c.cmd_transcript
2575
+
2568
2576
 
2569
2577
  _cctally_weekrefs = _load_sibling("_cctally_weekrefs")
2570
2578
  _get_canonical_boundary_for_date = _cctally_weekrefs._get_canonical_boundary_for_date
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Thin wrapper: `cctally-transcript` → `cctally transcript`.
3
+ # Mirrors the `cctally-forecast` / `cctally-project` pattern.
4
+ set -euo pipefail
5
+ exec "$(dirname "$0")/cctally" transcript "$@"