Plan-aware usage windows: a release-shipped plan catalog

Status:in-progress
Date:2026-11-01
Branch:jack_20261101_plan_window_catalog
Type:spec + plan

Goal

jacked assumes every account has a 5-hour window. On 2026-10-30 Anthropic renamed Max 5x to Max and Max 20x to Ultra, and removed the 5-hour limit for verified Ultra accounts (weekly limits unchanged, plus a separate weekly Fable limit). ChatGPT Pro also has no 5-hour limit in Codex. This change moves "which windows does this account type have, what is it called, and how long is each window" into a data file that ships with each jacked release (jacked/data/usage_plans.json), so the next provider change is a data edit, and makes every consumer read windows through it. It also fixes the missing week-elapsed marker on the Fable row, for the whole class of model-scoped bars.

Evidence

Spec: acceptance criteria and non-goals

User-facing goal

Every surface shows the windows an account really has, under the plan's current name, with a week-elapsed marker on every bar; jacked stops spending pings and quota on windows that cannot exist; a future provider change is a catalog edit in a release.

Acceptance criteria

  1. AC1 Catalog. jacked/data/usage_plans.json ships in the wheel, has version, verified_at, per-plan sources, window lengths, and plan entries for Claude (Ultra, Max, Team, Pro, Enterprise, Free, unknown-tier Max) and Codex (Pro, Plus). A missing or malformed file degrades to built-in defaults with a WARNING, never a crash. A newer version warns and reads best-effort.
  2. AC2 Resolution. Given a verified Ultra row (payload without a session limit), the resolver returns session state absent; an unverified Ultra (payload with a session limit) returns present + temporary; a Max or Team row returns present whether or not a window is open; Codex Pro returns absent; a Codex partial read returns unknown.
  3. AC3 Labels. Plan label precedence: provider plan_display_name, then catalog label, then the raw subscription type. Ultra rows read "Ultra" (hover "Formerly Max 20x"), Max 5x rows read "Max", Team reads "Team". No regex on the tier multiplier remains.
  4. AC4 API. GET /api/auth/accounts and every WebSocket account payload carry plan and windows; usage.five_hour is null (not 0) for an absent window; every per-model and binding entry carries window_seconds.
  5. AC5 Marker. Every usage bar that has a future reset shows the white elapsed marker, including the Fable binding row on the card, the per-model details list, the WebSocket live refresh, and the menu-bar panel. A source-scan test fails if any renderUsageBar call passes a literal null elapsed fraction.
  6. AC6 Display. An absent 5h window renders an empty track, "none", and "not on Ultra" (hover: the catalog note); the panel caption reads "not on Ultra"; the tray pill reads "-·33%"; jacked usage shows "none" in the 5h columns and the plan label.
  7. AC7 Keeper. The keeper never pings for a 5h window on an account whose session is absent (skip reason no_session_window); it still pings for a turned-over weekly window, and a 7d-only header reading is stored for such accounts; a ping_ok_no_window backoff escalates 600, 1200, 2400, 3600 s instead of resetting to 600 s.
  8. AC8 Crashes. "Refresh all usage" and OAuth add/re-auth both succeed for an account whose payload has five_hour: null.
  9. AC9 Decisions. Auto-swap's 5h headroom gate is explicit for absent windows; the 5h critical threshold comes from the catalog (Ultra 95, Max 90, Team 90, Pro/Free/Enterprise/unknown 80, unknown-tier Max 90); poll urgency for a no-session account follows its weekly and model-weekly windows; the burn-rate tracker updates when only 7d moves; the exhaustion banner's recovery time comes from each account's constrained windows and its text names no 5h window.
  10. AC10 Pacing + skill text. jacked usage --json rows add plan, plan_id, session_window, model_limits; the summary adds best_account_worst_window, best_account_worst_window_resets_at, plan_catalog; the pinned fields keep their meaning. chain-of-command rule 7 reads the plan-agnostic fields.
  11. AC11 Aging. A payload that contradicts the catalog (session on a never plan, silence on an always plan, an unlisted tier of a tier-split subscription, a provider plan name that differs from the catalog) logs one WARNING per shape per process and appears in plan.drift and summary.plan_catalog.drift.

