cctally 1.95.4 → 1.96.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 +71 -0
- package/README.md +4 -5
- package/bin/_cctally_config.py +46 -22
- package/bin/_cctally_core.py +125 -42
- package/bin/_cctally_dashboard.py +258 -100
- package/bin/_cctally_dashboard_cache_report.py +24 -1
- package/bin/_cctally_dashboard_envelope.py +11 -0
- package/bin/_cctally_dashboard_share.py +38 -4
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_journal.py +272 -20
- package/bin/_cctally_parser.py +14 -3
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_reporting.py +12 -8
- package/bin/_cctally_share.py +86 -12
- package/bin/_cctally_store.py +26 -21
- package/bin/_cctally_tui.py +11 -5
- package/bin/_lib_dashboard_settings_contract.py +86 -0
- package/bin/_lib_journal.py +4 -2
- package/bin/_lib_render.py +47 -20
- package/bin/_lib_share.py +191 -48
- package/bin/cctally +5 -1
- package/dashboard/static/assets/index-Bhr5gZ14.js +97 -0
- package/dashboard/static/assets/index-DfN_fsLZ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BvCbpJJA.css +0 -1
- package/dashboard/static/assets/index-CRZMxeYI.js +0 -97
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,77 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.96.0] - 2026-08-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- The dashboard Settings overlay is navigable. A section index rail down the side (a horizontal strip on narrow screens) reaches any of its seven groups without scrolling past everything before it, and a filter over the same list matches a setting's name, its help text, its dotted key path, or the words "this browser" — so you can find a setting whether you know what we called it or only what the config key is. A filtered-out setting you already changed still counts toward the Save badge and still saves; a line says how many changes the filter is hiding and offers a way back to them (#513).
|
|
12
|
+
- Every one of the thirty-seven configuration keys now has an on-screen disposition. Thirteen have an editor, one is shown read-only, three are disclosed without an editor, and twenty are named as CLI-only with the reason and the exact command to run — the wrapper where one exists (`cctally telemetry off`, `cctally budget set 200 --vendor codex`) and a real object example where the value is JSON. The four keys the endpoint accepts and deliberately does not store say so on their own row (#513).
|
|
13
|
+
- You can set the Claude weekly budget from the dashboard. The amount is also mirrored into the dashboard's live data, which is what lets Settings tell "no budget configured" from "budget configured, alerts off" and state the remedy for each; the two Claude budget toggles stay operable in both cases rather than being greyed out with no explanation (#513).
|
|
14
|
+
- Add an hourly local Codex issue-intake workflow that coalesces new issues, evidence edits, comments, closure, post-closure changes, and reopen events into validated records for the next daily whole-backlog triage.
|
|
15
|
+
- You can now watch a test run while it happens instead of waiting for the summary. `bin/cctally-test-all` prints a completion line per harness with its verdict and duration, a marker at each phase boundary, and a heartbeat every thirty seconds naming what is still running, how many harnesses are done and how many are queued. All of it goes to stderr, so anything parsing the aggregated block on stdout is unaffected (#529).
|
|
16
|
+
- A failed run now keeps its evidence instead of deleting it. Complete per-harness logs, timings, both pytest logs, a run manifest and the run-outcome record are retained on the machine that ran them for seven days, bounded by a 1 GiB cap; every eviction is reported, and the retained coverage states its own gaps so a rate is never computed over a window it does not cover. Set `CCTALLY_TEST_EVIDENCE_ROOT` to turn retention on; with no root set, nothing is retained and nothing changes (#529).
|
|
17
|
+
- A failure now travels with a bounded, sanitized extract of the log around each failure marker — forty lines before it and two hundred after, so the line that explains a failure is no longer discarded for arriving before the marker. The extract is validated by an independently implemented denylist before it is written or published anywhere, and a failing validation refuses the artifact rather than transforming it a second time. CI uploads that extract, and only that extract, as a failure-only artifact; the complete unsanitized logs never leave the machine that produced them (#529).
|
|
18
|
+
- You can now run just the harnesses that own your change, in one round trip. `bin/cctally-test-all` accepts `--harness NAME` (repeatable) and `--with-pytest`, and a subset run skips the pytest phase by default. A subset is never a gate: it is classified `incomplete` with reason `deliberate-subset` and exits 3 even when every selected harness passes, and naming all of them is still a subset. Admission runs in full either way, so a subset still catches a harness that lost its executable bit, one on disk with no manifest row, and a row whose file is missing. The new `bin/cctally-test-owners` answers which harnesses own a set of changed paths — from arguments, from stdin, or with `--from-diff <rev-range>` — and reports the direct owners, anything it could not attribute, and a separate safe set it is willing to stand behind. Only mechanically verified evidence narrows that set; any unattributable path widens it to the whole shell estate, and the pytest estate is never narrowed (#529).
|
|
19
|
+
- `bin/cctally-preflight` catches Python and shell syntax defects and harness-ownership drift locally, in seconds, before a twenty-minute remote round trip. It compiles every Python file under `bin/`, syntax-checks every shell script with the interpreter named in each file's own shebang, and verifies the ownership map. Files are selected by shebang as well as by extension, so the extensionless `bin/cctally` is covered. `bin/cctally-test-remote` runs it before an exact canonical full-suite invocation and only then, so targeted and subset runs are never blocked; `CCTALLY_SKIP_PREFLIGHT=1` skips it and the run stays authoritative, because the same checks also run inside the suite (#529).
|
|
20
|
+
- Every completed run now records a normalized runtime metric — wall-seconds per thousand passed cases — alongside its passed-case count, total wall time, effective job budget and coverage object, so two runs can be compared without reading a stopwatch. A run whose pytest passed count could not be read records nulls rather than a misleading zero (#529).
|
|
21
|
+
- `bin/cctally-test-remote --status` shows which runners are busy right now, with one row per live job: the remote directory, how long it has been running, its token and the worktree that started it. It also states what retained evidence still exists — whether coverage is complete or degraded, over how many runs, and the interval of any gap — read from the local ledger, so the answer survives a restart and an unreachable fleet. `bin/cctally-test-remote --report [--days N]` aggregates a machine-wide ledger of every invocation from every worktree — counts by invocation form, refusals by cause, and the count, total, median and p90 of lock waits. Both accept `--json`, take no lock, and are not themselves recorded (#529).
|
|
22
|
+
- The release gate now checks the test run you cite instead of taking your word for it. `bin/cctally-test-remote --verify-receipt <run-id>` exits 0 only when the named run was authoritative by its own recorded receipt, ran under `Etc/UTC`, passed with no failures, covered the whole estate, and tested exactly the commit checked out now over a clean tree whose digest still matches; anything else exits 3 and names which condition failed. Every run freezes that receipt at launch, so a blocking invocation and one whose timezone was defaulted rather than given explicitly now record themselves as non-releasable — both previously reported the same internal state as the authoritative command and were indistinguishable from it. The release skill's first gate prescribes that command and that check, in place of a bare local run (#529).
|
|
23
|
+
- A green test result is now citable through a key that names its inputs, its gate and its toolchain. Every receipt records `gateId` — one accepted value, assigned only where the authoritative launch predicate succeeds, so nothing a caller passes can set it — and `declaredToolchainDigest`, a digest of the exact bytes of the pinned test-toolchain closure. Both are frozen at launch and restored across a detached run's terminal attach rather than recomputed, and both are deciding in `bin/cctally-test-remote --verify-receipt`: a receipt naming another gate, one missing either field, or one whose declared toolchain no longer matches the checked-out tree refuses with exit 3 and says which. Nothing skips or short-circuits a run on the strength of a receipt (#529).
|
|
24
|
+
- A Claude-side real-browser QA verdict now leaves a durable record tied to the commit it describes. The commit carries a `UI-QA:` trailer naming the report, source and bundle digests, and the report itself is committed with the work, so the trailer still verifies from a fresh checkout rather than only in the tree that produced it. The bundle digest covers `dashboard/static/`, the files the dashboard actually serves, so a bundle rebuilt after QA no longer verifies against the verdict that approved it. A `PASS` must account for every acceptance criterion supplied when the gate opened, and cannot carry unresolved findings (#529).
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Settings tells you why a save was refused, and where. A rejection now lands on the control it names — including the ones the server names by their parent block, so a Codex budget rejection paints on the Codex row instead of in a generic banner — and an error summary above the buttons lists every problem with a link that puts focus on the offending control, revealing it first if the filter had removed it. Save is no longer greyed out when a field is invalid: clicking it is what surfaces the reason (#513).
|
|
28
|
+
- A slow save now says something true. Past three seconds Settings reports the elapsed time and that a large history can make this take a while and the dashboard is not stuck. It does not claim the write has landed, because at that point that is not knowable. Closing the overlay is suppressed while a save is in flight, so a click on the × can no longer strand the result (#513).
|
|
29
|
+
- Sessions-per-page stops rewriting your stored value while you type. Typing `5` into a field holding `50` used to persist `10`; the field now keeps what you typed, refuses to save until it is a whole number between 10 and 1000, and says so on blur or when you press Save (#513).
|
|
30
|
+
- Settings looks like a designed surface. Every disabled control is now visibly disabled and no longer accepts the pointer — the disabled Save was previously identical to the enabled one on every property, and so was the custom-timezone field, which is disabled whenever the zone mode is not Custom. Every control takes the same focus ring, including the overlay's own close button; text and number inputs previously computed no ring at all. A fieldset holding unsaved changes is outlined in the accent colour rather than relying on a single small dot. The action bar moved out of the scrolling area, so a control can no longer end up focused behind it, and on narrow screens a focused section-rail entry is brought fully into view instead of staying clipped. There is a spacing scale, a Windows High Contrast treatment, and the saving indicator stops moving under `prefers-reduced-motion` (#513).
|
|
31
|
+
- Three Settings controls gained a real accessible name. The custom-timezone input used to sit inside the label naming the Custom radio, which made that radio announce as "Custom: America/New_York" and left the input itself unnamed; sessions-per-page had no name; and the remembered filter term was named only by its placeholder (#513).
|
|
32
|
+
- The dashboard settings endpoint now refuses a setting it cannot write, instead of answering success and discarding it. `alerts.quota`, `budget.projects`, `budget.accounts` and `budget.codex.accounts` are real configuration keys that `POST /api/settings` never wrote, and a client that sent one was told the save succeeded; each now returns 400. Every rejection names the offending key in a `field` pointer, and the four keys the endpoint deliberately accepts without storing — `budget.period` and the three CLI-only Codex budget leaves — now report themselves in an `ignored_fields` array on the response, so a client can see that a value it sent was not persisted. The same rule covers any other key the endpoint does not write, not only those four: `alerts.weekly_thresholds`, `alerts.five_hour_thresholds`, any further unrecognized leaf under `alerts` or `budget`, and any extra key inside the `display` block were all accepted and discarded before, and each now returns 400 as well. No shipped client sends any of them. Requests that only set writable keys are unchanged, as is the dashboard's own Settings overlay (#513).
|
|
33
|
+
- `cctally config --help` no longer presents three keys as the supported set. It now labels them as commonly set, states how many keys are settable in total, and points at `cctally config get` and the reference page (#513).
|
|
34
|
+
- A release stamp no longer occupies the self-hosted runner. A hosted classifier job now proves whether a push is a pure release stamp — exactly one commit, the exact `chore(release): vX.Y.Z` subject, only the paths the release tool itself stages, a `package.json` diff that changes nothing but the version, and `CHANGELOG.md` and `.mirror-contributors.json` post-images equal to what the release tool's own transformations produce — and suppresses the three macOS jobs only then. The CHANGELOG condition matters because the suppressed pytest phase reads the repository's real `CHANGELOG.md`, so a hand-edited entry riding beside a version bump would otherwise skip exactly the tests that would catch it. The last three stamps each occupied that runner for roughly seventeen minutes. Everything else runs the full pipeline, including every error inside the classifier and a classifier that fails outright, because each dependent job is written to admit itself when the gate did not succeed. The fork-safety guards on those jobs are untouched; the classifier is only ever an additional condition (#529).
|
|
35
|
+
- The frontend test estate now has one declared execution per push instead of two. The `dashboard-build-stability` job ran the whole vitest estate as its own step while the test bundle already ran it on the same push and the same runner; the duplicate step is gone and that job keeps its build and byte-stability checks. A new test asserts the count over the parsed workflow graph and the aggregator's own resolved plan, so the invariant is stated once rather than argued per job (#529).
|
|
36
|
+
- Both agent workflows now state the same test budget: one authoritative full-suite run per session, with a second justified only when a later broad change materially invalidates the first. A gate point is defined for the first time — the single pre-merge verification of the complete change, not each implementor's checkpoint, each review, or each stage boundary — and both point at the targeted-harness tools for everything before it (#529).
|
|
37
|
+
- A test result no longer depends on the machine that produced it. The reconcile harness's five dedup invariants now run against a generated fixture corpus with a pinned reference instant, instead of reading the machine's own cctally databases and every Claude session transcript on it, and a missing database, an absent eligible row, cache drift or an unapplied migration is a failure rather than a skip. Those skip arms are why two runners could report the same total while checking different things. The harness also requires the set of invariants that inspected a row to be exactly the five, because a case count is not a named-set assertion. A pytest run now inherits none of the six environment variables the shell preamble neutralizes, and the fixture cache's key changes whenever a forwarded, byte-affecting environment variable does (#529).
|
|
38
|
+
- A refused test run now tells you what is holding the fleet and what to do about it. Every refusal states the occupying job's token, its age and the worktree that owns it, plus the exact command to attach, retry or clear it — including the fully resolved handle path, which previously had to be reconstructed by hashing a directory name by hand. A toolchain-parity refusal names the components that differ rather than only the two hashes, and a lock timeout names the holder's identity and the check to run before removing anything. When the job holding the fleet was launched from a tree pinned to a different agentmem revision, the refusal names both revisions, so a routing surprise is attributable rather than mysterious (#529, #519).
|
|
39
|
+
- Every gate now installs the same pinned test toolchain. `tests/requirements-dev.txt` is the single source of that closure — fourteen exact versions installed with `--no-deps` and verified with `pip check` — and all three authoritative CI lanes read that file, as does the remote test wrapper, which previously carried its own pin list while CI installed the same packages unpinned. A test enforces the parity by parsing the workflows structurally, replacing a comment that asked for it and nothing that checked. Install into a virtualenv, because the closure pins `pip` itself (#529).
|
|
40
|
+
- The sanctioned local test escape hatch now runs the same contract as a remote run. `CCTALLY_TEST_LOCAL=1` previously left the `agentmem`-gated tests skipping while every remote run required them, so a local green and a remote green were different claims and no document said so. The local path now pins the same policy. If `agentmem` is genuinely absent the run still completes rather than dying, but it states which contract it ran and how many tests that skipped, and classifies itself non-authoritative under a named reason (#529).
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- Remote test routing no longer mistakes runners last provisioned by different branches for a toolchain divergence; parity now compares the caller's declared Python closure while retaining live host-runtime checks (#548).
|
|
44
|
+
- The stats-corruption epic test now waits for the heal worker's terminal outcome instead of racing the earlier request-marker cleanup, and its maintenance-holder handshake uses bounded readiness and release pipes rather than wall-clock guesses. Sanitized pytest failure extracts now retain safe assertion structure and normalized traceback locations while continuing to redact private paths and dynamic values (#552).
|
|
45
|
+
- Remote test runs now refuse a conflicted checkout before contacting a runner, name the unmerged git index and its normal `git add` recovery, and always explain a manifest mismatch even when duplicate path records were the only difference (#550).
|
|
46
|
+
- Stable promotion now runs its automatic README refresh from a temporary clean worktree pinned to freshly fetched private `origin/main`, so dirty `main` checkouts and dirty feature branches are preserved byte-for-byte. The refresh publishes private `main` with an exact-base lease, fails closed on concurrent advancement, and cleans both success and failure isolation state while the standalone `readme-refresh` command keeps its strict clean-`main` preflight (#547).
|
|
47
|
+
- Host-pinned remote test runs no longer mint authoritative release receipts after skipping cross-runner parity, and malformed receipt identities in watch handles now fail closed (#549).
|
|
48
|
+
- Agent-workflow checks now expose every instruction chain's remaining byte headroom and warn before the fixed 57,344-byte cap becomes a surprise commit blocker (#554).
|
|
49
|
+
- A string sent to `budget.codex.alerts_enabled` or `budget.codex.projected_enabled` through the dashboard settings endpoint was coerced to a boolean and stored, while the same string sent to their Claude counterparts was rejected. Both sides now answer identically (#513).
|
|
50
|
+
- The configuration reference documented 19 of the 37 keys `cctally config` accepts. It now documents all of them, in the CLI's own order, with values, defaults and whether the dashboard can write each one, and a test ties the table to the code so a new key cannot ship undocumented. `README` now points at that page rather than at the file-format page, and the claim that a malformed `config.json` is silently regenerated over your edits is corrected — the loader warns and falls back in memory without rewriting the file (#513).
|
|
51
|
+
- The `cache-report` documentation and the `--anomaly-threshold-pp` help now state that the flag uses its own default of 15 and does not read the `cache_report.anomaly_threshold_pp` setting the dashboard writes and the dashboard and TUI read (#513).
|
|
52
|
+
- Mobile share previews now reach report content; preset management keeps its header visible, offers explicit rename and confirmation actions, localizes save times, and traps focus in the topmost share dialog (#536).
|
|
53
|
+
- Topbar chips now meet the 44px hit-target token without overlapping adjacent controls, and remote test commands retain the pinned Node runtime through caller-supplied Bash login shells (#536).
|
|
54
|
+
- Share charts now keep stacked-bar legends outside the plotted bars and give detail charts enough height to space every requested session label comfortably (#525).
|
|
55
|
+
- Journal writes now recover from a clock-skew or restore artifact when every future-dated observation segment is an empty regular file. Recovery runs under the journal leaf lock and fsyncs the removal; any segment containing durable bytes remains untouched and the refusal explains how to wait or merge it forward safely (#512).
|
|
56
|
+
- Concurrent commands now identify a detached stats corruption rebuild instead of claiming an operator maintenance command is running; rebuild deferrals, degraded-state attribution, and interrupted-publication decisions also remain typed and fail closed (#504).
|
|
57
|
+
- Every authoritative lane now checks that SQLite provides FTS5 before it runs the suite. Three workflow comments asserted that Ubuntu's `libsqlite3` supplies it and nothing verified the claim, so a runner whose interpreter linked a build without FTS5 would have failed deep inside the suite rather than at provisioning. One implementation serves both uses: the silent probe the admission path already needed, and a diagnosing check that refuses provisioning and names the interpreter it examined (#529).
|
|
58
|
+
- The authoritative test gate now rejects malformed GitHub Actions YAML with a real parser in every lane, verifies shell heredoc termination, emits an immediate heartbeat at each phase transition, proves pytest and benchmark phase classification, compares plan-mode processes against a pre-run baseline, exercises a minimal run from the generated public tree, and shows runner-evidence gaps on both observability surfaces (#541).
|
|
59
|
+
- A remote test run no longer collapses its three worker roles into one. `bin/cctally-test-remote` shipped a single combined `CCTALLY_TEST_JOBS=4` for every command, and `bin/cctally-test-all` reads that knob as the default for both the shell pool and the later, otherwise-solo pytest phase — so every wrapper run resolved to 4/4/4 while CI on the same physical runner used 4/2/10. The aggregator now receives three independent constants and every other command keeps the combined knob unchanged, because several harnesses read it as their own fan-out budget and a targeted run receives it directly. The budget stays fleet-constant, so a result still cannot depend on which runner was free. The Linux matrix CI lane carried the same collapse across a three-version Python matrix and now pins the outer pool alone, letting pytest use the hosted runner's cores (#529).
|
|
60
|
+
- Share artifacts no longer describe an omitted `--since` as an all-history period above only recent content. Their default start now follows the first displayed day, while an explicitly requested start remains unchanged (#527).
|
|
61
|
+
- Process-start identities now render in UTC at the two remaining `ps` fallback sites, so changing the caller's timezone cannot make a live test run or cache-repair owner appear dead (#542).
|
|
62
|
+
- Segment-elision rebuild diagnostics now identify each failing journal segment and exception class (#522).
|
|
63
|
+
- Project labels now use the share kernel's single collision algorithm across CLI, dashboard, reporting, and budget callers, so repeated parent names qualify far enough to remain distinct. A tree-wide structural guard also requires every `ShareSnapshot` constructor to be an approved builder or an explicitly justified bypass (#517).
|
|
64
|
+
- Live journal ingestion now removes a completed correction's materialized effects when a later conflicting marker or action taints that batch, including when durable selector state is unavailable and ingestion falls back to a full derivation (#510).
|
|
65
|
+
- Cutover retries now mint a fresh bootstrap after every already-published bootstrap, even when the wall clock moved backwards after a crash. The committed cursor can no longer leave a later orphan outside its covered journal prefix (#509).
|
|
66
|
+
- Stable-promotion README refreshes now provision and validate their own isolated Rich/Playwright toolchain and matching Chromium revision, repairing missing or torn state without manual package or browser installation (#505).
|
|
67
|
+
- Share export frontmatter now contains every absolute UTC instant the artifact prints, even outside UTC. West-of-UTC start bounds and east-of-UTC end bounds previously drifted past a displayed row by the display zone's offset while the human-readable dates still looked correct (#528).
|
|
68
|
+
- Share exports now reject non-boolean privacy and preset-overwrite fields instead of treating strings such as `"false"` as permission to reveal project names or replace a saved recipe. An anonymized composed artifact also states that its project aliases are shared across sections in Markdown, HTML, and SVG (#503).
|
|
69
|
+
- Hourly issue intake now admits open issues that have never received a managed triage record, including unchanged issues swallowed by the original all-issues baseline. Previously triaged and historical closed issues remain baselined, so enabling the automation does not flood the queue with the existing backlog.
|
|
70
|
+
- Binary bytes in a harness log now produce a retained infrastructure verdict and outcome instead of aborting `bin/cctally-test-all` during summary parsing (#540).
|
|
71
|
+
- The dashboard's Cache Report threshold popover now says which surfaces the value it writes governs. It states that the dashboard and the TUI honor the stored threshold, that `cctally cache-report` does not read it, and that the command has its own `--anomaly-threshold-pp` flag whose default is 15. The command's reference page already said so, but the popover is the only place the value can be set, and it said nothing (#513).
|
|
72
|
+
- A test can no longer pass while a Python process it started writes into your real cctally data directory, your Claude configuration or your session transcripts. Every pytest worker publishes the detector into its own environment, so a child reached through `subprocess`, `os.posix_spawn`, `os.system` or `multiprocessing` inherits it, and a child whose interpreter options would stop that bootstrap loading is refused at launch. A covered write is recorded to a worker-private ledger before the exception is raised, so a test that swallows the exception still fails the run. Every test the detector caught has been given its own pinned paths, and a per-module sweep checks that no module's violation is being hidden by an earlier module in the same process (#529).
|
|
73
|
+
|
|
74
|
+
## [1.95.5] - 2026-08-09
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
- Dashboard share-digest validation now pins the template artifact clock as well as the dashboard data clock, so two equivalent renders cannot fail Linux CI merely because their frontmatter and footer crossed a one-second wall-clock boundary.
|
|
78
|
+
|
|
8
79
|
## [1.95.4] - 2026-08-09
|
|
9
80
|
|
|
10
81
|
### Fixed
|
package/README.md
CHANGED
|
@@ -30,11 +30,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
|
|
|
30
30
|
</p>
|
|
31
31
|
|
|
32
32
|
<!-- cctally:latest-stable:begin -->
|
|
33
|
-
**Latest stable: v1.
|
|
33
|
+
**Latest stable: v1.95.5** (2026-08-09)
|
|
34
34
|
|
|
35
|
-
-
|
|
36
|
-
- Add account-scoped conversation browsing, search, reading, export, and permalinks to the dashboard and `cctally transcript`, while preserving the existing all-account and single-account output shapes (#347).
|
|
37
|
-
- Long Codex conversations now open with two concurrent data requests instead of issuing a third detail request just to repeat totals already available from the outline. On the same 3,733-row conversation, the first painted row improved from 784 ms to 421, 429 ms while live-tail streaming remained connected (#477).
|
|
35
|
+
- Dashboard share-digest validation now pins the template artifact clock as well as the dashboard data clock, so two equivalent renders cannot fail Linux CI merely because their frontmatter and footer crossed a one-second wall-clock boundary.
|
|
38
36
|
<!-- cctally:latest-stable:end -->
|
|
39
37
|
|
|
40
38
|
## Quick start
|
|
@@ -162,7 +160,8 @@ Everything runs locally against your own `~/.claude` and `~/.codex` data; sessio
|
|
|
162
160
|
## Documentation
|
|
163
161
|
|
|
164
162
|
- [Installation](docs/installation.md): symlinks, status-line wiring, Python version.
|
|
165
|
-
- [Configuration](docs/
|
|
163
|
+
- [Configuration](docs/commands/config.md): every setting `cctally config` accepts, its default, and whether the dashboard can change it.
|
|
164
|
+
- [The config.json file](docs/configuration.md): file shape, reserved collector keys, week-start rules.
|
|
166
165
|
- [Architecture](docs/architecture.md): data flow, caches, week boundaries.
|
|
167
166
|
- [Telemetry](docs/telemetry.md): the anonymous install-count beat, in full.
|
|
168
167
|
- [Command reference](docs/commands/): one page per subcommand.
|
package/bin/_cctally_config.py
CHANGED
|
@@ -576,11 +576,14 @@ def _resolve_one_account_budget_ref(
|
|
|
576
576
|
import re as _re
|
|
577
577
|
import _lib_accounts
|
|
578
578
|
if not isinstance(ref, str) or not ref:
|
|
579
|
-
raise _BudgetConfigError(
|
|
579
|
+
raise _BudgetConfigError(
|
|
580
|
+
f"{label} keys must be non-empty strings", field=label
|
|
581
|
+
)
|
|
580
582
|
if ref in (_lib_accounts.UNATTRIBUTED, _lib_accounts.VENDOR_WIDE):
|
|
581
583
|
raise _BudgetConfigError(
|
|
582
584
|
f"{label} cannot target the reserved {ref!r} bucket "
|
|
583
|
-
"(per-account budgets target real accounts only)"
|
|
585
|
+
"(per-account budgets target real accounts only)",
|
|
586
|
+
field=label,
|
|
584
587
|
)
|
|
585
588
|
if _re.fullmatch(r"[0-9a-f]{32}", ref):
|
|
586
589
|
return ref # already an immutable account_key
|
|
@@ -589,17 +592,19 @@ def _resolve_one_account_budget_ref(
|
|
|
589
592
|
raise _BudgetConfigError(
|
|
590
593
|
f"{label}: cannot resolve account ref {ref!r} while stats.db "
|
|
591
594
|
"maintenance is in progress; retry after it completes or use "
|
|
592
|
-
"the 32-hex account key"
|
|
595
|
+
"the 32-hex account key",
|
|
596
|
+
field=label,
|
|
593
597
|
)
|
|
594
598
|
raise _BudgetConfigError(
|
|
595
599
|
f"{label}: cannot resolve account ref {ref!r} — no accounts observed "
|
|
596
|
-
"yet; use the 32-hex account key"
|
|
600
|
+
"yet; use the 32-hex account key",
|
|
601
|
+
field=label,
|
|
597
602
|
)
|
|
598
603
|
try:
|
|
599
604
|
return _lib_accounts.resolve_account_ref(conn, ref, provider)
|
|
600
605
|
except _lib_accounts.AccountRefError:
|
|
601
606
|
raise _BudgetConfigError(
|
|
602
|
-
f"{label}: unknown or ambiguous account ref {ref!r}"
|
|
607
|
+
f"{label}: unknown or ambiguous account ref {ref!r}", field=label
|
|
603
608
|
)
|
|
604
609
|
|
|
605
610
|
|
|
@@ -662,9 +667,11 @@ def _parse_account_budget_value(raw_value: str, provider: str,
|
|
|
662
667
|
try:
|
|
663
668
|
parsed = json.loads(raw_value)
|
|
664
669
|
except (json.JSONDecodeError, ValueError):
|
|
665
|
-
raise _BudgetConfigError(
|
|
670
|
+
raise _BudgetConfigError(
|
|
671
|
+
f"{label} must be a JSON object, got {raw_value!r}", field=label
|
|
672
|
+
)
|
|
666
673
|
if not isinstance(parsed, dict):
|
|
667
|
-
raise _BudgetConfigError(f"{label} must be a JSON object")
|
|
674
|
+
raise _BudgetConfigError(f"{label} must be a JSON object", field=label)
|
|
668
675
|
return _normalize_account_budget_refs(parsed, provider, label)
|
|
669
676
|
|
|
670
677
|
|
|
@@ -678,7 +685,8 @@ def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
|
|
|
678
685
|
return float(raw_value)
|
|
679
686
|
except ValueError as exc:
|
|
680
687
|
raise _BudgetConfigError(
|
|
681
|
-
"budget.codex.amount_usd must be a finite number > 0"
|
|
688
|
+
"budget.codex.amount_usd must be a finite number > 0",
|
|
689
|
+
field="budget.codex.amount_usd",
|
|
682
690
|
) from exc
|
|
683
691
|
if leaf == "period":
|
|
684
692
|
return raw_value.strip()
|
|
@@ -688,7 +696,10 @@ def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
|
|
|
688
696
|
return True
|
|
689
697
|
if normalized in {"false", "no", "off", "0"}:
|
|
690
698
|
return False
|
|
691
|
-
raise _BudgetConfigError(
|
|
699
|
+
raise _BudgetConfigError(
|
|
700
|
+
f"budget.codex.{leaf} must be a boolean",
|
|
701
|
+
field=f"budget.codex.{leaf}",
|
|
702
|
+
)
|
|
692
703
|
if leaf == "alert_thresholds":
|
|
693
704
|
if not raw_value.strip():
|
|
694
705
|
return []
|
|
@@ -699,10 +710,13 @@ def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
|
|
|
699
710
|
except ValueError as exc:
|
|
700
711
|
raise _BudgetConfigError(
|
|
701
712
|
"budget.codex.alert_thresholds must be a comma-separated "
|
|
702
|
-
"list of integers"
|
|
713
|
+
"list of integers",
|
|
714
|
+
field="budget.codex.alert_thresholds",
|
|
703
715
|
) from exc
|
|
704
716
|
return parsed
|
|
705
|
-
raise _BudgetConfigError(
|
|
717
|
+
raise _BudgetConfigError(
|
|
718
|
+
f"unknown Codex budget leaf {leaf!r}", field=f"budget.codex.{leaf}"
|
|
719
|
+
)
|
|
706
720
|
|
|
707
721
|
|
|
708
722
|
def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
|
|
@@ -713,13 +727,17 @@ def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
|
|
|
713
727
|
becomes a partially repaired write.
|
|
714
728
|
"""
|
|
715
729
|
if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
716
|
-
raise _BudgetConfigError(
|
|
730
|
+
raise _BudgetConfigError(
|
|
731
|
+
f"unknown Codex budget leaf {key!r}", field=key
|
|
732
|
+
)
|
|
717
733
|
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
718
734
|
if leaf not in CODEX_BUDGET_LEAVES:
|
|
719
|
-
raise _BudgetConfigError(
|
|
735
|
+
raise _BudgetConfigError(
|
|
736
|
+
f"unknown Codex budget leaf {key!r}", field=key
|
|
737
|
+
)
|
|
720
738
|
budget = config.get("budget")
|
|
721
739
|
if budget is not None and not isinstance(budget, dict):
|
|
722
|
-
raise _BudgetConfigError("budget must be an object")
|
|
740
|
+
raise _BudgetConfigError("budget must be an object", field="budget")
|
|
723
741
|
existing = (budget or {}).get("codex")
|
|
724
742
|
if existing is None:
|
|
725
743
|
# `accounts` (#341) is valid without a vendor-wide amount_usd, so it —
|
|
@@ -727,13 +745,15 @@ def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
|
|
|
727
745
|
if leaf not in ("amount_usd", "accounts"):
|
|
728
746
|
raise _BudgetConfigError(
|
|
729
747
|
"budget.codex.amount_usd must be configured before setting "
|
|
730
|
-
f"budget.codex.{leaf}"
|
|
748
|
+
f"budget.codex.{leaf}",
|
|
749
|
+
field=f"budget.codex.{leaf}",
|
|
731
750
|
)
|
|
732
751
|
prospective: dict = {}
|
|
733
752
|
else:
|
|
734
753
|
if not isinstance(existing, dict):
|
|
735
754
|
raise _BudgetConfigError(
|
|
736
|
-
f"budget.codex must be an object or null, got {type(existing).__name__}"
|
|
755
|
+
f"budget.codex must be an object or null, got {type(existing).__name__}",
|
|
756
|
+
field="budget.codex",
|
|
737
757
|
)
|
|
738
758
|
# Strictly validate first: malformed existing data must abort without
|
|
739
759
|
# mutation, while valid unknown siblings retain the validator's
|
|
@@ -762,8 +782,8 @@ _QUOTA_RULE_KEYS = {
|
|
|
762
782
|
}
|
|
763
783
|
|
|
764
784
|
|
|
765
|
-
def _quota_alert_error(message: str) -> None:
|
|
766
|
-
raise _cctally_core._AlertsConfigError(message)
|
|
785
|
+
def _quota_alert_error(message: str, *, field: str = "alerts.quota") -> None:
|
|
786
|
+
raise _cctally_core._AlertsConfigError(message, field=field)
|
|
767
787
|
|
|
768
788
|
|
|
769
789
|
def _validate_quota_thresholds(name: str, value: object) -> list[int]:
|
|
@@ -795,7 +815,10 @@ def _get_quota_alerts_config(cfg: "dict | None") -> dict:
|
|
|
795
815
|
if alerts is None:
|
|
796
816
|
alerts = {}
|
|
797
817
|
if not isinstance(alerts, dict):
|
|
798
|
-
|
|
818
|
+
# The helper's default names `alerts.quota`, which is right for every
|
|
819
|
+
# other call here and wrong for this one: the block that is not an
|
|
820
|
+
# object is `alerts` itself.
|
|
821
|
+
_quota_alert_error("alerts must be an object", field="alerts")
|
|
799
822
|
quota = alerts.get("quota", {})
|
|
800
823
|
if quota is None or not isinstance(quota, dict):
|
|
801
824
|
_quota_alert_error("alerts.quota must be an object")
|
|
@@ -1316,7 +1339,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1316
1339
|
configured = _set_codex_budget_leaf(config, key, raw)
|
|
1317
1340
|
budget = config.get("budget")
|
|
1318
1341
|
if budget is not None and not isinstance(budget, dict):
|
|
1319
|
-
raise _BudgetConfigError("budget must be an object")
|
|
1342
|
+
raise _BudgetConfigError("budget must be an object", field="budget")
|
|
1320
1343
|
block = dict(budget or {})
|
|
1321
1344
|
block["codex"] = configured
|
|
1322
1345
|
config["budget"] = block
|
|
@@ -2206,13 +2229,14 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2206
2229
|
config = _load_config_unlocked()
|
|
2207
2230
|
budget = config.get("budget")
|
|
2208
2231
|
if budget is not None and not isinstance(budget, dict):
|
|
2209
|
-
raise _BudgetConfigError("budget must be an object")
|
|
2232
|
+
raise _BudgetConfigError("budget must be an object", field="budget")
|
|
2210
2233
|
existing = (budget or {}).get("codex")
|
|
2211
2234
|
if existing is None:
|
|
2212
2235
|
return 0
|
|
2213
2236
|
if not isinstance(existing, dict):
|
|
2214
2237
|
raise _BudgetConfigError(
|
|
2215
|
-
f"budget.codex must be an object or null, got {type(existing).__name__}"
|
|
2238
|
+
f"budget.codex must be an object or null, got {type(existing).__name__}",
|
|
2239
|
+
field="budget.codex",
|
|
2216
2240
|
)
|
|
2217
2241
|
validated_existing = _validate_codex_budget_block(existing)
|
|
2218
2242
|
assert validated_existing is not None
|