# ccpace

Claude usage calendar for your terminal. See your 5h limit, weekly pool,
and model-scoped limits together, with forecasts and history from your own usage.

[![PyPI](https://img.shields.io/pypi/v/ccpace)](https://pypi.org/project/ccpace/)
[![Tests](https://github.com/thevibeworks/ccpace/actions/workflows/check.yml/badge.svg)](https://github.com/thevibeworks/ccpace/actions/workflows/check.yml)
[![MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

[Website](https://thevibeworks.github.io/ccpace/) · [Install](#start) ·
[Calendar](#calendar) · [Data contract](docs/data.md) · [Changelog](CHANGELOG.md) ·
[For agents](llms.txt)

![ccpace usage calendar in Spectrum](https://raw.githubusercontent.com/thevibeworks/ccpace/main/docs/calendar-previews/calendar-120.png)

Synthetic demo: the 5h allowance is 88% used while the weekly forecast
leaves about 39% unused. The calendar keeps both conditions visible.
Run the same scenario with `uvx ccpace --calendar --demo`.

## Start

Requires [uv](https://docs.astral.sh/uv/), macOS or Linux, and a terminal.
Python 3.11 or later is resolved by uv.

```sh
uvx ccpace --calendar --demo   # try it without a Claude account
uvx ccpace --calendar          # your accounts, after claude login
uvx ccpace                    # compact one-shot view
uvx ccpace --watch            # compact watch view
```

Upgrade an existing uv installation with `uv tool upgrade ccpace`, or run
`uvx --refresh ccpace --calendar`. The calendar is opt-in.

Claude Code credentials are discovered from `~/.claude/.credentials*.json`.
Both `.credentials.work.json` and `work.credentials.json` name an account;
`-f PATH` selects explicit credential files. A subscription login is needed
for live usage. API-key billing and Codex collection are not supported.

## Calendar

- **Current limits stay visible.** 5h, aggregate 7d, and scoped weekly
  allowances are separate counters with their own reset times.
- **Browse the week.** Interval totals and hourly patterns show where usage
  accumulated. Enter opens hourly detail; History lists quota periods.
- **Forecast from your history.** The same model as claude-code-statusline
  learns weekday and hourly burn. A short history uses a labeled linear
  fallback; the learned forecast requires at least 14 days of history.
- **Warnings without execution control.** Alerts record condition changes,
  and existing notification channels can carry them elsewhere. ccpace never
  pauses, launches, switches models, or steers an agent.

Unavailable cells stay blank. Selecting one explains whether observations
are missing, a forecast is unavailable, or the next quota period has yet
to begin. Observed zero is `0.0`; `+` means a partial observed amount;
`~` marks a forecast; `|` marks a quota reset. Long gaps are not assigned
to individual hours, and forecasts end at the current pool or access boundary.

Spectrum uses mint for usage, cyan for forecasts, and rose for model
identity. Amber and red remain pressure signals. Quiet and Paper are also
available; `NO_COLOR` is honored.

```sh
ccpace --calendar --theme spectrum
ccpace --calendar --theme quiet
ccpace --calendar --theme paper
```

The theme picker and `Ctrl+t` change palettes during a run.
`CCPACE_THEME` sets the default. Light terminal backgrounds are detected
through `COLORFGBG` when it is available.

| Action | Key |
| --- | --- |
| Select an interval | Arrow keys |
| Hourly detail / back | Enter / Escape |
| Previous / next week | `[` / `]` |
| Today | `t` |
| Calendar / History / Alerts | `1` / `2` / `3` |
| Next account / meter | `a` / `m` |
| Acknowledge selected alert | `x` |
| Refresh / quit | `r` / `q` |

Mouse selection and scrolling work too. Compact terminals keep the calendar
and move details below it; narrow terminals use a daily agenda.

Demo scenarios: `mixed`, `weekly`, `scoped`, `stale`, `cold`, `reset`,
`credits`, `rebase`, and `weekly-only`. `d` cycles scenarios; `r` advances
the synthetic clock five minutes. Demo mode reads no credentials, makes
no provider requests, writes no usage or alert state, and sends no notifications.

## Notifications

```sh
ccpace --calendar --ntfy https://ntfy.sh/your-topic
ccpace --calendar --bark https://api.day.app/YOUR_KEY
ccpace --calendar --notifier ./notify-usage.sh
```

These options also work with `--watch`. Bare `--bark` uses `BARK_KEY` and
`BARK_SERVER`. Environment equivalents: `CCPACE_NTFY`, `CCPACE_BARK`,
`CCPACE_NOTIFIER`, `CCPACE_INTERVAL`, `CCPACE_THRESHOLD`, and `CCPACE_TZ`.

Custom notifiers receive JSON on stdin with `id`, `event`, `account`, and
`data`. Calendar events add stable condition and transition IDs, provider,
meter, observation time, and forecast provenance. Forecast notices require
two distinct observations; cap notices are immediate. A reset clock passing
does not establish recovery: a fresh observation must confirm it.

Calendar alert state is bounded to 200 events in `calendar-alerts.json`.
Acknowledgement marks a reviewed event without clearing its condition.
Delivery marked `attempted` does not prove receipt by an agent or device.
Weekly underuse thresholds are experimental: 20 points to enter, 15 to clear.

## Data and limits

Usage comes from the same undocumented OAuth endpoints Claude Code uses,
not transcript token estimates. Samples and fetch caches are shared with
[claude-code-statusline](https://github.com/thevibeworks/claude-code-statusline)
under `~/.claude/statusline`. History is partitioned by account UUID;
directory placement alone is not identity. The calendar requires a known
account UUID before displaying history.

`CCPACE_DATA_DIR` relocates the store; `--no-log` disables usage-sample
logging. Derived caches and calendar alert state still update. Fetching
uses the shared cache, activity gating, and reset boundaries; errors retain
the last observation with its age. The default interval is 15 minutes,
with jitter and a 60-second minimum.

Subscription percentages are not interchangeable credit balances. A model
can consume both its scoped allowance and the shared limits. A 5h cap does
not imply a depleted week, and a scoped cap does not imply every model is
blocked. Paid continuation and session-specific modes require their own
evidence. ccpace does not promise capacity or a particular continuation path.

Provider endpoints can change. Forecasts are estimates. API requests are
read-only except expired-token refresh, which writes the refreshed OAuth
token back to the credentials file. Local usage data stays local; enabled
notifications send messages to the destinations you configure.

## Claude Code plugin

```text
/plugin marketplace add thevibeworks/ccpace
/plugin install ccpace@ccpace
```

The `/ccpace` skill reports usage in a conversation. Interactive calendar
and watch views run in a separate terminal. The compact monitor can also
run from a downloaded `ccpace.py`; the calendar needs the full package or checkout.

## Verify and contribute

```sh
git clone https://github.com/thevibeworks/ccpace
cd ccpace
make check
make demo
make build
```

Tests cover quota accounting, forecast boundaries, account isolation,
notification transitions, and calendar navigation at 50, 80, 120, and 160
columns. Test data is synthetic and isolated from the real usage store.
These checks validate behavior, not forecast accuracy on every workload.

The terminal UI uses [Textual](https://textual.textualize.io/). Collection
uses [HTTPX](https://www.python-httpx.org/). The shared store and forecast
contract are developed alongside claude-code-statusline.

[Contributing](CONTRIBUTING.md) · [Calendar design](docs/calendar-tui.md) ·
[Theme previews](docs/calendar-previews/README.md) · [Data contract](docs/data.md)

MIT. Unofficial; not affiliated with Anthropic.

---

# Usage calendar

Available in ccpace 0.9.0 as the opt-in `--calendar` view.
All screen values and account labels below are synthetic.

The implementation provides one `--calendar` view with interval totals
and hourly patterns, live collection, synthetic scenarios, day inspection,
period history, and persistent warning transitions. See the
[calendar preview](calendar-previews/README.md) and
[usage reference](../README.md#calendar).

The design below also records further work: user-configurable warning
thresholds. The experiment currently uses 20/15
weekly percentage points for surplus entry/recovery and two fresh
observations for forecast changes. Those defaults are provisional, not
claimed as calibrated from workload replay. Spectrum, Quiet, and Paper
are selected with `--theme` or `CCPACE_THEME`; the picker and `Ctrl+t`
switch during a run. Light `COLORFGBG` selects Paper when no theme is set.
`NO_COLOR` is honored.

## The problem

An account can exhaust its 5h allowance repeatedly and still finish the
week with useful 7d capacity unused. The calendar must make both conditions
visible together: pressure during a sitting, and how usage is distributed
across the week. A single account health color cannot describe that state.

The first experiment watches, explains, and warns. It does not pause,
resume, launch, reroute, or steer agents. Task scheduling, reservations,
and personal-calendar integration are outside this experiment.

## Subscription limits are the domain

These are overlapping constraints on usage, not interchangeable balances.

| Meter | What it constrains | What a reset changes |
| --- | --- | --- |
| Claude 5h | Included usage in the current short window | That window's allowance; not the weekly counters |
| Claude 7d, all models | Aggregate included usage over its weekly window | The aggregate weekly allowance |
| Claude 7d, Fable or another scope | Included usage for the reported model/surface | That scope's allowance at its own reported reset |
| Paid usage credits | The paid continuation path, with its own balance and spending controls | Follow the reported credit/spend contract, not the 5h clock |

A Fable request can consume the short, aggregate weekly, and Fable weekly
allowances together. Switching models does not restore the shared 5h or
7d allowance. A scoped cap can bind while aggregate headroom remains.
Percentages have different denominators: never add them or interpret the
smallest remaining percentage as a common amount of work.

Claude's public documentation describes shared usage across product
surfaces, five-hour windows, and weekly limits. The statusline's observed
wire contract supplies the more specific `session`, `weekly_all`, and
`weekly_scoped` meters, including Fable. Label that distinction in source
documentation; do not pretend the public documentation specifies Fable's
capacity or its conversion to aggregate quota.

Codex has a similar need for multiple meters, but its protocol is not a
Claude-shaped constant. It supplies optional primary/secondary windows,
their durations and reset timestamps, named limit groups, credits, and
spend-control state. Its app-server exposes a snapshot and sparse update
notifications. Missing windows are unavailable, not zero or unlimited.
Use reported durations and identities when a Codex adapter is added.

Observed cap, predicted exhaustion, and guaranteed interruption are
different facts. Paid continuation or a session-specific mode can change
what happens at a cap; current Codex documentation also allows some active
turns to continue subject to fair-use limits. The TUI says which included
allowance is exhausted. It claims a continuation path only with evidence.

## First screen

One selected account, with a compact account switcher. Keep its current
limits pinned above the calendar, even when inspecting history. The
calendar opens on the local civil week containing today; quota resets are
events within that calendar, not calendar boundaries.

![Calendar with synthetic observations](calendar-previews/calendar-120.png)

Numeric cells and hourly strips share the same week grid. Large terminals
show both within each cell; the inspector adds the selected day's pattern.
Compact terminals keep the numeric calendar and open the hourly inspector
on Enter. There is no layout picker.

Unavailable values remain blank. Selecting a cell explains missing
observations, an unavailable forecast, or a future quota period. Observed
zero remains `0.0`. Partial observations have a trailing `+`; forecast
values use `~`; `|` marks a quota reset. No repeated uncertainty symbols.

The weekly forecast ends at the current pool's reset. The Wednesday 08-12
cell therefore contains a boundary and an unobserved portion. Do not
extend this pool's remaining balance into a new, unobserved pool.

The calendar's four-hour buckets organize wall time. They are explicitly
not the provider's five-hour windows. Selecting a bucket opens its precise
time range, coverage, burn, and the real quota windows that overlap it.
Daily totals may accompany the columns when space permits.

## Interaction and layout

- Calendar is the default view. Left/right selects a day; up/down selects
  a time band. Enter opens the day inspector; Escape returns with selection
  preserved. The account picker preserves the selected date where possible.
- The inspector expands the day to hourly detail, with separate 5h, 7d all,
  and scoped tracks. Reset markers and cap observations align to one clock.
- Calendar metric selection defaults to aggregate weekly burn. Switching
  to Fable changes the units and scale label with the data. Account or
  model percentages are never combined into a fleet total.
- History lists actual quota periods, with observed consumption, capped
  intervals, remaining allowance at the final observation, and coverage.
  A low final reading is not exact unused-at-reset capacity if the last
  observation was hours before reset. Enter returns to that period's calendar.
- Alerts lists condition transitions and delivery state. Acknowledging a
  warning suppresses repeat attention for that condition; it does not hide
  the current cap or mark the condition resolved.
- Refresh requests a fresh observation through the existing shared fetch
  discipline. Browsing, changing metrics, and selecting accounts read local
  state; they do not trigger network traffic.
- At 120 columns, the inspector can sit beside the calendar. At 80 columns,
  it replaces the calendar body. At narrow widths the same dates become an
  agenda list. Short terminals scroll the body while retaining identity,
  current constraints, and freshness. No clipped reset timestamps.
- Terminal resize, background refresh, and forecast rebuild preserve focus,
  scroll position, and the selected historical interval. Inspecting history
  never silently jumps back to now.

## Visual direction

Use terminal-native typography, aligned numeric columns, open sections,
and a small number of rules. Spectrum uses mint for observed burn, cyan
for forecasts and selection, and rose for scoped-model identity. Amber/red
mean pressure. Quiet reduces the saturation; Paper supplies a light palette.
Color supplements text and glyphs; all states survive monochrome rendering.

The 5h warning and weekly opportunity each have a stable line. They do not
rotate through one notification slot. High calendar burn is dense ink,
not automatically red: productive usage is not itself a failure.

No healthy-state animation, flashing background, nested boxes, large logo,
or percentage repeated in several competing widgets. Refresh updates data
in place. A new warning changes its marker once and stays inspectable.

All meter percentages are labeled USED. Remaining amounts are written as
such. Keep absolute reset times, explicit timezone, and a visible observation
time. Relative text can supplement them only while the watch clock is live.

## Evidence and forecasts

Reuse the shared account-partitioned store and forecast model. Do not
create another writer dialect or a different forecast inside the renderer.
Retain the existing schema, provenance, and co-writer rules.

The current ledger and hour profile are useful inputs, but they are not
yet a complete calendar evidence model. The current envelope attributes a
delta to the later sample's hour. A calendar inspector must retain the
interval between observations; otherwise it gives false precision to an
hour after a long observation gap.

For the experiment:

- A delta observed across several buckets remains an interval observation;
  show uncertain coverage instead of inventing its exact distribution.
- Unknown coverage and observed zero burn have distinct appearances.
  Merely being between the oldest and newest sample is not proof of idleness.
- A counter drop is not negative consumption. Distinguish new windows,
  stale echoes, and suspected rebases; never sum percentage points across
  pools whose capacities changed as though their denominators were equal.
- Cold history shows observations and reset clocks. A forecast either names
  its existing fallback or stays unavailable under the current model's gates.
- A stale fetch keeps its observation timestamp. Forecast timestamps do
  not make old provider observations appear fresh. Stale state cannot emit
  a newly asserted cap, recovery, or definite surplus.
- A weekly underuse projection is conditional on the observed pattern.
  It does not prove that the remaining 5h opportunities can absorb all the
  surplus, or that the person has useful work to run in them.
- Low historical activity means low activity. It does not establish human
  sleep, availability, or permission for autonomous work.
- Civil dates use the selected IANA timezone; repeated/missing DST hours
  retain their offsets in the inspector. A 168-hour pool can span portions
  of eight local dates. Never force it into seven equal day cells.

## Watch warnings and future hooks

The UI and hooks consume the same condition objects. Keep these distinct:

| Condition | Example |
| --- | --- |
| Short-window pressure | 5h cap projected before its reset |
| Weekly underuse | 7d forecast leaves substantial unused allowance at reset |
| Scoped constraint | Fable weekly allowance capped while aggregate headroom remains |
| Scoped underuse | Some scoped headroom is forecast to remain unreachable at the current mix |
| Observation failure | Last good observation is stale; current state unknown |
| Recovery | A fresh observation confirms the relevant allowance is available again |

Short-window pressure can coexist with weekly underuse. Neither suppresses
the other. Surplus is an informational opportunity, not an instruction to
generate work or change the agent's effort.

Extend the current JSON notifier envelope additively. A future condition
payload needs stable provider/account/meter/window identity, observed time,
used percentage, reset time, forecast provenance, and transition identity.
Forecasts additionally carry computed time and assumptions. Distinguish the
identity of a condition from the identity of each delivery/transition.

Emit on entry, meaningful escalation, and confirmed recovery. Coalesce
duplicates across restarts. Forecast warnings require sustained evidence
and a separate recovery threshold to avoid flapping; a newly observed cap
does not wait for forecast confirmation. Numeric warning defaults need
history replay before being chosen. A timer passing a reset triggers a
refresh, not a fabricated recovery event.

For v1, hook consumers can notify or record. No bundled hook sends agent
instructions, changes models, or controls execution. Later steering can
consume the same factual events through an explicit runner integration.
The TUI must not claim an agent was notified merely because a hook ran.

## What makes the experiment convincing

First build an interactive terminal prototype against synthetic scenarios,
then connect the existing collector. Keep the prototype's role explicit;
screen design alone does not validate the forecast or warning thresholds.

The key scenarios are: 5h pressure plus weekly underuse; aggregate weekly
cap with scoped headroom; scoped cap with aggregate headroom; confirmed
reset; paid continuation; sparse history; stale observations; a rebase;
and an account with no short window. Check 80x24, 120x36, 160x48, narrow
terminals, monochrome, both light/dark backgrounds, and live resizing.

A user should be able to identify the next constraining allowance, its
reset, and the week's projected unused capacity from the first screen.
They should then be able to inspect when usage happened without losing
the current warning. Hook output and the displayed condition must agree.

After dogfooding, evaluate warning lead time, false alarms, repeated 5h
cap episodes, and coverage-qualified weekly unused allowance. Interpret
changes alongside actual workload; higher consumption alone is not success.

## Sources checked

Public documentation checked 2026-09-07; observed contracts are identified
separately because provider behavior and fields can change.

- [Claude usage limits](https://claude.com/pricing): shared surfaces,
  five-hour windows, weekly and possible model/feature limits.
- [Claude usage credits](https://support.claude.com/en/articles/12429409-manage-usage-credits-for-paid-claude-plans):
  paid continuation is separate from included usage.
- [Statusline observed OAuth contract](https://github.com/thevibeworks/claude-code-statusline/blob/51ecf1723403fecc71502900ee2c5f974acb5710/docs/api/oauth-usage.md):
  captured generic limits and Fable scope; subscription dollar fields
  observed null. Capture dates are in that document.
- [Codex usage and pricing](https://learn.chatgpt.com/docs/pricing):
  variable work per allowance, shared local/cloud usage, possible weekly
  limits, credits, and active-turn continuation qualifications.
- [Codex protocol snapshot](https://github.com/openai/codex/blob/d52478c52ef09f001142a4b82339467c3880877f/codex-rs/protocol/src/protocol.rs):
  optional windows, durations, reset timestamps, credits, and spend controls.
- [Codex app-server snapshot](https://github.com/openai/codex/blob/d52478c52ef09f001142a4b82339467c3880877f/codex-rs/app-server/README.md):
  account rate-limit reads and sparse update notifications.
- [ccpace data contract](data.md) and [visual grammar](../DESIGN.md):
  shared observations, forecast provenance, quiet presentation, and events.

---

# ccpace data contract (store v1)

The store is the product's foundation: every forecast, ledger cell, and
notification derives from it. This file is the contract; code that
disagrees with it is wrong.

## Design rulings

- One shared store, two writers. ccpace adopts the record shape that
  thevibeworks/claude-code-statusline already writes (typed JSONL,
  epoch timestamps, top-level window sections). No second dialect:
  claudex's old `{ts,label,usage:{...}}` shape is read-compatible but
  never written.
- Raw API responses are the payload; derived numbers (pace, forecast)
  live in caches, never in the log. A log you can replay beats a log
  you must trust.
- Unknown is not zero. Gaps in the record render as unknown, and the
  forecast refuses to speak below its minimum history.

## Layout

    $CCPACE_DATA_DIR/                 default: ~/.claude/statusline
      usage.jsonl, *.cache            the untagged account (see identity below)
      calendar-alerts.json            bounded derived calendar conditions/events
      calendar-alerts.lock            serializes calendar notification transitions
      accounts/<account>/             tagged / named accounts
        usage.jsonl                   append-only samples (this spec)
        usage.jsonl.1                 single rotation backup
        usage.cache                   raw /api/oauth/usage response + fetched_at
        usage.err                     statusline's fetch-error state (ccpace: read never, write never)
        profile.cache                 raw /api/oauth/profile response (mtime = fetch time)
        prepaid_credits.cache         raw prepaid credits response + fetched_at
        forecast.cache                derived weekday burn profile (rebuildable)

The default root deliberately equals statusline's home: same machine,
same account, one history. Override with CCPACE_DATA_DIR only to
isolate (tests, exotic setups).

The experimental calendar reads the same usage records and forecast model.
Its separate alert journal contains at most 200 transitions and eight days
of condition state, with stable condition IDs, observation timestamps, and
transition IDs. It is derived state, not an additional usage-log format.
The lock serializes read/update/write across calendar processes. Delivery
is recorded as attempted, not confirmed. Demo mode never opens this journal.

### Account identity

`<account>` is the write key, and it is the same key statusline uses
for the same session:

- a named credentials file is its own account: `work.credentials.json`
  or `.credentials.work.json` -> `work` (the part that is not
  "credentials"; only `.credentials.json` is the default);
- the default file (`claude login`, or a runner's overlay) is whoever
  the runner says — statusline's rule, sanitized the same way:
  `STATUSLINE_ACCOUNT`, else `DEVA_AUTH_TAG` (`auth-file-<stem>` ->
  `<stem>`, `auth-default` = none), else the pre-0.18
  `DEVA_AUTH_DETAILS` stem, else untagged -> the store ROOT. It is
  displayed by that tag, or `default`.

Directories are where a sample was WRITTEN, not who it belongs to: the
same account lands at the root when an untagged statusline fetched, in
`accounts/<tag>/` when a tagged container did, in `accounts/<alias>/`
when an older ccpace did. Readers therefore read every store under the
root and partition by `user.uuid`; only without a known uuid (no profile
yet) does ccpace fall back to the account's own directories. Cross-account
mixing is the classic corruption here (statusline observed 9000%/day burn
rates) — never trust placement alone.

A row with no `user.uuid` (the field is younger than the log) is dropped
from every uuid-partitioned read, and the drop is counted (`corpus.
dropped_no_uuid`). Not guessed: some of those rows carry an email that
would identify them, and guessing identity on a store that already
proved it interleaves accounts is how burn rates get manufactured. Loss
is acceptable; silent loss is not.

## usage.jsonl records

One JSON object per line. `type` discriminates; readers skip unknown
types (forward compatibility). `timestamp` is unix epoch seconds (int).

### type: "usage" — a sample

    {
      "type": "usage",
      "timestamp": 1754870000,
      "source": "ccpace/0.1.0",          // writer + version; statusline omits
      "session_id": null,                 // statusline sets; ccpace watch has none
      "user": {
        "email": "...", "name": "...", "uuid": "...",
        "display_name": "...",
        "subscriptions": {"claude_pro": false, "claude_max": true}
      },
      "organization": {
        "name": "...", "type": "claude_max",
        "billing_type": "stripe_subscription",
        "rate_limit_tier": "default_claude_max_20x"
      },
      "five_hour":  {...},                // raw API section, verbatim
      "seven_day":  {...},                // raw API section, verbatim
      "seven_day_opus": {...},            // legacy section when present
      "extra_usage": {...},               // raw API section, verbatim
      "limits": [...],                    // raw API array, verbatim
      "model": null,                      // statusline sets from session
      "predicted_end": null               // statusline's walk; ccpace omits
    }

ccpace additions are additive only (`source`); it never renames or
re-nests statusline fields. Terminal weekly snapshots and idle responses
served from cache are NOT logged — the log records observations, not echoes.

### type: "session_start" / "session_end" — statusline's markers

    {"type":"session_start","session_id":"...","timestamp":...,
     "five_hour_window_end":"...","seven_day_window_end":"..."}
    {"type":"session_end","session_id":"...","timestamp":...}

ccpace never writes these (no session), always tolerates them.

## Rotation

32 MiB cap (USAGE_LOG_MAX_BYTES), single `.1` backup, mkdir-based
lock (`usage.jsonl.rotate.lock`) — identical to statusline so either
writer can rotate without eating the other's history. Readers read
`.1` then current.

## Caches (derived, disposable)

- profile.cache: raw profile, mtime is the fetch time. TTL 24h. The
  tier chip does not depend on it: `rateLimitTier` / `subscriptionType`
  are read from the credentials file itself (the CLI keeps them there),
  so a plan change shows on the next frame with no request.
- prepaid_credits.cache: raw response + `fetched_at`. TTL 5 min in
  statusline, 1 h in ccpace (a balance only moves on a purchase; spend
  is already in the usage payload). Not fetched at all when the usage
  payload says `extra_usage.credits_ever_enabled: false`.
- forecast.cache: `{schema, computed_at, days_history, recent_24h,
  recent_48h, weekday_profile:{"0".."6"}, hour_profile:{"0".."23"}, ...}`
  — statusline's shape, and statusline computes a SUPERSET off the same
  log (`pct_per_window`, `scoped_*`, `cost`). Rebuild at most hourly.
  Unknown weekday = -1.

  `weekday_profile` is %/day; `hour_profile` is 24 dimensionless
  MULTIPLIERS on it by local hour, mean 1.0, so a whole day still burns
  its weekday total and only the shape inside the day changes. Floored at
  0.1 and rounded at BUILD time by whichever writer built it — both
  writers implement the same arithmetic, and a reader that re-rounded
  would be a third opinion. Omitted when nothing was learned; a reader
  takes flat (all 1.0) for absent, short, out-of-range or off-mean, and
  never goes silent over it. `REST_MULT_MAX = 0.25` marks a rest hour, and
  `REST_SLOT_AWAKE_MIN_SECS = 9000` marks a 5h ledger slot as a night when
  under half of it is waking. Full contract: docs/statusline-interop.md.

  This is the one derived cache more than one tool wants to write, so
  `schema` versions the MODEL (2 = envelope burn) and the rule cuts both
  ways. **Reading**: freshness is necessary and not sufficient — a cache
  whose schema is missing or lower is rebuilt on sight, however recently
  it was written. **Writing**: stamp the schema you actually implement,
  and MERGE into what is already there. ccpace rebuilt only the five
  fields it knew for months, which silently truncated statusline's
  exchange rate, per-model profile and price join on every run; those
  surfaces then said "still learning" until the next hourly scan. A
  writer owns the keys it computes and nothing else.

  When a fresh cache of our own schema is already there, read it rather
  than recompute: same numbers, one scan. See claude-code-statusline
  `docs/api/state-dir.md`, "The co-writer contract".

  `corpus` is the stamp that says WHICH samples the model was run over —
  `{uuid, files, samples, dropped_no_uuid, oldest}`. `schema` versions
  the model and cannot carry this: two writers that agree on envelope
  burn and read different stores both pass the gate, and the same
  account then gets `days_history: 28` from one binary and `301` from
  the other depending on which rendered last. The stamp is informative,
  not a gate; a reader that wants to know why two caches disagree reads
  it, and a rebuild overwrites it.

## Shared fetch pool

usage.cache and profile.cache are not private caches — they are the
pool. Whichever tool fetched last serves both:

- Before fetching usage, read usage.cache; a `fetched_at` younger than
  60 s IS the fetch. Samples served from the pool are not re-logged
  (the fetcher already logged them — one observation, one record).
- After a successful fetch, publish it: raw response + `fetched_at`,
  written atomically (tmp + rename) so a concurrent reader never sees
  a torn file. Same for profile.cache (raw profile, mtime is the fetch
  time, 24 h TTL — statusline's rule).
- Locks (`usage.lock` etc.) are advisory between statusline processes;
  cross-tool safety comes from atomic rename, and the worst race costs
  one duplicate fetch, never a corrupt cache.
- usage.err is statusline's own fetch-error cooldown. ccpace neither
  writes it (a `Retry-After: 3600` written there would freeze every
  statusline render on the machine) nor gates on it (a poller on a
  15-min tick has nothing to gain from sitting out an hour, and a
  fresh usage.cache it publishes is served by statusline regardless).
  ccpace's own failures live in memory for the run: the last usage.cache
  is shown at any age with a `(stale <age> · !429)` badge and the next
  poll is the retry.

Lower-priority mode is deliberately not part of this store. Its offer,
active state, and separate allowance are session/request facts that are not
present in `/api/oauth/usage`. A consumer may distinguish a capped 5h window
from remaining `seven_day` headroom, but must not turn that relation into a
claim that `/low-priority` is available.

## Fetch discipline (rate-limit hygiene)

Defaults chosen so a fleet of watchers stays invisible to the API:

- usage poll: 900 s default, minimum 60 s enforced, ±10% jitter per
  cycle (fleet watchers must not synchronize).
- A 5h or model-scoped cap never suppresses polling: lower-priority service
  or another model can still move the account.
- An aggregate 7d cap with no paid path is terminal until its own reset.
  The in-memory snapshot yields to `r` and to any newer `usage.cache`
  published by statusline, so the optimization never hides fresh evidence.
- any fetch failure (429, 5xx, transport): show the last cache badged
  stale, retry on the next tick. With nothing to show at all: doubling
  backoff from 60 s, stretched to `Retry-After` when sent, never past
  the poll interval — the same posture as the CLI, which retries only a
  401 and otherwise just reports the failure.
- profile: 24 h shared TTL; only a profile that never landed is retried
  (every 10 min). Tier comes from the credentials file, not the profile.
- prepaid credits: 1 h TTL, skipped when credits were never enabled,
  same backoff file discipline as statusline (`prepaid_credits.err`
  with retry-until epoch).

## Forecast inputs

The weekday model consumes only `type:"usage"` records, partitioned by
`user.uuid` and EWMA-weighted with a 14-day half-life (plan changes
rescale percentages; old scales must fade). Below 14 days of history
the forecast is silent — a model with no data is decoration, and 14 is
also statusline's floor, so the two surfaces agree about whether a
forecast exists at all.

Daily burn is **the rise of a monotone envelope**, never the sum of raw
positive deltas. Utilization inside a window only climbs, so a sample
below the running max is one of two things and they need opposite
answers:

| | what it is | what to do |
|---|---|---|
| stale | an idle session reporting the numbers it last saw | hold the envelope |
| reset | the counter really went back to zero | re-baseline, credit nothing |

`resets_at` cannot tell them apart on its own — an observed 7d reset
(100 -> 0) left it untouched — so the window key is a ONE-WAY hint: a
newer key is certainly a new window, an unchanged one proves nothing.
A stale window's samples are dropped; everything else falls to a
two-signal test, sustained (>= 2 samples below) AND deep (>= 15 points).
The failure mode is a bounded under-count, which costs a missed warning;
the over-count cost a false alarm on every frame.

This is not a refinement. Summing raw deltas credits every stale dip and
then credits the re-climb, counting the same burn twice — measured, it
read 146 points of burn against a real 50-point week, and put 149%/day
into a Thursday. The projection built on that said `+133% rest of week`
on a week with 56% of the pool left.

## The projection

One walk, read twice. `project_week` steps from now to the reset (or to
the access boundary, whichever binds) a day at a time against the
weekday profile, blending the last 24h over the first day so a hot
streak escalates before the weekday average catches up. It returns the
landing **capped at 100** and the moment the pool dries, if it does.

The cap is the point. Utilization cannot exceed the pool: a projection
of 177% is not a landing, it is a wall plus burn that never happens.
Above 100 the fact is the DATE, and that is what the block prints — a
`!` wall with the time and the gap before reset — while the budget line
lands on exactly 100. Two readings of one model, never two models.

It stays silent rather than guess: below the history floor, on a window
younger than 24 h (the profile describes the windows *before* this one),
on nothing spent yet, and on any profile claiming a weekday averages more
than the whole pool per day — no real one can, so that input came from a
broken accountant.

## The budget line

Three clauses, and the grammar keeps them apart because two of them are
different kinds of statement:

```
budget: ~9 windows left · 6.2%/window stays even · lands ~91% on your pattern
         runway              RATION                  PREDICTION
```

`N%/window stays even` is what to spend — that rate lands the pool
exactly on 100. `lands ~N%` is where your own behaviour takes you, tagged
`on your pattern` for the learned walk and `at this pace` for the linear
fallback, so the reader always knows which model spoke. The runway counts
windows AHEAD of the one you are in: the current window is where you are,
not what you have left, and it is already drawn as `▮`.

---

# DESIGN — ccpace's language

The opt-in calendar adds a week grid and hourly inspection to this compact
grammar. Its interaction and evidence rules are in [Usage calendar](docs/calendar-tui.md).
Spectrum uses mint usage, cyan forecasts, and rose model identity; Quiet
and Paper keep the same meanings. Warm colors remain pressure signals.

Same family as [claude-code-statusline/DESIGN.md](https://github.com/thevibeworks/claude-code-statusline/blob/main/DESIGN.md):
same glyphs, same math, same log. ccpace is the full-screen view of what
the statusline shows in one line.

## The block

```
── [20x] feast · period ends ~Sep 10 ─────────────────────────────
5h     13% █▒▒▒▒▒▒▒░░  4h 11m   @04:00         +3%  0.6x
7d     42% ████▒▒▒▒░░  10h 29m  @Wed 19 09:00       0.7x
fable  70% ███████▒▒░  10h 29m  @Wed 19 09:00       0.7x
           ▅▂▃ ▄▅▁▄▅ ▄▄▂▃▂ ▅▁▃▂▂ ▁▁▁▄▅ ▁▁▁▅ ▆▆▁▃▮▯▯
           budget: ~2 windows left · 29%/window stays even · lands ~58% on your pattern
```

Three bands, one question each: **rows** how much of this window is
left; **ledger** where the week went; **advice** what to do about it.
Every account is one block; the rule is the splitter and carries
identity (tier, alias, provenance).

## Rows

One grammar per row: `name  pct  bar  remaining  @reset  Δ  pace`.
The bar merges usage with elapsed time — `█` both passed, `▓` usage ahead
(hot), `▒` time ahead (headroom), `░` untouched. Pace = used ÷ elapsed
(`>1x` = capping early). Reset is wall clock (`@04:00`, `@Wed 19 09:00`),
never a countdown that rots.

## Ledger

```
▂▃▄▅▆▇█    burned; height = 7d points that 5h slot cost (▂ ≤2 … █ >20)
▁          baseline: ran, negligible — the shortest bar of the same block,
           so the zero line and the bars share one font and one width
░          unknown — no sample; never drawn as idle
▮          now
▯          ahead — the hollow of ▮
▯ dim      ahead, but you sleep through most of it
┤          access ends here (trial / period end)
```

34 cells, oldest left, a gap at each local midnight *in history only*.
Tint = the 7d row's color; nothing else in the row is colored.

A GRID anchored to the period start, not a row of your real 5h windows —
those follow the 5h reset, in phase only by coincidence, and 34 cells span
170h against a 168h period. That grid is what makes a day's windows land
under one day. Read the row for shape; the budget line owns the count,
which comes from clocks. They sit within one cell of each other — and so
do the dim cells and `~N awake`, for exactly the same reason.

The night is the row's one refinement carried by tint alone, and the one
place that is honest: a dim ▯ is still a window ahead, so the tint says
only how likely that capacity is to be reachable and a reader who cannot
see it loses nothing they could have acted on. It makes the future a SHAPE
instead of a count. A slot is a night when under half of it (9000 s) falls
in hours you are awake for — the slot's whole wall span, not the hour it
opens in, because 20:00–01:00 and 05:00–10:00 straddle the same night and
only one of them is a window you can spend. Gated on the same evidence the
walk needs; unlearned, the row is byte-for-byte what it was. ▮ and the
record are untouched — and no cell ahead is ever a verdict. The ledger
used to overwrite dry-projected cells as red `×`; drawn out, a run of
them read as *deleted windows* (measured live, 2026-09-01, on
statusline's unfolded row — its v0.39.0 and this release retire the cell
together). The wall's owner is the `7d dry ~...` advice row directly
under this ledger, which states the exact time with its own gates. A
guess may not delete a window.

## Advice

```
 !  7d dry ~Wed 14:20, 19h before reset; then hard stop until reset
    fable: ~15% of its 37% left reachable at this mix · heavier fable extracts more
    budget: ~9 windows left · ~6 awake · 9.3%/window stays even · lands ~91% on your pattern
             runway            REST       RATION                   PREDICTION
```

`!` is a wall: a date, and the gap before the reset. The budget line is
the week in one breath, and its two futures are different kinds of
statement — the RATION is what to spend (that rate lands exactly on 100),
the PREDICTION is where your own behaviour takes you. Tagged `on your
pattern` when the learned weekday walk spoke, `at this pace` when it was
linear, so the reader always knows which model answered.

`~6 awake` is the runway with the nights taken out. Claude Code can work
around the clock; you cannot, and a ration that divides the pool across
windows you sleep through asks you to hit a number lower than the one you
can actually spend. The clause appears only when the hour shape is learned
and the two counts differ, and it names the RATION's denominator by sitting
beside it — nine windows, six of them yours, 9.3% each. Nothing awake ahead
is not a rate: the line states the count and stops.

The scoped row is the other pool, and it is an opportunity rather than a
wall. The 7d cap ends the week for every model, so a model-scoped cap
draining slower than the account's simply never empties. Both counters
start at the same reset, which makes their ratio this week's MIX RATE with
no history behind it: at 81/63 the week bought 0.78 fable points per 7d
point, so the 19 points left on the account reach 15 of fable's 37. Stated
as the reachable half, never the wasted one — 22 expiring points is not
something anyone can act on, and running fable heavier is. It sits above
the budget because it qualifies the same headroom the budget then rations,
and it appears only when the reading is honest: one wall (both resets
within two minutes), the account deep enough for the question to exist,
the model actually touched this week, neither pool capped, and at least ten
points on the table.

One model per block. A landing is capped at 100 — above that the fact is
the DATE, and the `!` row is where it goes. Two lines describing one week
with two numbers is not more information; it is an argument the reader has
to settle.

A 5h cap is not a weekly cap. Its quota row says `cap`, never the occasional
endpoint value `101%`; the reset is the useful fact once the counter binds.
The wall row keeps the weekly pool beside it: `5h capped · 47% of 7d left · back
@Tue 2 04:00`. Claude Code may offer `/low-priority` there, but that gated
offer and its allowance are absent from `/api/oauth/usage`; this surface
states the two counters it can prove and does not impersonate session state.

## Notifications

The watch surface and its side channels share one vocabulary. Every event
payload names `window`, `utilization`, `reset_at`, and the display-safe
`reset_time`; producers do not invent aliases that formatters have to guess.
The envelope adds a stable, readable `id` (`full:work:5h:<reset>`), so a
custom notifier can dedupe or trace one condition across process restarts.
Threshold and delta IDs add the utilization that caused the event; two real
climbs inside one window remain two events.

## Provenance on the rule

`(cached)` the aggregate weekly pool is spent and no paid path exists,
so this payload cannot move before its 7d reset · `(stale 12m · !429)`
last fetch failed, numbers this old · `(idle 12m)` Claude Code did nothing
since, so nothing was asked. A manual refresh or a newer shared cache always
breaks `(cached)`. Never a blank block: the last cache beats an empty frame.

## Requests

Ask only when the answer can have changed.

- Pool first: a `usage.cache` younger than 60 s — anyone's — is the fetch.
- Idle gate (watch): fetched before, no reset passed, no Claude Code
  activity since (`history.jsonl`, statusline session state — across every
  container sharing `~/.claude`) → no request; `r` overrides.
- Reset boundaries wake the loop; the poll interval (15 min ± 10%) is the
  ceiling, not the metronome.
- A 5h or model-scoped cap never freezes the account: lower-priority service
  or another model can keep moving the weekly counters.
- Only 7d at cap with no paid path is terminal until reset. Even then, `r`
  and a newer cache from statusline take precedence over the optimization.
- Failure: last cache + badge, retry next tick; never a lockout, never
  `usage.err` (that file is the statusline's).
- Profile 24 h (tier comes from the credentials file); prepaid 1 h and
  only when credits were ever enabled.

## Words

Lowercase, terse: `lands`, `stays even`, `windows left`, `awake`.
Numbers first. One line per thought; `·` between clauses.

---

# Contributing

For a bug, include the ccpace version, operating system, terminal size,
command, expected result, and actual result. Use `--calendar --demo` when
it reproduces the issue. For a feature, describe the usage decision it
would help someone make.

Do not attach credentials, raw usage stores, account identities, or session
logs. Reduce data problems to synthetic observations before sharing them.

```sh
make check
make demo
make build
```

Tests must isolate the shared usage store with `CCPACE_DATA_DIR`. Changes to
forecasts or quota interpretation need a regression test showing the
failure. Calendar changes should be checked at 80x24 and a wide terminal,
with light and dark palettes. Regenerate screenshots only from demo data.

The compact collector is `ccpace.py`. Calendar evidence and warning state
live in `ccpace_calendar.py`; Textual rendering lives in `ccpace_tui.py`.
Keep shared observations and forecast contracts compatible with
claude-code-statusline. Do not infer account identity from directory names.

---

# Changelog

## v0.9.0 - Usage calendar (2026-09-08)

Your 5h allowance can run out while much of the weekly pool goes unused.
`ccpace --calendar` puts both conditions on one screen, with a browsable
week, hourly usage patterns, quota-period history, and alerts.

- Spectrum, Quiet, and Paper palettes. Change the theme from the picker,
  with `Ctrl+t`, or through `--theme` / `CCPACE_THEME`.
- Account-wide and model-scoped limits stay separate. Forecasts use the
  shared history model and stop at the current quota or access boundary.
- Missing observations stay blank; recorded zero remains `0.0`. Long
  observation gaps are not assigned to individual hours. Select an interval
  for its evidence, or press Enter for hourly detail.
- Persistent warning transitions, acknowledgement, and JSON notifier hooks.
  Forecast warnings require two distinct observations. Stale data and
  escalation into a cap cannot announce recovery. No execution control.
- `ccpace --calendar --demo` runs synthetic scenarios without credentials,
  provider requests, usage writes, or notifications.

The calendar uses Textual and requires the package or a full checkout.
The compact `ccpace` and `--watch` views remain available. Supported on
macOS and Linux with Python 3.11+. Weekly underuse thresholds are
experimental; forecasts are estimates, not promised capacity.

```sh
uvx --from ccpace==0.9.0 ccpace --calendar --demo
```

## v0.8.0 — name the wall (2026-09-02)

Claude Code can now offer `/low-priority` at a spent 5h session window, but
the gated offer, active mode, and separate allowance do not exist in
`/api/oauth/usage`. ccpace now says what its source can prove:
`5h capped · 47% of 7d left · back @Tue 2 04:00`. It distinguishes the
shorter wall from the weekly pool without claiming a session-only escape is
available for an account it cannot see into.

Notifications now carry the same facts. Their producers and formatter had
drifted onto different key names, causing real threshold and delta messages
to say `0%`; the payload now has canonical `window`, `utilization`,
`reset_at`, and `reset_time` fields while retaining the old aliases for
custom hooks. Custom-notifier envelopes also gain a stable event `id`, such
as `full:work:5h:<reset>`, for dedupe and tracing across restarts.

The old watch cache used the maximum of every counter as an account-level
stop: 5h at 100, or one scoped model at 100, froze the whole account until a
reset. That is false once lower-priority service can bypass 5h, and it was
already false for another model. Worse, the early return ignored a newer
shared cache from statusline and even `r`. The cache is now reserved for
one genuinely terminal state — aggregate 7d spent with no paid path — and
both newer shared evidence and manual refresh evict it.

85 tests.

## v0.7.0 — a guess may not delete a window (2026-09-01)

**The `×` cell is retired.** The ledger used to overwrite dry-projected
ahead-cells as red `×`, and drawn out in a run they read as *deleted
windows* — measured live the day statusline's unfold exposed the same
run on its row (`▮▯▯▯×××`: six windows to the reset, counted as three
by the person the row exists for). A future cell is a slot, never a
verdict. The wall already has an owner with better gates and an exact
time: the `7d dry ~...` advice row directly under this ledger. Every
cell ahead now draws hollow — dim where your learned hours say you
sleep — to the grid's edge.

Ships with statusline v0.39.0 ("a guess may not delete a window"),
which retires its future `×` cells the same way and keeps the mark only
in its folded token, where no per-cell shape exists to say it. No
`forecast.cache` change of any kind.

## v0.6.0 — the night on the ledger (2026-09-01)

v0.4.0 taught the forecast that you sleep. The ledger still did not know:

```
▂▃▅▁▂▄█▃▁▁▂▅▄▃▁▂▃▅▄▂▁▃▄▅▃▂▮▯▯▯▯▯▯▯
```

Nine hollow cells, all drawn the same, and three of them are the middle of
two nights. The row said "nine slots ahead" while the budget line beside it
said `~6 awake` — one surface counting clock, the other already counting
yours.

### The future is a shape, not a count

An ahead-cell whose 5h slot has under `REST_SLOT_AWAKE_MIN_SECS` (9000 —
half a window) of waking seconds now draws DIM. Same ▯: the glyph is the
fact, the tint is the refinement, and a reader who cannot see the tint
loses nothing they could have acted on — a dim ▯ is still a window ahead,
it is just one the capacity is unlikely to reach. Waking hours are the
v0.4.0 arithmetic exactly (`mult >= REST_MULT_MAX` over the slot's real
wall span), so nothing here is a second opinion about your day.

The wall span is the whole rule. 20:00–01:00 and 05:00–10:00 straddle the
same night's two edges and the hour a slot OPENS in gets both of them
wrong: the first is four waking hours and a window you can spend, the
second is two and a night. On the 5h grid every slot straddles something.

Gated on the evidence the walk already needs — a valid `hour_profile` and
`FORECAST_MIN_DAYS` of history. Unlearned, the row is byte-for-byte the
row it was in v0.5.0, tints and all, and the tests hold it there against
every way of not knowing: no field, a truncated one, a nonsense one, a
real one with a fortnight of history missing behind it.

`×` beats rest — a slot the pool will not cover is unreachable for a
stronger reason than sleep, and drawing it as a night would hide that. ▮
and every cell of the record are untouched.

### Same rule on both surfaces

`REST_SLOT_AWAKE_MIN_SECS` is a shared READING RULE, not a cache field:
statusline v0.38.0 dims the same slots on its own 7d strip (and each rest
hour on the 5h one) off the same `forecast.cache`. `schema` stays 2; no
field moved. Documented in `docs/statusline-interop.md`. The dim cells and
the budget's `~N awake` may differ by one — the strip is a grid on the
period start, the count comes from real clocks — the same tolerance the
window count itself already carries.

Internally the two now share one `awake_seconds()`; the budget's count and
the ledger's nights were never allowed to be two implementations.

## v0.5.0 — two pools, one wall (2026-09-01)

An account at 81% of its week with a model-scoped pool at 63% has two
counters heading for the same wall at different speeds, and nothing on the
block said which cap binds or what it costs:

```
7d     81% ████████▓░  2d 3h   @Wed 19 09:00       1.2x
fable  63% ██████▒▒▒░  2d 3h   @Wed 19 09:00       0.9x
```

The 7d cap ends the week for every model, so those 37 fable points are not
37 points of headroom — at this week's mix, 22 of them expire untouched.

### The ratio is the estimator

Both counters start at the same reset instant, which makes the live ratio
between them this week's MIX RATE — scoped points per 7d point — with no
history behind it at all:

```
mix       = scope / seven
reachable = round((100 - seven) * scope / seven)
strand    = round(100 * (seven - scope) / seven)   == (100 - scope) - reachable
```

81/63 gives mix 0.78, reachable 15, strand 22. Mining the corpus for the
same week's dF/dS put it at 0.77, so this is not an approximation of the
measurement, it is the measurement — available in the payload already on
screen. No new `forecast.cache` field, no schema question, nothing to
learn and nothing to wait two weeks for.

What is NOT published: the pure-scope coupling, what a scoped point costs
the account when only that model runs. n=22, and the band is wide enough
to be fluent and wrong.

### One row, above the budget

```
fable: ~15% of its 37% left reachable at this mix · heavier fable extracts more
budget: ~9 windows left · ~6 awake · 9.3%/window stays even · lands ~91% on your pattern
```

An info row immediately before the budget line, because it qualifies the
very headroom the budget then rations. It states the reachable half rather
than the strand: 22 wasted points is not something a reader can act on,
and running that model heavier is. Where the payload names no running
model the deepest scoped pool answers, unless one declares itself active —
depth is a guess at which pool the reader cares about, `is_active` is the
account saying it outright.

Gated so the ratio stays honest, and statusline v0.37.0 gates its own
notice on the same five: one wall (both resets within 120 s — Anthropic
could split them someday), `SCOPE_MIX_MIN_7D = 60` (which cap binds is a
question only near the end), `SCOPE_MIX_MIN_SCOPE = 5` (an untouched model
is the underuse question, not a mix), neither pool capped (that is its own
notice), `SCOPE_STRAND_MIN_PCT = 10` (under that it is rounding wearing
advice), and the existing young-week guard. The constants are shared
READING RULES, documented in `docs/statusline-interop.md`; statusline adds
two mutes ccpace has no mechanism for and did not invent.

## v0.4.0 — the hours you keep (2026-09-01)

Claude Code can work 24/7. You cannot, and the forecast did not know the
difference: it learned a WEEKDAY profile and then burned it flat through
the night. So a week that really ran out on Thursday morning printed

```
7d dry ~Thu 03:00, 30h before reset; then hard stop until reset
budget: ~9 windows left · 6.2%/window stays even
```

A wall placed mid-sleep is a false alarm at 11pm and a missed warning at
09:00, and a ration divided across windows you sleep through asks you to
hit a number lower than the one you can actually spend. Both are the same
missing fact. The corpus already held it: burn credited by the envelope
pass carries a timestamp, and hours that never burn across weeks are the
hours you rest.

### `hour_profile`: the shape of your day, in the shared cache

24 multipliers by local hour, mean 1.0, so the rate at hour h is
`weekday_rate * mult[h]` and a whole day still burns its weekday total —
only the shape inside the day changes. Built on the same pass and the same
constants as the weekdays: each envelope delta is credited to its local
`(day, hour)`, today is excluded (partial, never a training day), the rest
are EWMA-weighted at the 14-day half-life, and each hour's share of the
week becomes its multiplier.

Floored at 0.1 and renormalized to a mean of exactly 1, in that order, at
BUILD time. The floor is the hedge for the occasional overnight autonomous
run — a rest hour projects a tenth of a uniform hour, never zero — and the
order matters, since flooring after the normalization would publish a
shape whose mean is no longer 1. Build-time rounding matters because the
cache is SHARED: statusline computes the identical field off the same log,
and two writers rounding their own way is two answers to one week.
`schema` stays 2 — the model of the existing fields did not change, and a
reader that has never heard of the field keeps working. Contract in
`docs/statusline-interop.md` and `docs/data.md`.

Read defensively and never fatally: all 24 keys, every value numeric in
[0, 24], mean in [0.9, 1.1], or the walk takes flat and carries on. A bad
hour shape decides only whether the forecast knows when you sleep; the
weekday guards still decide whether it speaks at all.

### The walk steps by the hour

`project_week` now walks local hour boundaries instead of local days — at
most 169 segments for a week — and multiplies each segment's weekday rate
by that hour's shape. With no learned shape every multiplier is 1 and the
numbers are the day walk's to thirteen decimal places, which the suite
asserts. The dry warnings needed no copy change: the shaped walk moves the
dry TIME out of the night by itself, and that is the early-warning fix.

Two behaviours moved, both deliberately:

- The 24h blend (`max(weekday, recent_24h)` over the first day) is tested
  at the start of each segment, and a segment used to be a calendar day —
  so a blend that began 15h out ran to 39h. It now ends at 24h.
- A spring-forward day is 23 hours long and now burns 23 hours of quota.
  The day walk sized its segments by subtracting two datetimes that shared
  one tzinfo, which Python does on the WALL clock, so the skipped hour was
  credited anyway — twice a year, in every zone that moves. Segments are
  measured in absolute seconds off the local clock's own minute.

### `~6 awake`: the ration you can actually spend

```
budget: ~9 windows left · ~6 awake · 9.3%/window stays even · lands ~52% on your pattern
```

An hour whose multiplier is under `REST_MULT_MAX` (0.25, a shared
constant) is rest. Count the waking seconds between the end of the window
you are in and the end of the week, ceil them into 5h windows the same way
`windows_ahead` ceils — a partial window is still spendable — and clamp to
the window count itself. The clause appears only when the shape is learned
on at least two weeks of history and the two counts differ, and it names
the ration's denominator by sitting beside it. Nothing awake ahead is not
a rate: the line states the count and stops rather than divide by zero or
quote a number nobody can spend. When paid access ends before the reset,
the awake count is measured over the truncated span too — one horizon per
block, as the runway, the ledger's `┤` and the landing already were.

`windows_ahead` itself is untouched. The countdown invariant stays; the
budget line owns the refinement.

## v0.3.1 (2026-08-27)

Two readers, one rule. `load_account_history` partitioned by uuid and
dropped rows that carried none; `weekday_burn_forecast` let those same
rows through, on the theory that alias-scoped directories are
single-account. They are not — one real store held twelve uuids — and
two filters that disagree are a leak waiting for the first caller that
skips the loader. The forecast now applies the loader's rule and nothing
else.

The drop is counted. A row without a uuid is refused, never guessed
(thirteen of ninety-three in one store carry an email that would
identify them, which is exactly the temptation to resist on a log that
has already interleaved accounts), but a reader that discards
identifiable observations silently will discard a larger number just as
quietly. `load_account_corpus` returns the samples and a `Corpus`: files
read, rows kept, rows dropped for no uuid, rows of other accounts, and
the oldest kept timestamp.

That corpus is stamped into `forecast.cache`. `schema` versions the
MODEL and cannot say which samples it ran over: statusline reads one
directory, ccpace reads every store under the root, both count burn the
same way, both pass the gate — and the same account reads
`days_history: 28` or `301` depending on which binary rendered last.
`corpus: {uuid, files, samples, dropped_no_uuid, oldest}` makes that
visible in one `jq`. Informative, not a gate; `docs/data.md` has the
contract.

## v0.3.0 (2026-08-24)

The forecast was wrong, and it was wrong in the way that is hardest to
notice: it produced a fluent sentence. On a week with 56% of the pool
left, this tool printed

```
budget: ~10 windows left · 5.6%/window stays even · heading ~62% at reset Wed 26 09:00
forecast: +133% rest of week on your pattern · lands ~177% (251d history)
```

Two lines, one week, three numbers that cannot all be true, and no way
for the reader to tell which — if any — was the forecast. This release is
that block reduced to one model and one sentence.

### Burn is the rise of an envelope, not the sum of the deltas

Utilization inside a window only climbs, so a sample below the running
max is one of two things: a stale session reporting the numbers it last
saw, or a real reset. They need opposite answers — hold, or re-baseline —
and summing raw positive deltas gives neither. It credits the dip's
recovery as fresh burn, counting the same points twice.

The measured cost of that: 146 points of "burn" against a real 50-point
week, and 149%/day in a Thursday. It is the arithmetic behind `+133%`.

A stale window is now dropped on its key (a NEWER 7d `resets_at` is
certainly a new window; an unchanged one proves nothing, since an observed
100 -> 0 reset left it untouched), and everything else falls to a
two-signal test: sustained (>= 2 samples below) AND deep (>= 15 points).
Both cheap, both independent. The failure mode is a bounded under-count,
which costs a missed warning where the over-count cost a false alarm on
every frame. The first sample of a series is a baseline, not burn: seeing
an account already at 40 is not watching it climb there.

### A landing above 100 is not a landing

`lands ~177%` describes nothing. The pool is 100; a projection past it is
a wall plus burn that never happens. The walk now caps the landing at 100
and returns the moment the pool dries, which is the fact worth having —
so the block says `7d dry ~Wed 14:20, 19h before reset; then hard stop`
and the budget line lands on exactly 100. Two readings of one walk.

The walk also stays silent where it has no standing: below two weeks of
history (statusline's floor too, so the surfaces agree about whether a
forecast exists), on a 7d window younger than a day (the profile
describes the windows *before* this one, and the 24h blend describes a day
on the far side of the reset), and on any profile claiming a weekday
averages more than the whole pool per day — no real one can, so that
input came from a broken accountant. It gained the recent-24h blend over
the first day, so a hot streak escalates before the weekday average
catches up.

### One budget line, and every number named

```
budget: ~9 windows left · 6.2%/window stays even · lands ~91% on your pattern
```

`N%/window stays even` is a RATION — spend that per window and the pool
lands exactly on 100. `lands ~N%` is a PREDICTION — spend like you have
been and you end up here. They are different kinds of statement and the
old line ran them together under one word, "heading", which is neither: a
direction is not a destination. The landing now says which model produced
it, `on your pattern` for the learned walk and `at this pace` for the
linear fallback, and linear inherits the same 100 ceiling so the fallback
cannot reintroduce what the walk just lost. The separate `forecast:` line
is gone; there was never a second week to describe.

`~N windows left` no longer counts the window you are standing in. That
one is where you are, not what you have left — the ledger already draws
it as `▮` and the 5h row already prices it, so counting it again made
`▮ + 9` read as ten, and made this tool disagree with statusline about
the same week. Same definition as `windows_ahead` there:
`(7d left - 5h left)`, rounded up. Both clocks tick down together, so the
count holds still inside a window and steps down by exactly one at each
rollover — a countdown rather than a reading that drifts.

### The shared cache has a contract now

`forecast.cache` lives in statusline's store and statusline computes a
superset of it off the same log: the cross-window exchange rate, the
per-model weekly profile, the dollars a quota point costs. ccpace rebuilt
the five fields it knew and wrote them flat over the file, dropping all
three every time it ran — after which statusline's report said "still
learning" about its own price and exchange rate until the next hourly
scan. It also published its pre-envelope profile there, which
statusline's corrupt-profile guard then had to refuse, dropping that
surface back to linear pace as well.

So: the cache carries `schema`, the version of the MODEL rather than of
the file. Writes MERGE and never truncate keys we do not compute. Reads
treat freshness as necessary and not sufficient — an unversioned or
foreign cache is stale however recently it was written — and a fresh
cache of our own schema is read rather than recomputed, because it holds
the same numbers and cost someone else the scan. Written down in
`docs/data.md` and in claude-code-statusline `docs/api/state-dir.md`.

A profile whose only credited day was today is no longer published at
all. It came out as seven `-1`s with `days_history: 0`, harmless to read
and actively harmful to publish: stamped with a current timestamp into a
shared cache, it silenced the walk on every surface that read it until
the hour turned.

### The ledger is one typeface

The strip's baseline was `ˍ` (U+02CD MODIFIER LETTER LOW MACRON) while
every bar above it came from Block Elements. Terminals resolve those
through different faces, so the zero line sat at a different height and
advance width than the bars beside it and the seam showed on every row
that held both. It is `▁` now, the shortest bar of the same run. Burn
starts one rung up, at `▂`; `▅` and above keep their thresholds, so a
fully burned window reads the height it always did.

The ledger's docstring stops claiming its cells are countable as windows
left. They are a grid anchored to the period start — which is what makes
the history readable — while your 5h windows are anchored to the 5h
reset, in phase only by coincidence, and 34 cells span 170 h against a
168 h period besides. Measured across a full week of positions the two
agree about three times in four and are never more than one cell apart.
The sentence owns the number; the row carries the shape.

### Tests

`t/`, run with `make check`. There were none, which is the whole story of
this release: the model was invisible and only its sentences were on
screen, and the sentences were plausible. 38 cases now pin the burn
accounting, the walk's silences and ceilings, the cache contract in both
directions, and the wording of every clause in the budget line.

## v0.2.0 (2026-08-18)

One release for what was found by watching a week of real use against
the CLI's own `/api/oauth/usage` traffic (cctrace, claude-cli 2.1.234).
Nothing here changes what is fetched — the CLI's request is still the
model — only when, from where, and what happens when it fails.

### A failed fetch is a badge, not a lockout

A `429 Retry-After: 3600` used to be taken at face value: the poller
retried inside the hour and, in an intermediate build, wrote an hour-long
cooldown into statusline's `usage.err`, freezing every statusline render
on the machine along with itself. That is not how the CLI treats this
endpoint (it fetches when it needs to and shows an error if that fails),
and a 15-minute poller has no lockout to gain from it. Now: a failure
keeps the last `usage.cache` on screen with `(stale 12m · !429)` on the
account rule, and the next poll is the retry — the footer already counts
it down. With nothing cached at all the frame says
`rate limited (429) (retry in 5m)`, doubling from 60 s and never past
the poll interval, whatever `Retry-After` asked. ccpace never writes
`usage.err`; it never blocks on it either.

### The account is the identity, not the directory

- The bare `.credentials.json` follows statusline's own rule for who it
  is: `STATUSLINE_ACCOUNT`, else deva's `DEVA_AUTH_TAG`
  (`auth-file-<stem>` -> `<stem>`), else the pre-0.18 `DEVA_AUTH_DETAILS`
  stem, else the untagged store root. Same directory as the statusline in
  the same session, so one fetch serves both — inside a deva container as
  well as on a host. It is displayed by that tag, or `default`.
- History (the 5h-window ledger and the forecast) is read from every
  store under the root and partitioned by account uuid, as docs/data.md
  always required of readers. Where a sample landed depends on who
  fetched it (untagged statusline -> root, tagged container ->
  `accounts/<tag>/`, older ccpace -> `accounts/<alias>/`); the ledger
  went blank whenever ccpace looked in the wrong one. Parsed stores are
  cached per (mtime, size) so watch frames do not re-read 20 MB.
- `.credentials.work.json` (the README's `.credentials*.json` glob) used
  to alias to `""` — the default account — and share its `usage.cache`,
  so two accounts showed each other's numbers. Both spellings now yield
  `work`; only `.credentials.json` is the default.

### Watch asks only when the answer can have changed

A fixed-interval poll of an idle account is the request that only ever
finds what it already knows. Watch now skips the request when it has
fetched before, no window reset has passed since, and Claude Code has
done nothing since that fetch — read from `~/.claude/history.jsonl` (a
prompt went out) and the statusline's per-session render state (a
frame was drawn, in any container sharing this `~/.claude`). The block
keeps the last numbers with `(idle 12m)` on its rule; `r` asks anyway.
Unknown activity (no such files) never blocks a fetch.

### The ledger reads as days, and never loses its first window

A thin gap now sits at each local midnight, in history only, in the window ledger
(`▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ …▮ ▯▯`), so days read as clusters — and a day that
held five windows shows it — without a ruler; the same rhythm as
claude-code-statusline's new week row, which draws this ledger from the
same log. And the period start is now snapped to the same 5-min grid the
window keys use: the API jitters `resets_at` by sub-seconds
(15:59:59.76, 16:00:00.47), and a raw `reset - 7d` could land a hair past
slot 0's true start and floor the first window of the week to slot -1 —
silently lost. Row needs 78 columns now (was 72).

### Also

- `--bark` reads the bark CLI's own env: bare `--bark` pushes to
  `BARK_KEY` on `BARK_SERVER` (default api.day.app), and `BARK_GROUP` /
  `BARK_ICON` ride along; `--bark URL` and `CCPACE_BARK` still win.
  (Before: `--bark` without a URL was a usage error, and the env sat
  unread.)
- A notification can never take the watch loop down: `has_command`
  exec'd the shell builtin `command -v` and raised `FileNotFoundError`
  on Linux the first time an event fired without `notify-send`; it uses
  `shutil.which` now, and every channel is fenced — a failing messenger
  is one warning line, not a traceback.
- The ledger's slots ahead are `▯` (the hollow of `▮`: an empty slot),
  not `▫`; same glyph in claude-code-statusline's week row.

### Tier from the credentials file, not the profile

Claude Code persists `subscriptionType` / `rateLimitTier` next to the
token. That is now the tier source: overlaid on the profile every frame,
synthesized into a minimal profile when none has landed, so a 5x -> 20x
upgrade shows on the next frame with no request. The profile is fetched
for the org uuid and subscription dates, 24 h TTL; only a *missing*
profile is retried (every 10 min).

### Watch mode refreshes at the reset boundary, not late

The non-full sleep was always the full poll interval (~15 min), so when
a 5h/7d window rolled over the reset countdown ticked past zero and the
account rendered stale — 100% for a window that had already reset —
until the next interval poll. Watch now caps the sleep at the next
upcoming reset (`+RESET_POLL_GRACE`). `get_earliest_reset` also
considers the model-scoped weekly limits (`limits[].scope`), not just
5h/7d, so the poll targets the window that actually binds.

### A model-scoped 100% counts as full

The watch loop measured fullness with `include_model_specific=False`, so
an account blocked on the fable/opus **weekly** limit (5h and 7d-all
still low) was treated as not-full: never cached, never reset-watched,
polled blindly every interval. It now counts model-scoped limits.

### Fewer requests

- Prepaid credits: 1 h TTL (a balance only moves on a purchase; spend is
  already in the usage payload's `extra_usage`), skipped outright when
  the payload says `credits_ever_enabled: false`.
- User-Agent pinned to `claude-cli/2.1.234 (external, cli)`; the
  prepaid-credits fetch uses the traced `teleport-org` header set
  (`anthropic-version` + `anthropic-client-platform` +
  `x-organization-uuid`, no `anthropic-beta`).
- `-v` no longer prints the httpcore transport trace; one `httpx` line
  per request. `-vv` keeps everything.

## v0.1.1 (2026-08-06)

### Shared fetch pool with claude-code-statusline

usage.cache and profile.cache are now the pool, not private caches:
ccpace reads a usage.cache younger than 60 s instead of fetching (and
does not re-log the sample — one observation, one record), and
publishes its own fetches back (atomic tmp+rename) so statusline's
next render skips its fetch too. profile.cache shared the same way
(raw profile, mtime TTL, 24 h). Two tools, one API load.

### Forecast honors the access boundary

The weekday projection now stops at the subscription period end when
that lands before the 7d reset — the ledger's `┤`, the budget count,
and the forecast line all describe the same span ("+10% by period
end" instead of a "rest of week" number you cannot spend).

## v0.1.0 (2026-08-06)

First release. Extracted from thevibeworks' internal claudex lab
(claude.py's monitor half, wire-verified against claude-cli 2.1.220);
the chat-client half stays private by design.

### Monitor

- Multi-account fleet view from `~/.claude/.credentials*.json`, sorted
  by tier; per-account splitter-rule headers.
- Dual bars (usage vs window-elapsed), 5h/7d/per-model rows, extra
  usage spend, prepaid credit balance (nonzero only).
- Window ledger: the 7d period as 34 countable 5h cells, burn heights
  from the sample store, `▮` now / `▯` ahead / `×` uncovered / `┤`
  access end. Unknown (`░`) and idle (`·`) stay distinct glyphs.
- Advisor: pace warnings with cap ETAs, windows-left budget, %/window
  to stay even. Budget truncates at the subscription period end
  (derived from the billing anniversary; `~` marks the assumption —
  no API endpoint states renewal or cancellation, re-verified against
  claude-cli 2.1.220's endpoint surface).
- Watch TUI: stable facts left, per-second tickers dimmed right; `r`
  refresh, `q` quit.

### Data (docs/data.md)

- Store v1: statusline-compatible typed JSONL records, epoch
  timestamps, raw API sections verbatim, `source` tag; 32 MiB rotation
  with `.1` backup and mkdir-lock, shared with claude-code-statusline.
- Account-scoped dirs keyed by credential alias; readers partition by
  `user.uuid`.
- Logging is on by default (`--no-log` to disable): history is what
  forecasts eat.

### Forecast

- Weekday burn signature from your own samples: positive 7d deltas per
  local day, EWMA-weighted (14-day half-life), projected over the rest
  of the window. Silent below 3 days of history.
- Same model and cache shape as statusline's forecast.cache: two
  surfaces, one set of numbers.

### Notifications

- Events: threshold, full, delta, pace, reset.
- Channels: system notify (macOS/Linux), ntfy (verified round-trip),
  bark (implemented, untested against a real device), custom script
  (JSON on stdin). Severity maps per channel.

### Rate-limit hygiene

- 15 min default poll, 60 s floor, ±10% jitter; exponential backoff
  (60 s → 15 min cap) when all accounts fail; accounts at 100% are not
  polled until reset; credits 5 min TTL with retry-after backoff;
  profile 24 h TTL.

### Plugin

- Installable as a Claude Code plugin: `bin/ccpace` on PATH, `/ccpace`
  skill, self-marketplace manifest.