Non-goals

Architecture

flowchart LR
    J[jacked/data/usage_plans.json] --> L[jacked/usage_plans.py loader + resolver]
    R[(accounts row: subscription_type, rate_limit_tier, profile_json, cached_usage_raw)] --> L
    L --> P[resolve_plan: label, policy, critical pct, drift]
    L --> S[session_window: present / absent / unknown, temporary, seconds]
    L --> M[model window_seconds]
    P & S & M --> API[routes/auth.py AccountResponse.plan + windows]
    P & S & M --> WS[usage_monitor WS payload plan + windows]
    S --> K[window keeper skip + 7d-only cache]
    P --> D[auto_swap thresholds + labels]
    S --> U[compute_urgency_tier]
    S --> C[jacked usage + pacing summary]
    API & WS --> JS[usage.js renderModelBar + usageBarOpts per window key]
  
json catalog + account row -> usage_plans resolver -> {plan, session window, model window lengths}
-> API/WS payloads -> JS bars; -> keeper, auto-swap, urgency, CLI/pacing
  

Session resolution (evidence order)

  1. The payload reports a session window (a limits[] entry with kind == "session", a five_hour object with a non-null utilization or reset, or a Codex short window): present; temporary on an until_verified plan; drift session_on_never_plan on a never plan (the payload still wins).
  2. The payload is authoritative and silent: absent, except a Claude always plan, which stays present (silence can mean "no window open") with drift session_missing. A Codex always plan reads absent with drift session_missing (Codex lists every window an account has).
  3. The payload is not authoritative, but the row still holds a 5-hour reading or reset (prior evidence): present (temporary on until_verified). A partial read must never erase a live window.
  4. No evidence: never → absent; until_verified or any Codex plan → unknown; Claude always/reported → present (legacy idle reading).

"Authoritative" for Claude: the raw payload has a limits list AND every entry's kind is one jacked knows (session, weekly_all, weekly_scoped). An unknown kind makes the list non-authoritative and is drift unknown_limit_kind, so a renamed session kind can never read as "no 5-hour limit". For Codex: jacked.codex.usage.normalize_rate_limits(raw) gives five_hour.reported and windows_complete (authoritative). unknown counts as having a window for every decision (keeper, headroom, urgency): jacked never skips a window it cannot rule out.

Payload contract

"plan": {
  "id": "ultra",                 // catalog id, or null when no entry matched
  "label": "Ultra",              // provider plan_display_name > catalog label > raw subscription type (Title Case)
  "formerly": "Max 20x",         // or null
  "label_source": "provider",    // provider | catalog | raw
  "drift": [],                   // codes: session_missing, session_on_never_plan, unknown_tier, unknown_limit_kind, renamed
  "catalog_verified_at": "2026-11-01"
}
"windows": {
  "five_hour": {"state": "absent", "temporary": false, "seconds": 18000,
                "note": "Verified Ultra accounts have no 5-hour limit. Weekly limits still apply."},
  "seven_day": {"state": "present", "temporary": false, "seconds": 604800, "note": null}
}
ModelUsage / binding entries: + "window_seconds": 604800

AccountUsage.five_hour: the cached value; when the cached value is null it is 0 only for a Claude account whose session state is present, otherwise null. seven_day keeps today's rule (Codex null stays null, Claude null reads 0). menubar_summary.summarize_account uses the same rule.

File structure and ownership

