cctally 1.49.0 → 1.51.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 +16 -0
- package/bin/_cctally_core.py +76 -0
- package/bin/_cctally_dashboard.py +22 -1
- package/bin/_cctally_parser.py +45 -0
- package/bin/_cctally_project.py +46 -3
- package/bin/_cctally_record.py +519 -25
- package/bin/_cctally_statusline.py +16 -14
- package/bin/cctally +20 -0
- package/dashboard/static/assets/index-CUHxiTTx.js +68 -0
- package/dashboard/static/assets/index-DAf-3Eal.css +1 -0
- package/dashboard/static/dashboard.html +3 -2
- package/dashboard/static/favicon.svg +1 -0
- package/package.json +1 -1
- package/dashboard/static/assets/index-0Wm0BajQ.js +0 -68
- package/dashboard/static/assets/index-BT9RRCjD.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.51.0] - 2026-06-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`record-credit` — manually record an in-place weekly credit the auto-detector misses.** When Anthropic lowers your 7-day usage counter mid-window by a sub-25pp, non-zero amount (e.g. 46%→31%) instead of a clean reset, neither auto-detector fires and the monotonic clamp pins every display at the stale peak. `cctally record-credit --to 31` records the credit explicitly and repairs the data while keeping the **same week** (no re-anchor): it records a clamp floor for the current week, lowers `hwm-7d`, clears stale replays, and inserts a post-credit snapshot so reports and the statusline read the credited value while the week boundaries and the forecast rate stay correct. As usage resumes, a later reading below the pre-credit peak (e.g. 37%) is stored normally rather than suppressed. Preview-and-confirm by default (`--dry-run` / `--yes`), with `--from` / `--at` / `--week` overrides, a `--force` clean re-record that never touches real history, and a `--json` (schemaVersion 1) envelope. See `docs/commands/record-credit.md`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Dashboard conversation viewer — the mobile fix that stops the sticky topbar from hiding `← Back` now holds on notched phones, and in-page panel jumps land correctly.** The mobile topbar height budget (`--topbar-h`) was a flat 104px, but the real two-row mobile header renders ~148px; the #205 reader-height fix compensated only for the no-safe-area (headless) case, so on a notched device (`env(safe-area-inset-top)`) roughly 30px of document overflow returned and the sticky topbar could again scroll over the reader's `← Back` button. `--topbar-h` is now measured safe-area-aware (`calc(142px + max(6px, env(safe-area-inset-top)))`) so it tracks the real header height at any inset — the conversation reader fills the viewport with no document overflow on every phone, and because that same variable is every panel's scroll-anchor floor, in-page jumps and `scrollIntoView()` now land panel titles flush just below the header instead of ~44px under it. Desktop is byte-identical (#206).
|
|
15
|
+
|
|
16
|
+
## [1.50.0] - 2026-06-18
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Dashboard conversation viewer — mobile transcripts no longer shear sideways, and the session outline is no longer an interaction trap.** On a narrow (≤640px) viewport the reader's pinned controls toolbar overflowed the viewport, so any automatic scroll (jump-to-latest on open, an outline/landmark jump, or a focus-filter switch) could drift the whole transcript sideways and clip every line off the left edge; the toolbar and focus control are now constrained to the viewport, all reader content is width-clamped, and the reader is locked against a stray horizontal scroll, so transcripts read fully at 320/360/390px. The session outline — previously a persisted, default-on slide-over that buried the transcript the moment you opened a conversation, dismissable only by tapping an unlabeled backdrop strip — now defaults closed on mobile, opens on an ephemeral per-conversation flag, and carries a titled header with a visible ✕ close (the backdrop stays a second dismiss path), raised above the sticky topbar so the close control is always tappable (#205).
|
|
20
|
+
- **Dashboard conversation viewer — find-in-conversation is reachable on mobile, and text inputs no longer trigger iOS auto-zoom.** A `🔍 Find` toggle now sits in the reader controls row on both breakpoints (the `/` shortcut still works on desktop), so the in-conversation find bar is no longer unreachable by touch; switching to a different conversation clears an open find state, so navigating back and reselecting no longer auto-reopens the bar and pops the keyboard. All seven viewer text inputs (rail search, find bar, and the date/cost/cache-rebuild filter fields) now render at 16px on mobile, so focusing one no longer makes iOS Safari zoom the page. Desktop styling is unchanged (#205).
|
|
21
|
+
- **Dashboard conversation viewer — a more compact mobile reader header, a readable subagent-card title, and a recoverable list-fetch error.** On mobile the reader header now shows de-duplicated, abbreviated model names (the `claude-` prefix and a trailing `-YYYYMMDD` date stamp are stripped) on a single line and clamps the title to two lines, putting more transcript above the fold; subagent cards swap the one-line `Impl…` ellipsis for a two-line clamped title with the same abbreviated models; and a failed conversation-list fetch now shows a working **Retry** button instead of a dead error state. Desktop output is byte-identical (#205).
|
|
22
|
+
- **Dashboard conversation viewer — every interactive control now meets the 44×44px mobile touch-target minimum, and the reader's `← Back` is no longer hidden behind the sticky topbar.** Roughly 32 controls across the reader header, find bar, filters popover, browse rail, outline, and transcript body (disclosure chips, the jump-to-top FAB, the corner copy buttons, and more) now have a ≥44×44px effective hit area at the mobile breakpoint — the absolute-positioned copy buttons get an invisible centered hit area so a literal box never covers code/diff/terminal content, and inline content links keep the documented WCAG 2.5.5 inline exception. A height-calculation bug that let the page scroll roughly 64px behind the `z-index:50` sticky topbar — hiding the reader's `← Back` — is fixed by switching the mobile conversation view to `100dvh` and correcting its height budget, so `← Back` is reachable at every width, at rest and after a forced scroll. The outline backdrop and ✕ now carry distinct accessibility labels. Desktop runtime is byte-identical (#205).
|
|
23
|
+
|
|
8
24
|
## [1.49.0] - 2026-06-17
|
|
9
25
|
|
|
10
26
|
### Added
|
package/bin/_cctally_core.py
CHANGED
|
@@ -1365,6 +1365,34 @@ def open_db() -> sqlite3.Connection:
|
|
|
1365
1365
|
"""
|
|
1366
1366
|
)
|
|
1367
1367
|
|
|
1368
|
+
# In-place weekly partial-credit floor (issue #209, record-credit M2).
|
|
1369
|
+
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / NO user_version
|
|
1370
|
+
# bump — the same framework-untracked posture as `project_budget_milestones`
|
|
1371
|
+
# above. A `record-credit` invocation records a weekly credit (e.g.
|
|
1372
|
+
# 46% -> 31%) WITHOUT writing a `week_reset_events` row: a credit lowers the
|
|
1373
|
+
# current-7d clamp floor only and must NOT re-anchor the week window (the
|
|
1374
|
+
# `week_reset_events`-driven window-resolution code would otherwise show a
|
|
1375
|
+
# spurious "new week" and corrupt the forecast rate). `_reset_aware_floor`
|
|
1376
|
+
# (below) unions this table with `week_reset_events` so the four MAX-clamp
|
|
1377
|
+
# sites floor the current % to the post-credit value while the window stays
|
|
1378
|
+
# put. `effective_at_utc` is `floor_to_hour(at)` in UTC; `applied_at_utc` is
|
|
1379
|
+
# audit-only (kept out of goldens). Lives BEFORE the migration dispatcher: a
|
|
1380
|
+
# plain CREATE on a framework-untracked table never touches
|
|
1381
|
+
# `schema_migrations`, so the dispatcher's fresh-install snapshot is
|
|
1382
|
+
# unaffected. See docs/superpowers/specs/2026-06-19-record-credit-weekly-design.md §2/§4a.
|
|
1383
|
+
conn.execute(
|
|
1384
|
+
"""
|
|
1385
|
+
CREATE TABLE IF NOT EXISTS weekly_credit_floors (
|
|
1386
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1387
|
+
week_start_date TEXT NOT NULL,
|
|
1388
|
+
effective_at_utc TEXT NOT NULL,
|
|
1389
|
+
observed_pre_credit_pct REAL NOT NULL,
|
|
1390
|
+
applied_at_utc TEXT NOT NULL,
|
|
1391
|
+
UNIQUE(week_start_date, effective_at_utc)
|
|
1392
|
+
)
|
|
1393
|
+
"""
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1368
1396
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1369
1397
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
1370
1398
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|
|
@@ -1523,6 +1551,54 @@ def _get_latest_row_for_week(
|
|
|
1523
1551
|
).fetchone()
|
|
1524
1552
|
|
|
1525
1553
|
|
|
1554
|
+
def _reset_aware_floor(
|
|
1555
|
+
conn: sqlite3.Connection,
|
|
1556
|
+
week_start_date: str,
|
|
1557
|
+
week_start_at: str,
|
|
1558
|
+
week_end_at: str,
|
|
1559
|
+
) -> str | None:
|
|
1560
|
+
"""Return the latest in-week clamp floor (an ISO timestamp) across BOTH
|
|
1561
|
+
`week_reset_events` and `weekly_credit_floors`, or None when neither has a
|
|
1562
|
+
row for this week.
|
|
1563
|
+
|
|
1564
|
+
This is the single chokepoint the four MAX-clamp sites consult to floor the
|
|
1565
|
+
current 7d % to the most-recent in-place credit / reset effective moment
|
|
1566
|
+
(record-credit M2, issue #209, spec §4a):
|
|
1567
|
+
- statusline `_hwm_clamp` 7d (bin/_cctally_statusline.py)
|
|
1568
|
+
- the record-usage write-site monotonic clamp (bin/_cctally_record.py)
|
|
1569
|
+
- `_resolve_reset_aware_hwm` (the --from default helper)
|
|
1570
|
+
- `project`'s `_load_week_snapshots` per-week MAX (bin/_cctally_project.py)
|
|
1571
|
+
|
|
1572
|
+
A `week_reset_events` row counts iff its `effective_reset_at_utc` falls in
|
|
1573
|
+
`[week_start_at, week_end_at)`; a `weekly_credit_floors` row counts iff its
|
|
1574
|
+
`week_start_date` matches (record-credit always stamps `effective_at_utc`
|
|
1575
|
+
inside the week, validated at plan-build time).
|
|
1576
|
+
|
|
1577
|
+
The latest floor wins via `ORDER BY unixepoch(floor_at) DESC LIMIT 1` —
|
|
1578
|
+
`unixepoch()`, NOT a textual `MAX(...)`: the two legs carry mixed offset
|
|
1579
|
+
spellings (`Z` / `+00:00`), and a lexical MAX would silently mis-order them
|
|
1580
|
+
on a non-UTC host (the same gotcha as the statusline clamp / 5h-block
|
|
1581
|
+
cross-reset flag; see the comment at bin/_cctally_statusline.py)."""
|
|
1582
|
+
row = conn.execute(
|
|
1583
|
+
"""
|
|
1584
|
+
SELECT floor_at FROM (
|
|
1585
|
+
SELECT effective_reset_at_utc AS floor_at
|
|
1586
|
+
FROM week_reset_events
|
|
1587
|
+
WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
|
|
1588
|
+
AND unixepoch(effective_reset_at_utc) < unixepoch(?)
|
|
1589
|
+
UNION ALL
|
|
1590
|
+
SELECT effective_at_utc AS floor_at
|
|
1591
|
+
FROM weekly_credit_floors
|
|
1592
|
+
WHERE week_start_date = ?
|
|
1593
|
+
)
|
|
1594
|
+
ORDER BY unixepoch(floor_at) DESC
|
|
1595
|
+
LIMIT 1
|
|
1596
|
+
""",
|
|
1597
|
+
(week_start_at, week_end_at, week_start_date),
|
|
1598
|
+
).fetchone()
|
|
1599
|
+
return row[0] if row and row[0] else None
|
|
1600
|
+
|
|
1601
|
+
|
|
1526
1602
|
def get_latest_usage_for_week(
|
|
1527
1603
|
conn: sqlite3.Connection,
|
|
1528
1604
|
week_ref: WeekRef,
|
|
@@ -4988,6 +4988,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4988
4988
|
"_error": f"{type(exc).__name__}: {exc}",
|
|
4989
4989
|
}
|
|
4990
4990
|
|
|
4991
|
+
# B1 (#207): the "vs last week" header delta reuses the is_current trend
|
|
4992
|
+
# row's delta_dpp ($/1% vs the previous trend row — normally the prior
|
|
4993
|
+
# subscription week). Select by the is_current FLAG, not snap.trend[-1]:
|
|
4994
|
+
# snap.trend is oldest-first by week_start_date and reset/credit handling
|
|
4995
|
+
# can synthesize an intervening row, so position -1 is not guaranteed to
|
|
4996
|
+
# be the current week (Codex P1). reversed() picks the latest if ever
|
|
4997
|
+
# multiple are flagged. Null-safe: None when no row is current, or when
|
|
4998
|
+
# the current row's delta_dpp is itself None — both hide the stat.
|
|
4999
|
+
_current_trend = next(
|
|
5000
|
+
(r for r in reversed(snap.trend) if r.is_current), None
|
|
5001
|
+
) if snap.trend else None
|
|
5002
|
+
|
|
4991
5003
|
return {
|
|
4992
5004
|
"envelope_version": 2,
|
|
4993
5005
|
"generated_at": _iso_z(snap.generated_at),
|
|
@@ -5011,7 +5023,9 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
5011
5023
|
"dollar_per_pct": dollar_pp,
|
|
5012
5024
|
"forecast_pct": header_fcast_pct,
|
|
5013
5025
|
"forecast_verdict": verdict,
|
|
5014
|
-
"vs_last_week_delta":
|
|
5026
|
+
"vs_last_week_delta": (
|
|
5027
|
+
_current_trend.delta_dpp if _current_trend is not None else None
|
|
5028
|
+
),
|
|
5015
5029
|
},
|
|
5016
5030
|
|
|
5017
5031
|
"current_week":
|
|
@@ -5387,6 +5401,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5387
5401
|
if path == "/":
|
|
5388
5402
|
self._serve_static_file(self.static_dir / "dashboard.html",
|
|
5389
5403
|
"text/html; charset=utf-8")
|
|
5404
|
+
elif path == "/favicon.ico":
|
|
5405
|
+
# #207 D11 — serve the SVG favicon for the browser's default
|
|
5406
|
+
# /favicon.ico request so it stops 404-ing even absent the
|
|
5407
|
+
# <link rel="icon"> in dashboard.html. Vite copies public/ verbatim
|
|
5408
|
+
# into the build output, so favicon.svg lands under static_dir.
|
|
5409
|
+
self._serve_static_file(self.static_dir / "favicon.svg",
|
|
5410
|
+
"image/svg+xml")
|
|
5390
5411
|
elif path.startswith("/static/"):
|
|
5391
5412
|
# Defense in depth: a lexical ".." pre-check is bypassable via
|
|
5392
5413
|
# percent-encoding (e.g., %2e%2e), so the authoritative guard is the
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -1610,6 +1610,51 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1610
1610
|
)
|
|
1611
1611
|
ru.set_defaults(func=c.cmd_record_usage)
|
|
1612
1612
|
|
|
1613
|
+
rc = sub.add_parser(
|
|
1614
|
+
"record-credit",
|
|
1615
|
+
help="Record an in-place weekly credit the auto-detector misses",
|
|
1616
|
+
formatter_class=CLIHelpFormatter,
|
|
1617
|
+
description=textwrap.dedent(
|
|
1618
|
+
"""\
|
|
1619
|
+
Record an in-place weekly (7d) credit that the auto-detector
|
|
1620
|
+
misses (a sub-25pp, non-zero drop — e.g. Anthropic lowered your
|
|
1621
|
+
7d % from 46 to 31 without a clean reset). Writes a
|
|
1622
|
+
weekly_credit_floors clamp row (no week re-anchor — the same week
|
|
1623
|
+
continues), lowers hwm-7d, and inserts a post-credit snapshot so
|
|
1624
|
+
reports and the statusline read the credited value.
|
|
1625
|
+
Preview + confirm by default.
|
|
1626
|
+
"""
|
|
1627
|
+
),
|
|
1628
|
+
epilog=textwrap.dedent(
|
|
1629
|
+
"""\
|
|
1630
|
+
Examples:
|
|
1631
|
+
cctally record-credit --to 31 # baseline auto-read from HWM
|
|
1632
|
+
cctally record-credit --to 31 --dry-run # preview, write nothing
|
|
1633
|
+
cctally record-credit --to 31 --yes # apply without prompting
|
|
1634
|
+
|
|
1635
|
+
Exit codes: 0 success (incl. --dry-run and an interactive decline),
|
|
1636
|
+
2 validation/refuse, 3 on a database error.
|
|
1637
|
+
"""
|
|
1638
|
+
),
|
|
1639
|
+
)
|
|
1640
|
+
rc.add_argument("--to", required=True, type=float,
|
|
1641
|
+
help="New post-credit weekly %% (0-100).")
|
|
1642
|
+
rc.add_argument("--from", dest="from_pct", metavar="FROM", type=float, default=None,
|
|
1643
|
+
help="Pre-credit baseline %% (default: current HWM for the week).")
|
|
1644
|
+
rc.add_argument("--at", default=None,
|
|
1645
|
+
help="Effective credit moment (ISO; naive=UTC; default now).")
|
|
1646
|
+
rc.add_argument("--week", default=None,
|
|
1647
|
+
help="week_start_date YYYY-MM-DD (default: current week).")
|
|
1648
|
+
rc.add_argument("--dry-run", action="store_true",
|
|
1649
|
+
help="Preview only; write nothing.")
|
|
1650
|
+
rc.add_argument("--yes", action="store_true",
|
|
1651
|
+
help="Apply without the confirm prompt.")
|
|
1652
|
+
rc.add_argument("--force", action="store_true",
|
|
1653
|
+
help="Re-record when a credit is already fully recorded for the week.")
|
|
1654
|
+
rc.add_argument("--json", action="store_true",
|
|
1655
|
+
help="Machine output (schemaVersion 1).")
|
|
1656
|
+
rc.set_defaults(func=c.cmd_record_credit)
|
|
1657
|
+
|
|
1613
1658
|
rfu = sub.add_parser(
|
|
1614
1659
|
"refresh-usage",
|
|
1615
1660
|
help="Force-fetch 7d/5h percent from OAuth API and record it",
|
package/bin/_cctally_project.py
CHANGED
|
@@ -26,7 +26,13 @@ import json
|
|
|
26
26
|
import os
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
|
-
from _cctally_core import
|
|
29
|
+
from _cctally_core import (
|
|
30
|
+
_command_as_of,
|
|
31
|
+
_reset_aware_floor,
|
|
32
|
+
eprint,
|
|
33
|
+
open_db,
|
|
34
|
+
parse_iso_datetime,
|
|
35
|
+
)
|
|
30
36
|
from _lib_fmt import stable_sum
|
|
31
37
|
|
|
32
38
|
|
|
@@ -53,22 +59,59 @@ def _load_week_snapshots(
|
|
|
53
59
|
`+03:00` from pre-UTC-cast canonicalizer history) coalesce into one
|
|
54
60
|
bucket instead of splitting and silently dropping the higher value.
|
|
55
61
|
|
|
62
|
+
Reset-aware (record-credit M2, #209): each week's MAX is restricted to
|
|
63
|
+
snapshots captured at-or-after that week's latest in-place clamp floor
|
|
64
|
+
(`_reset_aware_floor`, the union of `week_reset_events` +
|
|
65
|
+
`weekly_credit_floors`). Without this, a credited week's per-project Used %
|
|
66
|
+
would read the stale pre-credit peak (e.g. 46) instead of the post-credit
|
|
67
|
+
value (e.g. 31) — the same floor the statusline / write-clamp / `--from`
|
|
68
|
+
helper apply (spec §4a, test S15). The floor compare uses `unixepoch()` on
|
|
69
|
+
both sides (mixed `Z` / `+00:00` offset spellings).
|
|
70
|
+
|
|
56
71
|
Returns an empty dict if the stats DB has no relevant rows.
|
|
57
72
|
"""
|
|
58
73
|
conn = open_db()
|
|
59
74
|
try:
|
|
60
75
|
cur = conn.execute(
|
|
61
|
-
"SELECT week_start_at,
|
|
76
|
+
"SELECT week_start_date, week_start_at, week_end_at, "
|
|
77
|
+
" captured_at_utc, weekly_percent "
|
|
78
|
+
"FROM weekly_usage_snapshots "
|
|
62
79
|
"WHERE week_start_at IS NOT NULL "
|
|
63
80
|
"AND week_end_at IS NOT NULL "
|
|
64
81
|
"AND datetime(week_start_at) < datetime(?) "
|
|
65
82
|
"AND datetime(week_end_at) > datetime(?)",
|
|
66
83
|
(until.isoformat(), since.isoformat()),
|
|
67
84
|
)
|
|
85
|
+
rows = cur.fetchall()
|
|
86
|
+
# Resolve each week's clamp floor once (keyed on week_start_date) so a
|
|
87
|
+
# mid-week credit doesn't re-inflate the per-project Used %. None means
|
|
88
|
+
# "no floor" — every captured row counts.
|
|
89
|
+
floor_epoch: dict[str, int | None] = {}
|
|
90
|
+
|
|
91
|
+
def _floor_for(wsd, ws_iso, we_iso):
|
|
92
|
+
if wsd not in floor_epoch:
|
|
93
|
+
f_iso = _reset_aware_floor(conn, wsd, ws_iso, we_iso)
|
|
94
|
+
floor_epoch[wsd] = (
|
|
95
|
+
int(parse_iso_datetime(f_iso, "project.floor").timestamp())
|
|
96
|
+
if f_iso else None
|
|
97
|
+
)
|
|
98
|
+
return floor_epoch[wsd]
|
|
99
|
+
|
|
68
100
|
result: dict[dt.datetime, float] = {}
|
|
69
|
-
for ws_iso, pct in
|
|
101
|
+
for wsd, ws_iso, we_iso, cap_iso, pct in rows:
|
|
70
102
|
if ws_iso is None or pct is None:
|
|
71
103
|
continue
|
|
104
|
+
floor = _floor_for(wsd, ws_iso, we_iso)
|
|
105
|
+
if floor is not None and cap_iso is not None:
|
|
106
|
+
try:
|
|
107
|
+
cap_epoch = int(
|
|
108
|
+
parse_iso_datetime(str(cap_iso), "project.cap").timestamp()
|
|
109
|
+
)
|
|
110
|
+
except ValueError:
|
|
111
|
+
cap_epoch = None
|
|
112
|
+
# Drop pre-floor (stale pre-credit) snapshots from the MAX.
|
|
113
|
+
if cap_epoch is not None and cap_epoch < floor:
|
|
114
|
+
continue
|
|
72
115
|
ws = dt.datetime.fromisoformat(
|
|
73
116
|
str(ws_iso).replace("Z", "+00:00")
|
|
74
117
|
)
|