OwnerFilesChange
Core (main loop, done first)jacked/data/usage_plans.json, jacked/usage_plans.py, jacked/service/usage_pacing.py, tests/unit/test_usage_plans.py, tests/unit/test_usage_pacing_windows.py Catalog, loader, resolve_plan, session_window, account_view (plan/windows payloads, five_hour_reading, projected_row), project_row, per-plan and observed window lengths, drift log; pacing helpers account_windows, worst_window, account_recovery_at and the new summary fields.
B1 API + display backendjacked/api/routes/auth.py, jacked/service/menubar_summary.py, jacked/web/oauth.py, jacked/web/database.py (raw-payload size guard only), jacked/statusline_common.py, jacked/statusline.py (import line only) + their tests AccountResponse plan/windows, five_hour rule, ModelUsage window_seconds, bulk-refresh crash, OAuth crash, summarize_account rule, per-model window_seconds, binding_model_compact window_seconds + is_active, raw-payload guard raised and logged, delete dead _tier_label.
B2a monitor + keeperjacked/api/usage_monitor.py, jacked/web/auth.py (compute_urgency_tier and usage_cache_updates only), jacked/api/routes/menubar.py (urgency fallback only) + their tests Keeper skip / 7d-only cache / confirmed success / escalating backoff, burn-rate, WS payload plan + windows, projection at every decision entry point, recovery time, urgency, decision reason text, ingestion clears a 5h reading an authoritative payload no longer lists.
B2b auto-swap + CLIjacked/web/auto_swap/diagnostics.py, selection.py, __init__.py, tiers.py, jacked/cli.py (usage command only), tests/unit/test_auto_swap.py, tests/unit/test_usage_cmd.py Thresholds and labels from the catalog, explicit headroom gate, weekly length per account, jacked usage rows (plan, session_window, model_limits, worst window, active), --provider filter.
B3 frontendjacked/data/web/js/components/usage.js, accounts.js, panel.js, auto-swap.js, jacked/data/web/js/websocket.js + tests/unit/test_web_js_*.py, the JS call-site guard in tests/unit/test_codex_usage_display.py computeElapsedFraction(resetsAt, seconds) fed by windows[key].seconds, renderModelBar, usageBarOpts(acct, key, base), plan-aware windowReason, plan label (also refreshed by WebSocket updates), banner text, call-site guard.
Main loop (text)jacked/data/skills/chain-of-command/SKILL.md, README.md, docs/architecture/auto-swap-system.md, lessons.md Rule 7 rewrite, README text that assumes a 5h window, architecture sections 3 and 11.1, one lesson.

Tasks

Task 0 (core, main loop): catalog, resolver, pacing helpers, TDD — DONE before dispatch

Public API (jacked/usage_plans.py)
load_catalog(data_root=None) -> Catalog ; catalog() -> Catalog (cached)
window_seconds(provider, kind) -> int                 # provider length (session | weekly | weekly_model)
resolve_plan(row) -> PlanMatch                        # id, label, formerly, label_source, session policy, session_critical_pct, drift, window_seconds(kind)
session_window(row, raw=MISSING, plan=None) -> SessionWindow   # state present|absent|unknown, temporary, seconds, note, drift
has_session(row, raw=MISSING) -> bool                  # False only for absent; unknown counts as a window
account_view(row, raw=MISSING) -> AccountView         # .plan .session .weekly_state .weekly_seconds
    .plan_payload() .windows_payload() .five_hour_reading() .projected_row()
project_row(row) -> row                               # 5h value and reset dropped when the window is absent
model_window_seconds(provider, window=None, slot="weekly_model") -> int
raw_usage(row) -> dict | None

jacked/service/usage_pacing.py
account_windows(acct, now) -> [(kind, pct, reset)]    # "5h" only when the plan has one
worst_window(acct, now) -> (kind, pct, reset) | None  # 7d wins ties
account_recovery_at(acct, now, constrained_threshold=90.0) -> datetime | None

Task B1: API and display backend (Opus builder, worktree pw-b1)

Task B2a: monitor and keeper (Opus builder, worktree pw-b2a)

Task B2b: auto-swap and the usage CLI (Opus builder, worktree pw-b2b)

Task B3: frontend (Opus builder, worktree pw-b3)

Task 4 (main loop): text

Task 5 (main loop): combine, gate, review, prove

Filed, not fixed here

Decisions made (reviewable)