Skip to content

Latest commit

 

History

History
438 lines (331 loc) · 88.7 KB

ROADMAP.md

File metadata and controls

438 lines (331 loc) · 88.7 KB

AIrail Roadmap (research of 2026-09-05)

Generated from an 81-agent research run: two scouts (competition, codebase), ten ideation lenses, one merge, one skeptical macOS engineer per idea, a completeness critic, a synthesis pass. 96 ideas proposed → 55 merged → 65 verified → 62 kept. Interactive version with the same content: https://claude.ai/code/artifact/1b4a8ab6-0cea-47cc-989b-4fa07e31d771 . Full verdicts (APIs confirmed, blockers, prerequisites) live in the run journal under ~/.claude/projects/-Users-vincentjacobs-Developer-airail/8f27e9cd-b225-4973-80ce-2f0bad75ebb7/subagents/workflows/wf_235c4a16-1fd/journal.jsonl, keyed by the ids below.

Verdict

AIrail is not a meter; it is peripheral vision for AI capacity, and everything mindblowing about it lives on the one surface no rival has: a hairline on the edge of the screen that breathes for free (Core Animation, 0% CPU), fills along its length with your nearest limit, glints while Claude Code is actually generating, sweeps amber before you notice, and, on the notch, unfurls on its own for three seconds when a window resets or you cross 90%, then tucks away. The card underneath earns its glass by being honest-by-construction: stale numbers expire at their own reset, the wall leads with the time until it lifts, pace is arithmetic on server truth rather than slope-fitting, 'used elsewhere' explains a ring that moved while this Mac was idle, and every estimate says 'est.'. Then the Mac itself seems to know how much AI you have left: Siri and Shortcuts answer 'Claude is at 62%, resets in 2h 40m', a Focus filter quiets the rail, a Claude Code status line reads the same numbers, a hotkey peeks. All of it ships on a trust story CodexBar cannot copy because it is a menu-bar meter farm: one allowlisted network path, no cache, no cookies, hardened and attested builds, a Privacy pane that lists every host ever contacted, and a consent card before the first Keychain read. The rail, the restraint, and the read-only brand are the pitch, and they stay free.

Two corrections to the 2026-09-05 morning assessment. NSWindow.sharingType = .none is documented by Apple as "a legacy constant that macOS no longer uses… Don't use this value to hide or omit content from being captured" (verified against developer.apple.com on 2026-09-05), so hide-on-screen-share is cut. SwiftUI .redacted(reason: .privacy) only affects Text and Image; rings, share bars and chart bars are Shapes and keep drawing real percentages, so the redaction-based demo face is cut. A names-only redaction Bool (email, plan, projects; rings stay real) is the honest replacement if wanted (decision 2).

What the research changed

  • CodexBar moved. v0.56.x, ~21k stars, 69 providers, near-daily releases: pace forecasting ("lasts until reset"), WidgetKit widgets, 365-day local history, hourly heatmap, per-project/conversation cost, multi-account, hide-personal-information mode, iCloud sync, CLI + serve dashboard + JSON, hooks, provider plugins, adaptive refresh, 21 languages. Still menu-bar only. The README comparison table understates the gap; replace it with a "why the edge" story.
  • The notch is not a moat. CodexIsland, Claude Notch Tracker, NOTCHYLIMIT, Notchy, Vibe Island, Ping Island all put usage in the notch or a drawn island. Nobody does a screen-edge rail with per-display placement and an ambient hairline. Lead with the rail.
  • Platforms changed under the data model (Apr–Aug 2026). Copilot monthly plans bill AI credits in dollars (from June 1) plus session and weekly token×multiplier lanes (from April 20); AIrail reads the legacy quota_snapshots shape. Cursor split included usage into two pools (Cursor Models vs Other Models) plus on-demand; AIrail shows one totalPercentUsed. Codex Plus regained a 5-hour window on Aug 26 while Pro is temporarily exempt: a missing window must not read as 0%. Google shut the consumer Gemini tier (18 Jun 2026) and points to Antigravity. OpenRouter added an Activity dashboard and Analytics API; AIrail reads only credits.
  • Defects found in v0.2.0 that the README contradicts. (1) The default URLSession disk cache holds bearer tokens and cookies, so "keeps no tokens on disk" is false. (2) RailHairline's 20 Hz TimelineView measured ≈4.8% CPU idle with a full layout pass per tick. (3) The shipped DMG carries get-task-allow, so any same-user process can attach a debugger. (4) ModelPricing makes an undisclosed weekly call to openrouter.ai (principle 3) and the demo card prints dollars from it. (5) UpdateChecker can run a modal NSAlert over the user's work from the background timer. (6) The first notification after launch is dropped and 75/90% re-fire on relaunch. (7) A 403 HTML body reads as "sign-in expired".
  • Table stakes moved. Threshold + pace + reset alerts, per-model buckets, an extra-usage/credits bar, history deeper than 7 days, a scripting surface (JSON/CLI/statusline), widgets, multi-account. AIrail has the first two.
  • The biggest gap is distribution. Private, un-notarized, not on Homebrew, while every serious rival is one brew install --cask away. macOS 15 removed the Control-click "Open" bypass the README describes; users now need System Settings › Privacy & Security › Open Anyway. The Developer ID ($99/yr) is the prerequisite for the free path as well as any paid one.

The eight moments (all tier 1)

  1. The hairline becomes a gauge (ambient-headroom): fills along its length with the nearest limit; idle island caption reads "Claude 91% · Codex 12% · resets 14:30".
  2. Agent now playing (agent-now-playing): a travelling glint while Claude Code/Codex generates; caption "Opus · working · 142K context" from an FSEvents tail of the transcripts.
  3. Island announcements (island-announcements): at a reset or 90% crossing the notch unfurls for 3 s with "Claude · session reset" / "Claude 90% · Codex has headroom", then tucks away.
  4. At the wall, lead with the time (critic-6-at-the-wall-lead-with-the-time): at 100% every caption and the ring centre count down to the reset.
  5. Used elsewhere (critic-7-used-elsewhere): the ring climbed while nothing ran on this Mac → the card says so.
  6. Siri and Shortcuts (shortcuts-siri-app-intents): two App Intents answering from the cached snapshot, never fetching, never a Keychain prompt.
  7. Desk mode (desk-mode-both): island in the MacBook notch and the rail on the far monitor at once.
  8. Privacy & Security pane (privacy-pane): what AIrail reads, every host contacted and when, what it keeps, what this build is, Remove All Data.

Tier 1 — build next (42 items, ≈ 54 working days)

Five ordered blocks; each lands before the next touches the same files. v0.2.1 = block A item 1 alone. v0.3.0 (launch) = blocks A + B + E at minimum (my pick: plus the first three C items). Tick boxes as they ship.

Block A — Foundations and removals (10 items, ≈ 6 days)

  • Hardened, attested builds · 1 day · wow 2/5 · signing-hygiene (hardened runtime + workflow landed; 10-year cert by hand at v0.3.0, attestation at go-public — see TODO.md) Hardened runtime with no get-task-allow, a timestamped 10-year cert, and provenance attestations once the repo is public.

    • How: release.sh: build with CODE_SIGN_IDENTITY=-, PlistBuddy-add AIrailCommit, then one codesign --force --options runtime --timestamp --sign "AIrail Dev" with no --entitlements (get-task-allow gone) and three fail-the-build asserts (no get-task-allow, flags=0x10000(runtime), Timestamp). Recreate the cert with 10-year validity. Draft .github/workflows/release.yml on macos-15 with attest-build-provenance guarded on !repository.private; GeneralPane footer shows the commit only when the plist key exists.
    • Why here: Block A, first: the shipped 0.2.0 DMG is debuggable by any same-user process, a plain defect worth a v0.2.1 today. The cert re-creation costs one Keychain re-prompt; see open question 6 on whether to fold it into the Developer ID release instead. The attestation half waits for go-public.
  • Remove: v0.1 snapshot fields · hours · wow 1/5 · remove-v01-snapshot-fields Delete weeklyHistory, sessionUsed/Limit, .outage and the chart fallback so every view reads one UsageDetail.

    • How: In UsageSnapshot.swift delete sessionUsed, sessionLimit, weeklyHistory, historyDates and UsageStatus.outage; make detail non-optional (UsageDetail()). Drop the dead arguments at all nine builders, the two weeklyHistory writes in KeyedPlatforms, and reduce UsageChartSection to init(detail:color:sessionWindow:). Same pass: delete JWT.expiry, Summary.newestEventDate writes, CursorUsage.Report.onDemandEnabled. Update six test sites; one behaviour-free commit.
    • Why here: Block A: ~80 net lines removed, compiler-driven, and every later item (ledger, level line, pace) must be built against detail.days rather than reviving weeklyHistory. Note: used-elsewhere later re-adds newestEventDate as UsageDetail.newestLocalEvent, so delete the actor field only.
  • Single-flight, testable refresh · 1 day · wow 1/5 · single-flight-testable-refresh In-flight guards stop duplicate fetches; injected providers, notifier and clock make backoff, stale/error and alerts unit-testable.

    • How: ProviderManager gains inflight: [String: Task<Void, Never>]; refresh(_:force:) joins an existing task (force bypasses backoffUntil, never cancels), disconnect cancels, and the post-fetch write re-checks !Task.isCancelled && isConnected. refreshAll maps connectedProviderInfos (or demo). init(settings:providers:notifier:clock:); UsageNotifier gets init(authorize:deliver:). Skip start() under XCTestConfigurationFilePath. FakeProvider plus ~9 tests on a UserDefaults suite.
    • Why here: Block A: nine later items edit ProviderManager.refresh or UsageNotifier (notifications, announcements, stale expiry, used-elsewhere, ledger, pace alert); without this each is hand-tested on one Mac. Also stops every xcodebuild test run hitting live endpoints through the test host.
  • Simplify: static price table · hours · wow 1/5 · static-price-table Price the value line per model from a shipped, version-aware table; drop the weekly openrouter.ai fetch; show it only when live.

    • How: In ModelPricing.swift delete modelsURL, cache keys, refreshIfDue/refresh/loadCache, @MainActor and Codable; promote the fallback to a version-aware prefix table matched most-specific-first (a contains pass for Cursor ids). estimate(_:) apportions week.tokens by each model's share of week.models. OverlayView gates the line on status != .demo && spend == nil && week.cost == 0. Remove the AppDelegate call; rewrite the pricing test; add 'check ModelPricing.table' to the release checklist.
    • Why here: Block A: removes an undisclosed network call (principle 3), fixes the demo card printing dollars, and shrinks the allowlist to six hosts before cost-by-model builds per-row estimates on the same table.
  • One network path, allowlisted · hours · wow 2/5 · one-network-path-allowlist An ephemeral session with no cache or cookie jar, and a host allowlist enforced on every request and redirect.

    • How: HTTPClient owns a private static URLSession from .ephemeral (urlCache nil, httpCookieStorage nil, httpShouldSetCookies false, User-Agent header, waitsForConnectivity true, timeoutIntervalForResource 30) with a task delegate that refuses redirects off allowedHosts; send() throws non-transient ConnectionError.blockedHost before any task when scheme != https or host off-list. Route UpdateChecker through HTTPClient.get. One-time launch scrub of ~/Library/Caches and HTTPStorages for the bundle id. Tests: config, every provider host allowed, example.com blocked.
    • Why here: Block A: the v0.2.0 cache on disk holds bearer tokens and cookies, so README's 'keeps no tokens on disk' is false today; this is the prerequisite for the Privacy pane and sleep/wake reuses the same session. Add live.dodopayments.com to the list only if the licence ships (monetization).
  • Notifications the Apple way · 1 day · wow 2/5 · notifications-done-right Permission asked when you flip the toggle; tap opens that HUD; grouped per account; never dropped at launch; reset fires at the real time.

    • How: UsageNotifier.notify becomes async and reads notificationSettings().authorizationStatus; threadIdentifier = providerId, userInfo['providerId']; thresholds keyed by providerId+resetsAt (persisted); one UNTimeIntervalNotificationTrigger at resetsAt under '.reset' scheduled after the 75% alert, removed in forget/toggle-off/terminate. AppDelegate becomes UNUserNotificationCenterDelegate first thing in applicationDidFinishLaunching: willPresent [.banner,.list], didReceive expands the active surface then OverlayWindowController.show(providerId:). notificationsEnabled defaults false; GeneralPane requests authorization on toggle-on and links System Settings when denied. Clear percentHistory when resetsAt changes.
    • Why here: Block A: the delegate is shared by the update notification, island announcements, consent and the pace alert, so it lands once, here. Fixes the confirmed first-event-after-launch drop and the 75/90 re-fire on relaunch.
  • Quiet the update check · hours · wow 1/5 · update-check-without-modals The daily check posts a passive notification with a Download action; NSAlert stays only behind the menu item.

    • How: UpdateChecker.presentUpdate(userInitiated:false) posts one notification per release version (UserDefaults notifiedUpdateVersion) with category 'update', no activate(); menu path keeps NSAlert with NSApp.activate(). AppDelegate registers the 'update' category with a Download action; didReceive opens the DMG (host == github.com) or the release page. Replace the launch-only call with an hourly tolerant Timer. RailUIState.availableUpdate relabels the menu item 'Update to 0.2.1…'. README names the GitHub check.
    • Why here: Block A, right after the notification delegate exists: a modal NSAlert over someone's work from a background timer is the one thing here Apple would never ship, and the same category wiring is reused by Sparkle later.
  • Don't build: caps, forecasts, idle flag; fix the spend footer · hours · wow 1/5 · dont-build-caps-projection-idle No soft caps, month-end projection or idle-subscription nag; make the spend footer honest now that every unit is documented.

    • How: Log the three cuts in TODO.md. Add SpendPeriod { month, billingCycle, lifetime, keyLimit } and spendPeriod to UsageSnapshot; OpenRouterUsage reads usage_monthly for spend and derives the ring from (limit - limit_remaining)/limit; Cursor tags .billingCycle. OverlayView.footer switches the caption on spendPeriod ('SPEND (ALL TIME)' etc.). UsageFormatting.credits/dollars use .currency(code:).locale(locale) with en_US pinned in tests.
    • Why here: Block A: OpenRouter's lifetime usage currently sits under a 'SPEND (SEP)' caption, a live mislabel. Do the money formatting here once so locale-formatting and cost-by-model share it.
  • Don't build: the demo face · hours · wow 1/5 · dont-build-demo-face Cut the privacy-redaction mode; the collapsed surfaces already expose only names and percents, and redaction leaves live rings drawing.

    • How: No code. Strike 'demo face' from the tier-1 list and TODO; add no AppSettings key, menu item, .redacted or .privacySensitive. Record the finding that Shapes (rings, share bars, chart bars) ignore redaction, so a 'redacted' card looks broken, not private. If BY PROJECT names ever draw a complaint, the narrowest fix is one 'Show project names' toggle under General, added on demand.
    • Why here: Block A decision record. The companion capture-hiding switch is on the cut list too (sharingType .none is ignored on macOS 15.4+), so the presenter story rests on the surfaces themselves; presentation-aware mirroring in tier 3 is orphaned unless open question 2 reverses this.
  • Don't build: recap card yet · hours · wow 1/5 · dont-build-recap-card-yet No Share Week… before a notarized public release and a calendar-week history; note the ImageRenderer trap so nobody pre-builds the wrong thing.

    • How: Edit TODO.md: replace the 'shareable recap card' nicety with a gated note (Developer ID + notarized public release + the usage ledger). Record that ImageRenderer skips .ultraThinMaterial and AppKit-backed Menu/Picker, so the card must be its own value-typed, material-free view rendered at scale 2 and offered via ShareLink, never an extraction of OverlayView.
    • Why here: Block A decision record; the redacted recap card returns in tier 2 with exactly these prerequisites.

Block B — Honest numbers (11 items, ≈ 10 days)

  • Live countdowns and locale formatting · hours · wow 2/5 · locale-formatting-live-countdowns Reset times tick down by themselves, clocks honour 12/24-hour, money follows your locale, percents roll like Apple's digits.

    • How: UsageFormatting.resetString/dollars/credits/duration gain locale: Locale = .autoupdatingCurrent using Date.FormatStyle, .currency(code:) and Duration.UnitsFormatStyle([.hours,.minutes], .narrow) so '2.4 h' becomes '2h 24m'. ChartCallout uses Date.IntervalFormatStyle; hourLabels use .hour(.defaultDigits(amPM:.abbreviated)). sessionSection renders Text(resetsAt, style: .relative) under 24 h. .contentTransition(.numericText(value:)) on the three percent Texts, .id(info.id) on the island caption. Tests pinned to en_US vs en_GB.
    • Why here: Block B, first: this is the already-approved '2h 40m' formatter done once; at-the-wall, pace, Siri dialog, used-elsewhere and the headroom caption all read from it. A plain en_US Mac currently sees 'resets Mon 14:00' beside a 12-hour clock in the same card.
  • Read the 2026 meters · hours · wow 2/5 · critic-5-read-the-2026-meters Copilot's credits-billed plans read as 'AI credits', Cursor's two pools get their dashboard names; no guessed Codex caption.

    • How: CopilotUsage.parse: when token_based_billing is true and premium_interactions.entitlement > 0, name the meter 'AI credits', periodLabel 'monthly credits', note '1 credit = $0.01 · ≈ $9.22 of $15.00' plus overage_count; keep the legacy and chat branches. Add UsageSnapshot.unitLabel (default 'requests') used by sessionSection and weeklyAccessibilityValue. CursorUsage.detail renames 'Auto mode' → 'Cursor models', 'API models' → 'Other models'. Fixture built from copilot-sdk typings; README row tweak.
    • Why here: Block B: a relabel, not a parser rewrite, and the visible quality signal for the buyers who compare tools. Never print 'No session limit' for Codex; a missing window is not evidence.
  • Stale numbers expire at their reset · hours · wow 2/5 · critic-4-stale-numbers-expire-at-their-reset A stale ring drops to '—' and the hairline calms the moment the window it was read from has reset.

    • How: UsageSnapshot.expiringWindows(now:) nils session fields when resetsAt < now, weekly fields plus detail.meters when weeklyResetsAt < now, records expiredResetAt. ProviderManager.refresh applies it in the catch (lastLive.marking(.stale).expiringWindows()) and in the backoff early-return (assign only on change), calling notifier.forget and clearing percentHistory on first expiry. OverlayView.notice appends 'The session window reset Fri 14:30 — nothing read since'. Test beside testStaleMarkingKeepsNumbers.
    • Why here: Block B: an amber hairline all weekend from a window that no longer exists is a known-false reading wearing a smaller badge (principle 4). ~40 lines, no setting.
  • Hashed Claude Keychain name · hours · wow 1/5 · hashed-claude-keychain-name Try Claude Code-credentials-<sha8> beside the legacy item so CLAUDE_CONFIG_DIR users are not blanked.

    • How: ClaudeUsage.keychainServices(configDir:) returns the legacy name plus 'Claude Code-credentials-' + first 8 hex of CryptoKit SHA256 over the NFC path (~/.claude, plus the env var when present). KeychainReader.genericPassword(services:tool:) probes attributes-only (no prompt), reads data from the most recently modified candidate, throws .notSignedIn only when all are missing. ClaudeProvider remembers the winning name; one test; one caveat sentence.
    • Why here: Block B: table-stakes robustness, and work-personal-accounts (tier 2) reuses keychainServices verbatim.
  • Sleep, wake and offline awareness · 1 day · wow 2/5 · sleep-wake-offline-awareness 'Offline' over the last real numbers, no polling while asleep, one refresh on return.

    • How: ProviderManager: NWPathMonitor hopping a Bool into isOnline; NSWorkspace willSleep invalidates the timer, didWake restarts it after ~2 s and runs refreshAll if online. When offline, skip the fetch, mark lastLive .stale with lastErrors = .offline, leave backoff untouched; on reconnect clear backoff for .offline/.network and refresh once (10 s dedup). ConnectionError.offline ('You're offline…'), rendered as a wifi.slash Label. timer.tolerance = max(5, interval*0.1); JSON-parse failure of an HTTP body becomes transient .network.
    • Why here: Block B: the wake-time .network failure and 1-minute backoff are a real defect today. The session config lives in one-network-path's static session; no pause on screen lock (that is when the 90% alert matters).
  • Pace against the window · 1 day · wow 3/5 · pace-against-window An inner time-elapsed arc in the ring and a '12 pts above an even pace' line, from server truth, no slope fitting.

    • How: Add sessionWindowLength and periodStartsAt to UsageSnapshot (Claude 5 h; Codex from limit_window_seconds; Cursor billingCycleStart; Copilot month start; Mock 5 h) and make OverlayView.sessionWindow use them instead of -5*3600. UsagePace.of(percent:window:now:) in UsageInsights. sessionRing draws a ~4 pt white 0.35 time arc inside the 9 pt ring with a '2h 10m left' caption; the burn-rate slot shows the pace line when no projection exists. LogoMark gets an optional 2 pt 'now' tick. testPaceMath.
    • Why here: Block B: kills the hard-coded 5-hour window that used-elsewhere and the tier-2 exchange rate both need, and fills the burn-rate slot that is blank most of the time.
  • At the wall, lead with the time · hours · wow 3/5 · critic-6-at-the-wall-lead-with-the-time At 100% every caption and the ring centre switch from '100%' to '1h 12m', and a final 'limit reached' alert names the reset.

    • How: UsageSnapshot.atLimitResetsAt: non-nil when session or weekly percent ≥ 99.5 and that window has a reset (both spent → the later). UsageFormatting.countdown(to:now:) ('1h 12m', '12d 3h', 'resetting…' once passed) shares the Duration formatter. Substitute into RailView caption/help, NotchView caption, and sessionRing centre (monospacedDigit over 'until reset') inside TimelineView(.periodic(by: 60)) only while at limit; ring stays full in the provider colour. UsageNotifier thresholds [100, 90, 75] with its own copy. Tests for target selection and 80→100→5.
    • Why here: Block B, after the formatter: the one moment users stare at the rail, and keyed platforms (no reset date) keep '100%' honestly. railAccent should take the same session-or-weekly max.
  • Used elsewhere · 1 day · wow 3/5 · critic-7-used-elsewhere When the session ring climbs while nothing ran on this Mac, the card says so, so a second Mac or claude.ai stops looking like a bug.

    • How: Block A deleted the scanner's newest-event field, so re-add it first: a max over event.date in TranscriptScanner.ingest, exposed as Summary.newestEventDate and forwarded as UsageDetail.newestLocalEvent in ClaudeUsage/CodexUsage.snapshot. Pure UsageElsewhere in UsageInsights, evaluated on the .ok path beside recordSample and cleared on disconnect or a new resetsAt. Rule A: sessionPercent ≥ 3 and no local event since window start − 2 min → 'No Claude Code activity on this Mac this session'. Rule B: accumulate rises ≥ 3 pts across ≥ 2 reads with newestLocalEvent unchanged → 'Up 12 pts since 14:02…'. One .subheadline line with a .help stating exactly what is read.
    • Why here: Block B, after pace-against-window supplies the window start. Phrase as an observation, never a device attribution; no island qualifier.
  • Screen Time header · 1 day · wow 3/5 · week-over-week-header 'DAILY AVERAGE 2.1M tokens · ↑12% from last week' above the chart plus a dashed average line; the 30-day segment is cut.

    • How: TranscriptScanner.summary gains lookbackDays = 14 (days/week stay 7) and Summary.previousWeek; thread it into UsageDetail.previousWeek from Claude, Codex and Cursor (historyDays 7→14; keyed platforms once verified). UsageChartSection's caption becomes a range-following header: 'LAST 24 HOURS' + total, or 'DAILY AVERAGE' at 20 pt semibold with a secondary Label('12% from last week', systemImage: arrow.up) from UsageFormatting.percentDelta (nil unless previous tokens > 0). Sparkline gains a dashed average. Demo fills previousWeek at 0.89. Tests extended.
    • Why here: Block B: the previous week is already on disk for Claude and Codex, so no store is needed; the 30-day range stays cut because Claude's 30-day transcript retention would draw a false fade.
  • Cost by model and project · 1 day · wow 3/5 · cost-by-model-and-project Quiet '≈ $' estimates under each model and project, a per-model-accurate value line, and any populated seven_day_ meter.

    • How: UsageDetail: UsageKey { model, project } and splits: [UsageKey: TokenSplit] on UsageAggregate (merged in merge), with models/projects and Cursor's cost derived (costs keeps real per-model cents); fill at the four collapse sites plus demoDetail. ModelPricing.estimate sums price(model) × split, prices(for:) matches forward prefixes at '-'/'.'/end boundaries (test 'gpt-5' vs 'gpt-5.5-mini'). UsageBreakdown.column appends a caption2 tertiary '≈ $18' (hidden under $1; unprefixed for Cursor's real cents) with .help. Optionally parse any non-null seven_day_ into a UsageMeter.
    • Why here: Block B, after the static table: per-row figures make a wrong prefix match visible where the blended figure hid it, so the matcher lands in the same change. The Opus meter as pitched is null on Vincent's Max account; generalise, never show 0%.
  • Local usage ledger and export (ledger, restore and Copilot history landed; Export/Delete Usage History are phase two — see TODO.md) · 2–4 days · wow 2/5 · local-usage-ledger One JSON file per account on disk with 366-day retention, persisted alert state, Copilot daily history, CSV/JSON export later.

    • How: Actor UsageStore in Providers/Support: ~/Library/Application Support/AIrail/usage/.json (version, lastSnapshot with account stripped, days upserted from detail.days pruned to 366, percentSamples, notifier window keys, a sampled weeklyUsed level for feedless accounts), written atomically only when bytes change. Make the usage types Codable. start() loads before refreshAll to seed lastLive (marked .stale), percentHistory and notifier state; record on success and connect; delete on disconnect. Copilot: today minus yesterday → UsageBucket messages. Phase two: UsageExport + 'Export Usage…' and 'Delete Usage History…'.
    • Why here: Block B, last: fixes projection-nil-for-2-min, alert re-fire and launch-time blanking, closes the Copilot TODO, and is the single store that battery-level-line, plan-fit and the recap card build on (put their samples in this file, not a second store). README's 'keeps no tokens on disk' gets an honest sentence about usage counts and project basenames; under the sandbox the path moves into the container (open question 1).

Block C — The ambient surface (10 items, ≈ 13 days)

  • Simplify: no drawn pill (Notch and Island merged into Top, decision 3) · hours · wow 3/5 · island-collapses-to-hairline On displays without a notch, idle shows only the hairline at top centre; nothing floats over full-screen video.

    • How: NotchView.hairline becomes unconditional (Color.clear over ui.notchSize.height above RailHairline); delete NotchPillShape; keep virtualNotch, notchIsVirtual and frame(expanded:) so the island anchors to the same rect; leave ignoresMouseEvents default so the hairline pixels are the target and the menu-bar centre stays click-through. Drop NotchGeometry.virtualMinHeight; give RailPosition an explicit label ('Top'); rewrite the RailPane footer, NotchWindow header comment and README paragraph. Optionally fold Notch and Island into one 'top' case with a stored-value migration.
    • Why here: Block C, first: deletes the shape the morph would otherwise have to animate, and settles the Notch/Island merge (open question 3) before README screenshots are shot once.
  • One-shape island morph, ring fill · 1 day · wow 4/5 · island-morph-ring-fill The island grows as one continuous shape from the notch and the HUD ring draws itself from zero when the card opens.

    • How: NotchView.body: replace the isExpanded branch and islandTransition with a ZStack holding one always-present IslandShape (animatableData = AnimatablePair(flare, bottomRadius)) framed ui.notchSize with 12/10 when collapsed and maxWidth .infinity × notchSize.height + islandBodyHeight with 16/22 when expanded, driven by the existing springs; marks and caption fade/scale in with a 0.12 s delay, allowsHitTesting and accessibilityHidden gated on isExpanded; stroke and shadow only when expanded or virtual. OverlayView.sessionRing: @State drawn, trim from 0 with a 0.7/0.85 spring on appear, .id(selectedProviderId). Reduce Motion: no morph, instant ring.
    • Why here: Block C: TODO already names it as the next tuning; do it before screenshots and the recap card so renders show the final motion. Edge rail deliberately untouched.
  • Energy-honest hairline (CPU measurement by hand still owed — see TODO.md) · 1 day · wow 2/5 · energy-honest-hairline Replace the 20 Hz TimelineView with a Core Animation breath that pauses when occluded or asleep and holds still under Low Power Mode.

    • How: Rewrite RailHairline as an NSViewRepresentable with the same signature: layer-backed view, two CAShapeLayer capsules, CABasicAnimation('opacity') duration 2.25, autoreverses, infinite; updateNSView only sets fills on accent change. Hold still when reduceMotion, isLowPowerModeEnabled or thermalState ≥ .serious (observe both notifications, hop to MainActor); pause when occlusionState lacks .visible or after screensDidSleep, resume in viewDidMoveToWindow/wake. Acceptance: top idle ≤ 0.3% and a sample with no layoutIfNeeded frames; record 4.8% → after in TODO.
    • Why here: Block C, before any other hairline change: measured 4.8% CPU idle with a full Auto Layout pass per tick. Headroom fill, the now-playing glint, the dashed severity stroke and the full-screen pause must all be built on the CA layers, not the TimelineView.
  • Ambient headroom: gauge and caption · 1 day · wow 4/5 · ambient-headroom The hairline fills along its length with the nearest limit and the idle island caption reads 'Claude 91% · Codex 12% · resets 14:30'.

    • How: Pure UsageInsights.headroom(of:) → HeadroomSummary (peakId, percent, roomId = lowest other account under 70% with .normal severity, resetsAt, 'demo'/'stale' qualifier), exposed as ProviderManager.railHeadroom and replacing both duplicated railAccent computeds; unit-tested. RailHairline gains fill: Double?: a full-length accent 0.2 track with the lit glow+line sized max(0.08, fill/100), anchored bottom on the edge and leading under the notch, eased 0.6 s, nil = today's line; accessibilityValue on the collapsed hairline. NotchView idle caption: at most two segments in the existing 14 pt row, plus a secondary qualifier; hover keeps the projection caption.
    • Why here: Block C: one HeadroomSummary feeds the three already-approved tier-1 strings (hover projection caption, '2h 40m', the failover nudge quoting roomId in the 90% alert body) and peek-hotkey's nearest-to-limit. Fill is a CA layer length after the rewrite.
  • Agent Now Playing · 2–4 days · wow 4/5 · agent-now-playing While Claude Code or Codex is generating, the hairline carries a travelling glint and the caption reads 'Opus · working · 142K context'.

    • How: Providers/Support/ActivityMonitor.swift: one FSEventStream per connected feed (~/.claude/projects, ~/.codex/sessions; latency 0.5 s, FileEvents, main-actor hop), per-file offsets initialised to current size, appended lines fed to pure ClaudeUsage/CodexUsage.activityEvent (turn markers, model, context tokens; Codex also model_context_window) with a 5-minute safety expiry. UsageProviding.activityFeed (default nil); ProviderManager publishes activity: [String: AgentActivity]. Hairline: a gradient-masked brighter capsule of the current accent sliding every ~3.5 s (steady under Reduce Motion). Captions append '· Opus 4.8 · 127K'; OverlayView gets a 'Now' row. Fixture and temp-dir tests.
    • Why here: Block C, after the CA hairline (the glint is a layer animation) and after headroom (the caption row is composed once: activity while working, projection or headroom otherwise). Never shown for Cursor, Copilot, keyed or demo. The adaptive-refresh DirectoryWatcher in tier 2 reuses this monitor.
  • Island announcements · 2–4 days · wow 4/5 · island-announcements At a reset or 90% crossing the island unfurls on its own for three seconds with a caption, then tucks away.

    • How: Factor detection out of UsageNotifier.consider into a pure UsageEventDetector in UsageInsights (enum UsageEvent: reset, threshold 90; keep the ≥25-pt-drop rule and add previous.resetsAt < now < new.resetsAt); ProviderManager.refresh feeds it and publishes a PassthroughSubject with UsageNotifier and AppDelegate subscribing. Notch only: NotchWindowController.announce expands, sets RailUIState.announcement ('Claude · session reset', 'Claude 90% · Codex has headroom' from HeadroomSummary.roomId), sleeps 3 s, clears, scheduleCollapseIfIdle; caption prefers the announcement, one phaseAnimator bounce, never while the HUD is open or for demo. Edge rail: .animation(.easeInOut(1.5), value: accent) on the hairline only. One 'Show in the island' sub-toggle.
    • Why here: Block C: the notification craft it bundled already landed in notifications-done-right, so this is the detector plus the moment. Cannot honour Focus without the communication entitlement (Developer ID), same as iOS's own island moments; a later drop-in.
  • Semantic surface style tokens · 1 day · wow 2/5 · surface-style-tokens One pass honours Reduce Transparency, Increase Contrast, Differentiate Without Colour and VoiceOver; light mode stays a separate decision.

    • How: GlassPanel (and the overlay's inline duplicate, which becomes GlassPanel(shape:)) reads accessibilityReduceTransparency and colorSchemeContrast: flat windowBackgroundColor with no material under reduce, edge stroke 0.35 under increased; same guard in LogoMark.disc and ChartCallout. Swap ~25 literals for .secondary/.primary/separatorColor/.quaternary via a tiny SurfaceStyle value in the views that draw custom surfaces (no EnvironmentKey). UsageSeverity gains a shape: dashed warning, short-dash plus exclamationmark critical on the hairline under Differentiate Without Colour; hairline gets accessibilityValue and an 'Expand' accessibilityAction via an onExpand closure. Leave the forced darkAqua.
    • Why here: Block C, after the CA hairline (dashes become lineDashPattern) and after headroom (the accessibilityValue text is the HeadroomSummary). Verify once with Increase Contrast on that colorSchemeContrast flips inside the forced-darkAqua panels; otherwise read NSWorkspace's flag. Light appearance is open question 8.
  • First-run reveal and reopen · hours · wow 2/5 · critic-1-first-run-reveal-and-reopen On first launch the rail unfurls for a few seconds and Settings › Accounts opens; reopening AIrail from Finder or Spotlight springs the rail open.

    • How: AppDelegate.reveal(holdingFor: 4) calls expand() on the active controller and scheduleCollapseIfIdle() after the hold, never activating the app; applicationShouldHandleReopen calls it and returns false; call it at +0.8 s after launch when settings.isFirstLaunch (Key.firstLaunchDate set in init). Replace the RailView-only settings path with RailUIState.settingsRequest consumed by both RailView and NotchView, set from LaunchOptions, to .accounts on first launch, and on reopen while no account is connected; first launch also presents the Add Account sheet. No welcome screen, no Help menu (accessory apps have no menu bar).
    • Why here: Block C: an accessory app with no Dock icon currently does nothing when reopened, and a first launch shows an empty rail nobody notices. Reuses expand/scheduleCollapseIfIdle that announcements and peek also use.
  • Dock-style edge etiquette · 1 day · wow 2/5 · critic-2-dock-style-edge-etiquette The rail expands after a short dwell, never on an edge the Dock already owns.

    • How: Pure EdgePolicy.effectiveSide(chosen:frame:visibleFrame:dockOrientation:dockAutohides:) in ScreenSelection.swift flips Left/Right when the Dock owns that edge (visible Dock via the visibleFrame inset; hidden Dock via UserDefaults(suiteName: 'com.apple.dock') orientation + autohide); AppDelegate.applyPosition publishes RailUIState.effectiveSide replacing the six settings.railSide reads, with a RailPane footer 'The Dock is on the left edge of , so the rail uses the right edge.' HoverTrackingView gets a fixed ~120 ms dwell cancelled by mouseExited; no pressedMouseButtons guard (AppKit already withholds mouseEntered during drags). Tests beside testAutomaticRailDisplayIsTheOuterEdge; README notes the Dock-prefs read.
    • Why here: Block C: for anyone with a left or right Dock, today's rail is a 10 pt strip beside the Dock where the pointer never stops, i.e. broken on first launch for that segment. Land before go-public.
  • Always-shown compact rail · 1 day · wow 3/5 · always-shown-compact-rail A Dock-style 'Automatically hide the rail' toggle; off, the rail stays open as a narrow column of rings.

    • How: AppSettings.railAutoHides (default true); RailPane puts the Toggle in the auto-hide Section with the delay Slider disabled when off, Left/Right only, footer saying it floats over windows rather than reserving space. RailWindowController moves the 108/10/44+5+31 literals into a unit-tested RailMetrics enum; frame(expanded:) treats !railAutoHides as expanded at width 64 with cell 44+3+13; show() expands and scheduleCollapse returns early when pinned; applyAutoHide() driven by one settings sink in AppDelegate. logoButton drops the name line when pinned, keeps the percent (dimmed when stale); shadow radius 8; carry railAccent into the pinned card's 1 pt edge stroke.
    • Why here: Block C: the state machine already supports it via isExpanded held true, and RailMetrics is the extraction three other items wanted. Not offered for Notch/Island. Full-screen behaviour comes with the tier-2 menu-bar-hide watcher.
    • Landed 2026-09-08, first half: AppSettings.railAutoHides + railIsPinned, the Toggle with the delay Slider disabled, RailWindowController.applyAutoHide() from one settings sink, pinned keeps the full 108 pt card with names. Still open: the 64 pt compact column without the name line, RailMetrics, the accent edge stroke.

Block D — Reach (5 items, ≈ 9 days)

  • Peek hotkey · 1 day · wow 3/5 · peek-hotkey A permission-free Carbon hotkey (off by default) opens the rail and the HUD for the nearest-to-limit account; a second press or Escape closes both.

    • How: Providers/Support/HotKey.swift: Codable KeyCombo plus a @MainActor GlobalHotKey wrapping RegisterEventHotKey/InstallEventHandler with an Unmanaged userData trampoline and MainActor.assumeIsolated. AppSettings.peekShortcut (nil = off); AppDelegate subscribes to (re)register and handles: if overlay.isVisible close, else expand the active surface and overlay.toggle(providerId: railHeadroom.peakId). Settings › General 'Peek shortcut' row backed by a small ShortcutRecorder NSViewRepresentable (local keyDown monitor, requires a modifier, Delete clears, Escape cancels). Optional ←/→ in OverlayPanel.keyDown. KeyCombo round-trip test; verify over Terminal with Secure Keyboard Entry on.
    • Why here: Block D: one key, one thing, and the glue is six lines because expand() and overlay.onClose exist. Uses HeadroomSummary.peakId instead of a second nearest-to-limit reduction; no KeyboardShortcuts package.
  • Shortcuts and Siri via App Intents · 1 day · wow 4/5 · shortcuts-siri-app-intents Siri and Shortcuts answer 'Claude is at 62%, resets in 2h 40m'; a global key comes free from Shortcuts; Spotlight arrives on macOS 26 with no code.

    • How: Sources/AIrail/Intents/UsageIntents.swift with exactly two intents: GetUsageIntent (@Parameter account: AccountEntity; @MainActor perform() returning .result(value:dialog:view:) from the cached ProviderManager.snapshots plus projection(for:), never fetching; dialog voices demo/stale/error/'still reading'; snippet reuses LogoMark) and ShowUsageIntent (new AppDelegate.showOverlay(for:) wrapping overlayController.toggle with the 1.5 s settle). AccountEntity carries @Property sessionPercent, weeklyPercent, resetsAt, weeklyResetsAt, plan, status; EntityQuery reads connectedProviderInfos. AIrailShortcuts provider with the macOS 14 AppShortcut initializer; updateAppShortcutParameters() at launch and in the connectedAccountIds sink. Dialog builder is a pure, tested function. No refresh intent.
    • Why here: Block D, after the formatter so Siri reads '2h 40m' not '2.4 h'. Get must return the cached snapshot so an intent never triggers a Keychain prompt. Verify once that Shortcuts lists the AIrail Dev-signed build; the Focus filter in tier 2 reuses AccountEntity.
  • Status files and deep links · 2–4 days · wow 4/5 · airail-cli-statusline-deeplinks airail:// URLs open, peek or nudge; a status.json and a pre-rendered status-line segment feed Claude Code and Codex hooks; no second binary until notarized.

    • How: Register airail in a two-key Sources/AIrail/Info.plist; AppDelegate.application(_:open:) dispatches a Models/Deeplink.swift enum that also absorbs LaunchOptions: overlay/ → toggle, peek → expand + scheduleCollapse(after: 2), settings/ → RailUIState.settingsRequest, refresh?account= → ProviderManager.nudge with a 20 s floor honouring backoffUntil. Providers/Support/StatusFile.swift atomically writes status.json (connected accounts only, no email or projects, never demo) and a one-line ANSI statusline on every snapshot change, 'AIrail isn't running' on quit. AccountDetailView 'When a turn ends' section with Copy buttons: Claude Stop hook open -g, Codex [[hooks.Stop]], and cat …/statusline.
    • Why here: Block D: the scripting surface competitors have, done as files plus URLs so no un-notarized helper is ever exec'd every 300 ms. Under the sandbox the file path moves into the container and the cat recipe gets uglier (open question 1). Compiled helper and Homebrew binary stanza wait for the Developer ID.
  • Consent card before connect · 1 day · wow 3/5 · consent-first-connect Connect from the HUD or Add Account through one card saying what is read and what macOS will ask; Keychain re-prompts wait for your click.

    • How: Shared ConsentCard(info:onConnect:onCancel:) (LogoMark, explainer, caveat, 'macOS will ask — choose Always Allow' for Claude) shown as Page.consent in AddAccountSheet and as a .popover from the demo notice in OverlayView, both calling ProviderManager.connect; the overlay's click monitor does not close the HUD while refreshingIds contains the selected id. UsageProviding.fetchUsage gains interactive: Bool; ClaudeProvider, when non-interactive and the token expired, does an attribute-only SecItemCopyMatching via KeychainReader.creationDate(service:) and throws transient .needsConsent if the creation date changed; the timer skips .needsConsent/.accessDenied. 'Allow…' link in the stale notice; 'Needs your OK' in captions; 'demo' instead of a percent under demo marks.
    • Why here: Block D: the first three tools go live without opening Settings, and a cancelled SecurityAgent dialog is never re-raised on the 60 s timer. Watch one real rotation on the stably signed build first; the re-prompts seen so far match ad-hoc re-signing.
  • Desk Mode: island and rail · 2–4 days · wow 4/5 · desk-mode-both 'Also use the notch' keeps the island in the MacBook's notch and the rail on the far monitor's outer edge at once, HUD beside whichever you clicked.

    • How: AppSettings.notchAlongside (key railAlsoInNotch) as a RailPane checkbox shown only for Left/Right with a hardware notch and more than one screen. applyPosition() replaces usingNotch with independent wantsIsland (hardware notch only) and wantsRail (railScreen excluding notched displays), shows both controllers, fans onClose and reposition() to both, resets expansion only on the surface whose visibility flipped. Each controller owns a SurfaceState { isExpanded } (deleting RailUIState.isExpanded); OverlayWindowController.toggle(providerId:anchor:) takes OverlayAnchor { frame, edge, owner } replacing railFrameProvider/anchorsBelow; isOverlayOpen compares owner. ScreenSelection.outerIndex test with the notched frame excluded.
    • Why here: Block D, last: multi-display citizenship is white space nobody else touches, and SurfaceState plus OverlayAnchor are the model the tier-2 tear-off HUD extends. Test on the Dell | ultrawide | MacBook desk with hot-plug and clamshell; base-controller extraction is recommended, not required.

Block E — Trust and go-public (6 items, ≈ 16 days)

  • Endpoint drift detection (record the first fixtures by hand — see TODO.md) · 2–4 days · wow 2/5 · drift-detection-fixtures Shape drift gets an honest error and update nudge; recorded, redacted fixtures catch it before users do.

    • How: ConnectionError.shapeChanged(tool:detail:) (transient; 'Cursor sent usage data in a form AIrail can't read yet. Check for an update.') thrown by the six 2xx-but-keys-missing guards with sorted key names, and by HTTPClient.unwrap when the body is not JSON (fixes 403-HTML → 'sign-in expired'). First .shapeChanged per provider per launch calls UpdateChecker.checkQuietly(); OverlayView.notice adds 'Check for Updates…' for that case; AccountDetailView adds a 'Report…' Link with key names. LiveProviderTests: a RecordingURLProtocol under AIRAIL_RECORD_FIXTURES=1 writing allow-list-redacted bodies to Fixtures/, a JSONShape.paths helper and a fixture ⊆ live assertion. Codex session-log fallback deferred.
    • Why here: Block E: the recorder is the enabler for the TODO's 'verify keyed parsers against real keys', which gates Mistral/xAI and the cost lines on API accounts; the nudge only becomes visible once the repo is public.
  • About panel and diagnostics · 1 day · wow 2/5 · about-report-diagnostics Standard About panel, Report a Problem… prefilling a GitHub issue, Save Diagnostics… from os.Logger; nothing leaves by itself.

    • How: One AIrailMenuItems view replaces the three copied menus and adds 'About AIrail' (NSApp.activate then orderFrontStandardAboutPanel with a credits NSAttributedString) and 'Report a Problem…': a Feedback-Assistant-style NSAlert whose NSTextView shows the redacted summary (version, OS, placement, displays, per-account status/shortDescription/backoff) with 'Open GitHub Issue' (ProjectLinks.newIssue targeting .github/ISSUE_TEMPLATE/bug.yml text fields) and 'Save Diagnostics…' (NSSavePanel: summary + OSLogStore(.currentProcessIdentifier) entries + newest AIrail-*.ips through Diagnostics.redact). First adopt a small Log enum (categories refresh/network/window/keychain/update; host, status, provider id, counts public; never headers or bodies). Two tests: no home path, no '@'.
    • Why here: Block E: the menu consolidation is the first commit and is what keyboard-driven-hud, the recap card and the licence menu line all wait on. The issue link only works for others once public; ship in the go-public checklist.
  • Privacy & Security pane · 2–4 days · wow 3/5 · privacy-pane A fourth Settings tab listing what AIrail reads, every host it contacted and when, what it keeps, what this build is, and Remove All Data.

    • How: ConnectionMethod gains footprint: [FootprintItem] (.file, .keychain, .command, .host) filled per provider and keyed platform, with testEveryProviderExplainsItsConnection requiring it. In-memory NetworkLedger (host: last date, method, path, status) recorded from HTTPClient.send. Views/PrivacySettingsView.swift with SettingsTab.privacy (hand.raised), grouped Form: 'What AIrail reads' (Reveal in Finder, Open Keychain Access), 'Where it connects' (ledger rows, footer 'Since AIrail launched, 3h ago. Nothing else.'), 'What it keeps' (computed live: prefs size, Keychain item count, 'No cache, cookies or tokens on disk'), 'This build' (SecCodeCopySigningInformation: signer, notarized, Hardened Runtime, sandbox), then 'Remove All AIrail Data…' with confirmationDialog.
    • Why here: Block E, after one-network-path and the static table (or 'What it keeps' would lie) and after the sandbox decision, since the 'This build' row and the 'What it keeps' paths change with it. Also the natural home for 'Licensed to' if the licence ships.
  • Kernel-enforced read-only sandbox · 2–4 days · wow 3/5 · app-sandbox-read-only Run in the App Sandbox with read-only home-path exceptions so codesign proves AIrail cannot write to any tool's store.

    • How: Sources/AIrail/AIrail.entitlements: app-sandbox, network.client, home-relative-path.read-only for /.claude/, /.codex/, Cursor's globalStorage, /.config/gh/, /.config/github-copilot/ via CODE_SIGN_ENTITLEMENTS; Resources/container-migration.plist moving the prefs plist; RealHome.path (getpwuid) replacing the six NSHomeDirectory() sites. First commit, independently useful: CopilotProvider reads the gh token from the Keychain item 'gh:github.com' (strip 'go-keyring-base64:', decode), falling back to hosts.yml then apps.json, and CommandRunner.swift is deleted. No UI; rewrite README Install, Accounts intro and release.sh header to say exactly what the sandbox proves and does not.
    • Why here: Block E, decided before the ledger, status files and Privacy pane text are written (open question 1): it moves every AIrail-written path into the container, adds a Copilot Keychain consent, and each future provider (JetBrains, Gemini, extra Claude homes) needs its own exception. Flip it in the Developer ID release so the re-prompt, migration and README land in one version bump.
  • Honest README and scripted demo (flags, script and copy landed; run the script and swap the table by hand — see TODO.md) · 2–4 days · wow 3/5 · honest-readme-captures-demo --demo= and --expanded flags let scripts/screenshots.sh re-shoot real captures each release; README leads with 'Why the edge'.

    • How: LaunchOptions: --expanded (expand the active controller inside the existing 1.5 s --overlay block) and --demo= (raise the highest demoProfile.sessionStart before building MockUsageEngine); force demo and placement with open -n … --args -connectedAccounts '()' -railPosition notch, which persists nothing. scripts/screenshots.sh builds Debug, quits the running instance, shoots fixed regions with screencapture -x -R on the MacBook display, relaunches /Applications/AIrail; delete renders/. README: real hover+HUD hero captioned live or demo honestly, the CodexBar table replaced by five 'Why the edge' paragraphs, the card description updated, one badge row. The 20 s clip is shot by hand and attached from a public-repo comment.
    • Why here: Block E: HN will spot renders, and text and pictures currently contradict each other ('7-day sparkline'). Shoot after the morph and hairline-only island so it is done once; the capture-hiding dependency is gone with that cut.
  • Gate-then-launch: DMG, tap, HN (release.sh notarize/tap steps, FUNDING.yml and README landed; the rest waits for the Developer ID) · 2–4 days · wow 2/5 · gate-then-launch Developer ID → notarized self-explaining DMG → Homebrew tap written by release.sh → public repo → one Show HN, X and Product Hunt.

    • How: release.sh: ENABLE_HARDENED_RUNTIME=YES OTHER_CODE_SIGN_FLAGS=--timestamp, notarize and staple the .app (ditto → notarytool submit --wait → stapler staple), create-dmg with the built AppIcon.icns, one Retina background, ULFO, then notarize the DMG and print the sha256 and gh release create line. Separate homebrew-tap repo with Casks/airail.rb (livecheck github_latest, depends_on sonoma, uninstall quit, zap the three real paths) bumped by sed when AIRAIL_TAP is set. README Install shrinks to two lines; FUNDING.yml. Then one launch of the version whose numbers are verified live: Show HN Tue–Thu 13:00 UTC with the pre-written first comment, X same hour, Product Hunt the following Tuesday, one reply each in claude-code #30341 and codex #19869/#32195.
    • Why here: Block E, last: the single biggest competitive gap is being private, un-notarized and absent from Homebrew. Prerequisites are external (Developer Program membership, notarytool profile, create-dmg) plus the OpenRouter key verification or an 'Other…' beta label. Switching identity re-prompts the Claude Keychain once; say so in the notes.

Tier 2 — valuable, each waiting on one prerequisite (17 items)

Prerequisites: the Developer ID, the usage ledger, the menu consolidation, the sandbox decision, a real fixture from hardware not on this Mac, or a device check. Named in each item.

  • Keyboard-driven HUD · hours · wow 2/5 · keyboard-driven-hud With the card open, arrows change account, ⌘1–9 jump, ⌘R refreshes, ⌘, opens Settings; no swipe, no pinned account.

    • How: OverlayPanel gains onCommand: (OverlayCommand) -> Void: keyDown maps 123/126 to .previous and 124/125 to .next (both axes), performKeyEquivalent maps ⌘1–⌘9 to .jump only after super returns false. OverlayWindowController resolves against manager.railProviderInfos (clamped), writes ui.selectedProviderId, re-runs layout(). In the overlay's ⋯ menu only, add Button('Refresh') with .keyboardShortcut('r') disabled while refreshing, and .keyboardShortcut(',') on Settings…. Cut swipe, push transition, focusable picker and the pin.
    • Why here: Prerequisite: the AIrailMenuItems consolidation from about-report-diagnostics so Refresh is not a fourth menu copy. Peek-hotkey's optional ←/→ becomes this item's job.
  • Focus filter for the rail · 2–4 days · wow 4/5 · focus-filter-rail AIrail appears under System Settings › Focus as a filter: pick which accounts sit on the rail and quiet alerts per Focus.

    • How: Sources/AIrail/Intents/RailFocusFilter.swift: RailFocusFilter: SetFocusFilterIntent with exactly two parameters, accounts: [AccountEntity]? and hideRail: Bool = false, whose @MainActor perform() writes a transient AppSettings.focusOverride (nil when both default, which is what deactivation sends) and whose appContext returns FocusFilterAppContext(notificationFilterPredicate:) once UsageNotifier sets content.filterCriteria = providerId. railProviderInfos intersects with the override (empty treated as nil); applyPosition() gains a hidesRail branch; register settings and providerManager with AppDependencyManager first thing at launch; run RailFocusFilter.current at launch. One RailPane footer line is the only UI. The entity carries only provider id and name (Focus configs sync via iCloud).
    • Why here: Prerequisite: a day-one on-device check that an accessory-policy app appears under Focus Filters (no local precedent); if not, an App Intents extension target is needed. Reuses AccountEntity from the tier-1 Shortcuts item.
  • Battery-pane level line · 2–4 days · wow 4/5 · battery-level-line A thin line traces session percent across the day above the token bars, each reset a cliff: the limit itself, charted.

    • How: ProviderManager.recordSample also appends LevelSample { date, percent } from snapshot.sessionPercent only (never ringPercent), on ≥1 pt change or every 5 min, pruned to 24 h, recorded on connect, cleared on disconnect, persisted in the UsageStore file. Factor HourlyBars' time→x math into a shared HourlyAxis; stack a 44 pt LevelLine (1.5 pt provider line over a faint fill, 0/50/100 hairlines, segments broken at gaps > 30 min, dot on the newest sample, ≥2 samples) above HourlyBars under the existing hoverTracker; ChartCallout gains '62% of session' and an accessibilityValue. Shown only when sessionPercent != nil and never for demo. Tests for thinning, gaps, round-trip.
    • Why here: Prerequisite: the tier-1 usage ledger (put the samples in its per-provider file rather than a second levels.json) and a real visual pass on the hover callout, which has only ever been eyeballed in code.
  • Window exchange rate · 2–4 days · wow 3/5 · window-exchange-rate '≈ $18 of work like this left in the window (est.)': headroom in work, not minutes, from one snapshot's level ratio.

    • How: Drop the regression and any persistence: dollarsLeft = spentInWindow × (100 − percent) / percent, with spentInWindow = ModelPricing.estimate(detail.sessionWindow). TranscriptScanner.summary(days:hours:sessionStart:now:) keeps 5-minute buckets for the last 6 h and returns Summary.sessionWindow; ClaudeProvider passes resetsAt − sessionWindowLength; UsageDetail.sessionWindow. Pure UsageHeadroom.estimate(percent:spent:) in UsageInsights, nil unless .ok, session basis, percent ≥ 5, spent > 0, ≥ 15 min of activity, two significant figures. One insight line in sessionSection merged with burnRate, with a .help stating the rate; never in the island caption. Synthetic-aggregate test.
    • Why here: Prerequisites: pace-against-window's sessionWindowLength (so the start is not hard-coded), the formatter, and one afternoon logging percent vs spentInWindow across a real Claude window to confirm the ratio is stable. Codex only after its window semantics are verified live.
  • Pace alert from the projection · hours · wow 2/5 · personal-baseline-pace-alert One alert per window when the provider's own numbers project a limit before the reset, while the ring is still under 75%.

    • How: Add projection: UsageProjection? to UsageNotifier.consider and pass projection(for:) right after recordSample. Fire once per window (lastPaceWindow keyed on resetsAt, cleared by reset detection) when sessionPercent != nil, projection.resetsFirst == false, ringPercent < 75 and the sample buffer spans ≥ 10 min: 'At this pace Claude hits its session limit at 16:15 — before it resets at 17:00.' Session-window providers only; no 168-hour retention, no category. Unit test once-per-window through the injected deliver closure.
    • Why here: Prerequisites: the injectable UsageNotifier from single-flight-testable-refresh and the windowed threshold keys from notifications-done-right. The personal-baseline trigger itself is on the cut list (misfires on one in five working hours).
  • Plan fit on the account page · 2–4 days · wow 4/5 · plan-fit Settings › Accounts says whether Pro would have run out this week, whether a lower plan fits, and the month's API-equivalent vs list price (est.).

    • How: PlanCatalog (dated static table: providerId, plan id, label, monthly USD, multiplier) and PlanFit (pure rules → factual sentences) in UsageInsights; UsageSnapshot.planId set from a well-formed Claude rateLimitTier (ignoring default_claude_ai), Codex plan_type, Cursor membershipType, Copilot copilot_plan. On the .ok path upsert WindowRecord { resetsAt, peakPercent, apiValue, firstExceededAt } keyed by weeklyResetsAt (keep 12) into the ledger file. AccountDetailView adds a 'Plan fit' Section for tool accounts: 'This week' from the live snapshot with lower-bound math when the tier is ambiguous; 'Last 4 weeks' only with ≥ 3 windows and a trusted tier; 'API-equivalent' vs list price; footer naming the price date. No verbs, no links.
    • Why here: Prerequisites: the tier-1 usage ledger for the per-window peak store (build it there, not a second UserDefaults store) and a Keychain fixture carrying rateLimitTier. Two price ladders changed in the last three months, so the assumed price is printed in the row.
  • JetBrains AI, then Mistral and xAI · 2–4 days · wow 2/5 · more-providers JetBrains from its quota XML labelled 'cached'; Mistral and xAI as keyed Other… entries; Zed deferred, Windsurf cut.

    • How: JetBrainsProvider modelled on CursorProvider: enumerate ~/Library/Application Support/JetBrains/*/options/AIAssistantQuotaManager2.xml, newest mtime, XMLDocument → the two entity-encoded JSON option values → JetBrainsUsage.parse mapping current/maximum, nextRefill.next, tariff.duration; lastUpdated = file mtime; BrandIcons.jetbrains; demo profile; fixture; README row. Add UsageStatus.cached (pill, notice caveat) leaving the .ok guards so cached accounts never project or alert. Then two KeyedPlatform entries: Mistral (x-api-key Admin key, monthly category sums, spend cap) and xAI (Management key, validation endpoint derives the team id, prepaid balance from total.val/100, daily USD via authorizedPost).
    • Why here: Prerequisites: a real AIAssistantQuotaManager2.xml from a JetBrains user (none on this Mac), the keyed-parser verification gate before Mistral/xAI go beyond fixtures, and, under the sandbox, one more home-relative exception. Ship the ones with a real ring; five at once is the meter-farm move.
  • Gemini CLI and Antigravity · 2–4 days · wow 2/5 · gemini-antigravity Replace 'coming soon' with a Workspace-only Gemini CLI quota read, an honest discontinued message, and an Antigravity tile.

    • How: Rewrite GeminiProvider in place (id 'gemini', 'Gemini CLI', Workspace Standard/Enterprise only): parse ~/.gemini/oauth_creds.json (access_token, expiry_date, id_token → email), POST loadCodeAssist once per process; on ineligibleTiers reasonCode UNSUPPORTED_CLIENT throw non-transient ConnectionError.discontinued naming 18 Jun 2026 and Antigravity; else cache cloudaicompanionProject (fallback ~/.gemini/.env) and call retrieveUserQuota, mapping buckets to UsageMeters with the ring from the tightest. Add AntigravityProvider: Keychain generic password service 'gemini' account 'antigravity' via KeychainReader.genericPassword(service:account:tool:), strip the go-keyring prefix, use only access_token and expiry, POST retrieveUserQuotaSummary mapping gemini-5h/weekly and 3p-5h/weekly. Never onboardUser, never the token endpoint.
    • Why here: Prerequisite: Antigravity and the agy CLI installed on Vincent's Mac to confirm the Keychain item and the summary body; until then the tile ships isSupported false with an honest explainer. The Workspace half is fixture-tested from gemini-cli's own types and flagged unverified.
  • Activity-aware adaptive refresh · 1 day · wow 2/5 · adaptive-refresh Read every minute while a transcript is moving, drowse to 5–15 min when idle or in Low Power Mode, one read right after each reset.

    • How: Replace the zero-tolerance Timer with a 30 s tick (tolerance 10 s) over per-provider nextDue computed by a pure RefreshCadence.interval(setting:lastActivity:lowPower:now:): 60 s while UsageSnapshot.lastActivityAt (from Summary.newestEventDate — re-added by used-elsewhere as a max over event.date in TranscriptScanner.ingest and surfaced as UsageDetail.newestLocalEvent, since block A deleted the field — and Cursor events.last) is under 5 min old, 5 min idle, 15 min after an hour, doubled in Low Power Mode, always pulled forward to resetsAt + 10 s. The tier-1 ActivityMonitor's FSEvents stream marks a provider active and requests a read if the last one is over 45 s old. GeneralPane's Refresh picker gains 'Automatically' (stored 0, default when the key is absent). Cut per-account cadence, 'Next read' row, thermal and battery detection.
    • Why here: Prerequisites: the single-flight guard and the ActivityMonitor from agent-now-playing (one FSEvents wrapper, not two). Mail's 'Check for new messages: Automatically' is the precedent.
  • Work and personal accounts · 2–4 days · wow 3/5 · work-personal-accounts Two Claude or Codex marks from sign-ins the tools already keep separately, discovered read-only, never switched.

    • How: ClaudeProvider and CodexProvider take init(home: URL); ids become 'claude#'/'codex#' for non-default homes; Claude derives its Keychain service name from keychainServices and its scanner root from home/projects, Codex reads home/auth.json and home/sessions; both fill UsageSnapshot.account (Claude from .claude.json oauthAccount.emailAddress). makeProviders() becomes discovery: default homes plus /.claude*//.codex* dirs with markers plus AppSettings.extraHomes. AddAccountSheet shows one tile per instance with the email subtitle and an 'Another Claude Code or Codex folder…' NSOpenPanel row (showsHiddenFiles). Email domain shown in captions only when a provider appears twice. Tests for hash, id and discovery over a temp tree.
    • Why here: Prerequisites: hashed-claude-keychain-name (tier 1), registry-derived provider ids (one-file-providers), a real second CLAUDE_CONFIG_DIR and CODEX_HOME sign-in on Vincent's Mac to confirm the hashed name lands, and the stable signing identity so two items are not two prompts per rebuild. Under the sandbox, chosen folders need the user-selected read-only entitlement.
  • One-file keyed platforms · hours · wow 1/5 · one-file-providers A new keyed platform is one KeyedPlatform entry plus a fixture row; ids derive from the registry; an issue form demands a documented API.

    • How: Delete AppSettings.toolProviderIds/platformProviderIds/allProviderIds (unused in Sources); rewrite testProviderRegistryIdsUnique to assert uniqueness and that apiKey providers equal KeyedPlatform.catalog, no '9' literal; add testEveryKeyedPlatformHasAFixture walking the catalog against a test-local fixture table; loop testKeyedPlatformsLiveRead over the catalog with AIRAIL__KEY; iterate brand icon paths from makeProviders(). Point AddAccountSheet.requestURL at issues/new?template=provider-request.yml; add that form (documented endpoint, key kind, sample response, 'no cookies, no scraping' checkbox), config.yml, a six-line PR template, a README paragraph. Separately make allProviderInfos @Published and rebuilt by refreshInstallState() on the sheet's onAppear.
    • Why here: Prerequisites: go-public for the issue form and PR template to matter, and the keyed-parser verification so contributors copy a proven pattern. The ToolPlatform abstraction half is cut (zero members).
  • Stable display identity · 1 day · wow 1/5 · stable-display-identity Key the chosen display by the window server's display UUID, debounce screen changes with a 1 s settle, and hide the rail when no screen resolves.

    • How: DisplayKey { uuid, vendor, model, serial, name } in ScreenSelection.swift built from CGDisplayCreateUUIDFromDisplayID (ColorSync, public, not deprecated) plus CGDisplayVendor/Model/SerialNumber, with a pure match(among:) resolving uuid → vendor+model+non-zero serial → localizedName → same arrangement side; stored JSON as railDisplayKey with a migration from railDisplay; RailPane Picker tags DisplayKey?. AppDelegate replaces applyPosition(resettling:) with one cancel-and-restart Task (1 s trailing settle) driven by didChangeScreenParameters plus NSWorkspace.shared.notificationCenter's didWake/screensDidWake; user-driven changes apply immediately. RailWindowController.frame(expanded:) returns NSRect? and orders out on nil. Tests for match and migration.
    • Why here: Prerequisite: the Dell + ultrawide + MacBook desk attached (only the built-in is present today) to read real external UUIDs and reproduce the wake flap. The debounce/orderOut sliver alone is a couple of hours and can go first.
  • Cross-tool recap card, redacted · 2–4 days · wow 4/5 · recap-card-redacted 'Share This Week…' renders one solid-painted image of every tool's rolling week, no projects, no emails, via ShareLink.

    • How: Views/RecapCard.swift: RecapCard.Row (colour, brand path, name, period + percent, seven daily totals from detail.days, top-3 models, per-row ModelPricing.estimate, status + lastUpdated) built by rows(from:) that admits only .ok/.stale snapshots and structurally never receives account, plan or byProject; a pure 1200×675 view painted Color(white: 0.12) with LogoMark(onDark: true), Sparkline, MetersList for history-less rows, share bars, a periwinkle hairline and a footer 'AIrail · 30 Aug – 5 Sep · est.' (stale rows say 'as of HH:mm'). ImageRenderer(scale: 2, isOpaque: true) → NSImage → ShareLink in AIrailMenuItems, disabled when no live account exists; NSSharingServicePicker fallback with a custom 'Copy Image' service if ShareLink misbehaves on the non-activating panel. Two hosted tests.
    • Why here: Prerequisites per the tier-1 gate: notarized public release (a share card for an app nobody can download is pointless), the AIrailMenuItems extraction, and the usage ledger so 'week' can become a calendar week rather than a rolling sum. Candidate first Pro-labelled feature (monetization).
  • Soft wall: usage credits and Codex credits · 1 day · wow 2/5 · critic-3-soft-wall-extra-usage-and-credits When Claude usage credits or Codex credits are on, the hairline stops at amber and the card states the fact; never 'takes over'.

    • How: UsageOverflow { usageCredits(spent, cap?), codexCredits(balance?, unlimited) } as a defaulted overflow on UsageSnapshot, set from report.extraSpend/extraCap and from has_credits or the ignored unlimited. UsageSeverity becomes Comparable with of(_ snapshot:) capping at .warning when the overflow has room; both railAccent sites take the max severity. UsageNotifier appends one factual sentence to the 75/90% body ('Usage credits on — $12.40 of $50 this month'); OverlayView recaptions the footer 'USAGE CREDITS (SEP)', shows one conditional subheadline at warning or above, and words burnRate 'to plan limit'. Extend the Claude and Codex parse tests plus one severity test.
    • Why here: Prerequisites: the spend-footer fix from dont-build-caps (same footer) and a one-off live dump of extra_usage from an account with usage credits ON, since Vincent's is off and is_enabled does not prove funds exist.
  • Hide with the menu bar in full screen · 1 day · wow 2/5 · critic-8-hide-with-the-menu-bar-in-full-screen When the rail's display shows a full-screen app the hairline steps out of the way like the menu bar and returns when the pointer rests on the edge.

    • How: Windows/FullScreenWatcher.swift observing activeSpaceDidChange and didActivateApplication (plus the applyPosition resettle), calling CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements]) and a pure, tested menuBarHidden(on:safeTop:windows:) that is true only when no Window Server layer-24 window sits at the top of that display and a layer-0 window covers its width and at least frame.height minus the menu-bar band (robust on the notch, immune to maximized windows, follows the user's own Control Center setting). Each controller gains setEtiquetteHidden(_:) fading panel.alphaValue (instant under reduce motion); HoverTrackingView starts a 300 ms rest before restoring and expanding; RailUIState.isEtiquetteHidden pauses the hairline. AppDelegate closes the overlay on any space change. One footer sentence.
    • Why here: Prerequisite: the Core Animation hairline (pause means removing the layer animation, not a TimelineView flag) and a device check that the tracking area still fires at alpha 0. Pairs with the pinned rail, which otherwise sits over full-screen apps.
  • Tear-off HUD · 1 day · wow 3/5 · critic-9-tear-off-hud Drag the card away from the rail and it becomes a small floating panel that stays while you work; close it with its own button or Escape.

    • How: A ~25-line NSViewRepresentable over the header's mark+name only: mouseDown stores the event, mouseDragged past ~4 pt calls OverlayWindowController.detach() then window.performDrag(with:). detach() sets isMovable = true (otherwise server-side drag is a no-op), level .floating, removes the global click monitor, sets RailUIState.isOverlayDetached and fires onClose once so the rail tucks away; while detached, layout() keeps the top-left and clamps to the screen (host.sizingOptions = .preferredContentSize), toggle swaps content in place, screen-change handlers re-clamp instead of close. isOverlayOpen returns visible && !detached. A 22 pt xmark.circle.fill shown only when detached, plus Escape while key, calls close(), which resets everything.
    • Why here: Prerequisite: desk-mode-both's OverlayAnchor/owner model, so detached state and anchoring are designed once rather than patched twice.
  • Desktop widget · 2–4 days · wow 2/5 · desktop-widget One WidgetKit systemSmall ring on the desktop or Notification Center, fed numbers-only through an App Group.

    • How: One macOS-only, sandboxed AIrailWidget extension with a single AppIntentConfiguration widget whose parameter is an AccountEntity (choices from the feed): brand mark in the ring, name, percent, Text(resetsAt, style: .relative), 'as of 14:02'. Sources/Shared/WidgetFeed.swift compiled into both targets: Codable WidgetSnapshot (no account, tokens or detail) written by ProviderManager.publishWidgetFeed() on every snapshots mutation, skipping demo, then reloadTimelines only when rounded percent, status or resetsAt changed. Move SVGPathShape into Shared; WidgetRing with solid fills honouring widgetRenderingMode; provider policy .never, labelled stale when lastUpdated exceeds twice the interval. Round-trip test that email never serializes.
    • Why here: Hard prerequisite: an Apple Developer ID / Team ID, because a sandboxed extension's group container must be Team-ID-prefixed on macOS 15+ (CodexBar issue #533) and it cannot even be tested on this macOS 26 Mac without one. Table-stakes, one widget kind only.

Tier 3 — later or experimental (3 items)

  • Local models as failover · 1 day · wow 3/5 · local-models-failover The 90% alert body names the Ollama or LM Studio model ready right now; no ring, no rail mark, no invented percent.

    • How: Providers/Support/LocalModels.swift: static func hint() async -> String? that, only when a runtime is detected (~/.ollama or ollama on PATH; LM Studio bundle id present), GETs 127.0.0.1:11434/api/tags + /api/ps and 127.0.0.1:1234/api/v0/models through HTTPClient.get(timeout: 1) (loopback added to the allowlist), parses in pure functions, and returns one clause ('Ollama is running with qwen3-coder loaded/ready'; LM Studio only when state == loaded), any error meaning absent. UsageNotifier.consider appends it to the 90% body only when no connected account has headroom, inside a Task. Two fixture tests, one README sentence.
    • Why here: Prerequisites: the failover nudge (now part of ambient-headroom's HeadroomSummary.roomId) as the branch it extends, and a Mac with Ollama and LM Studio to capture real fixtures and confirm the 'loaded' state value; neither is on this Mac.
  • String Catalog, English only · 1 day · wow 1/5 · critic-10-string-catalog-english-only Every user-facing string goes through Localizable.xcstrings so a translation is a one-file pull request, and no label is a capitalised rawValue.

    • How: Add an empty Sources/AIrail/Localizable.xcstrings (the synchronized group and the existing LOCALIZATION_PREFERS_STRING_CATALOGS setting populate it). Convert plain-String copy to String(localized:): ConnectionError descriptions, UsageNotifier bodies (drop capitalizedFirst), UpdateChecker alert text, the nine ConnectionMethod sets and keyKind, the composed AccountsSettingsView strings and RailView.helpText; replace RailPosition.label, UsageStatus.label and ChartRange.label with exhaustive switches; sectionCaption and UsageBreakdown.column take LocalizedStringKey with .textCase(.uppercase). Server-derived names wrapped in Text(verbatim:). No in-app language setting, no generated symbols.
    • Why here: Prerequisite: the locale-formatting pass first, since it deletes several UsageFormatting strings this would otherwise catalogue and orphan. Payoff only once the repo is public and someone can send a translation.
  • Presentation-aware redaction while mirrored · hours · wow 3/5 · presentation-aware-demo-face When a display is mirrored to a projector the rail redacts account and project names by itself and restores them when mirroring ends.

    • How: ScreenSelection.anyDisplayMirrored (~10 lines: CGGetOnlineDisplayList + CGDisplayIsInMirrorSet) and a @Published isMirroring on RailUIState set inside AppDelegate.applyPosition(), which already runs at launch, on didChangeScreenParameters and after the resettle. A single derived Bool ui.isRedacted applied as .redacted(reason: .privacy) at the roots of OverlayView, ExpandedRailContent and the island caption, with privacySensitive() on snapshot.account, the BY PROJECT column, the plan pill and the value line; rings and percentages stay real. One RailPane Toggle, default on, worded like Apple's 'Allow notifications when mirroring' setting. Camera-on trigger cut. Verify once with a real AirPlay mirror.
    • Why here: Decision 2 (pick 3) makes this the vehicle for the names-only Bool; rings stay real, see TODO.md. The mirror-detection sliver itself is sound.

Cut, with the skeptic's blocker

  • Cache and reasoning mix ('≈ $41 saved by prompt caching') — Prices a counterfactual no Claude Code user can pay (computes to ≈ $3,706 'saved' against a $626 value line on this Mac) and the restart count swings 8x with the rule; four numbers for facts the user cannot change.
  • Cost per commit, per project — Counting commits under Documents/Desktop needs a Files & Folders TCC prompt fired from a refresh timer, widens the trust footprint to every repo's commit log, and divides an estimate by a proxy.
  • Hide from screen sharing switch (NSWindow.sharingType = .none), previously judged tier 1 — Apple documents .none as legacy and says not to use it to hide content; ScreenCaptureKit ignores it on macOS 15.4+, so a labelled switch would silently fail in Zoom, Meet and QuickTime on the macOS most users run, a false sense of privacy.
  • Demo face / privacy redaction mode, previously judged tier 1 — Redaction affects Text and Image only; the rings, share bars and chart bars are Shapes that keep drawing the real percentages, so a redacted card looks broken, not private, and the collapsed surfaces already expose only names and percents.
  • Recap card now (before go-public) — An estimate on a poster with four brand marks is trademark and trust exposure with no verified identity behind the release; ImageRenderer cannot render the HUD's glass or AppKit-backed controls anyway. Returns in tier 2 behind notarized release plus the ledger.
  • Soft caps, month-end bill projection, idle-subscription flag, previously judged tier 2 — No cap is reachable through any read-only endpoint AIrail uses, so an AIrail-set cap would disagree with the platform's; both need a per-account preferences container and history that do not exist. Only the spend-footer relabel ships.
  • Personal-baseline pace trigger (3x your typical hour) — On this Mac's real week it fires on 8 of 42 active hours; agentic bursts are the normal mode, so 'your typical hour' is not a stable baseline. Replaced by a projection-based pace alert.
  • Windsurf provider — Its state.vscdb cache only refreshes when Windsurf launches, so a daily quota ring would be hours wrong even labelled 'cached'; the only live path is Chromium localStorage plus Full Disk Access, which is cookie scraping.
  • Zed provider — Every paid Zed plan has unlimited edit predictions and the monthly credit is not in the response, so a Keychain prompt would buy a mark with no ring for paying users. Deferred, not blocked.
  • Codex session-log fallback for a drifted endpoint — A second unofficial parser defending the first (its shape already moved between June and August), producing a 'stale but 3 minutes old' ring that muddies the status vocabulary; 18 days stale on this Mac today.
  • 30 Days chart segment — Claude's default 30-day transcript retention makes the left edge of a 30-day chart an artefact; Copilot has no history source, Cursor's backfill can exceed its page cap, and the Battery pane stops at 10 days.
  • Compiled airail helper before notarization — An un-notarized nested binary keeps its quarantine xattr and can raise 'developer cannot be verified' from a status line Claude Code execs every 300 ms. Waits for the Developer ID and a Homebrew binary stanza.
  • Data-protection Keychain for AIrail's own keys — Fails with errSecMissingEntitlement without a Team ID, delivers nothing a stable signing identity does not already deliver, and its migration read triggers the very prompt it claims to avoid.
  • Trackpad swipe, push transition and pinned account in the HUD — No Mac precedent for swipe inside a HUD (fights the chart's hover callout), the transition briefly holds two heights, and a pin adds a Defaults key plus a concept that digits and List.onMove already cover.
  • Team pulse, haptics, Ollama ring, $2 Guard tier (already assessed) — Contradict no-cloud, only fire during trackpad interaction, have no limit to ring, and gate free features behind a forkable paywall respectively; unchanged from the earlier assessment.

Charging for it

Do not charge $2. Do not charge monthly. Charge $12 once, softly, after the launch.

The market price of a meter is zero (CodexBar free/MIT, ~21k stars). Paid outliers in this exact category: Usagebar $9, AIUsageBar Pro $9.99, Shottr-style $12, Vibe Island $15, QuotaMeter £4.99–8.99; SessionWatcher's $24/yr or $59 is the ceiling; tokenkarma is the only subscription and is cloud-based. Nobody charges for privacy features.

Recommendation, in order:

  1. Buy the Apple Developer ID this week regardless of pricing. Notarization, Sparkle, the Homebrew tap, a stable signing identity (fixes the Keychain re-prompt item) and any future Setapp listing all need it. Nothing paid is viable without it.
  2. Ship v0.3.0 free, MIT, notarized, public, with GitHub Sponsors (0% fee on personal sponsors, South Africa supported), FUNDING.yml, a "Support AIrail…" row in Settings › General, and a personal tap brew install --cask vincentj88-python/airail/airail (homebrew-cask proper needs 75 stars / 30 forks / 30 watchers for third-party submission).
  3. First release after launch: a $12 one-time "AIrail Licence" via Dodo Payments (account exists; merchant of record, so VAT/GST handled, no invoicing). Soft gate: after 30 used days (Mac Mouse Fix-style), one quiet "Buy AIrail…" line at the bottom of the consolidated menu; never a modal, never anything on the rail, never a feature switched off. "Licensed to" in Settings. Later, the key unlocks only net-new conveniences explicitly labelled Pro as they ship (candidates: recap card share, work/personal accounts; maybe the desktop widget). Never paid: anything in v0.2.0, anything in tier 1, any privacy feature, any data-model fix, the source, the notarized build, updates.

Mechanism: one Dodo one-time product with a licence-key entitlement (unlimited activations, no expiry), payment link, key emailed at checkout, no webhook, no server. In-app Providers/Support/LicenseManager.swift (@MainActor ObservableObject owned by AppDelegate) storing {key, instanceId, email, validatedAt} in KeychainStore (service "AIrail", account "license"); POST /licenses/activate with the Mac's name, weekly /licenses/validate with a 30-day offline grace, /licenses/deactivate on demand. These endpoints are public (no API key), so no secret ships. Add live.dodopayments.com to HTTPClient.allowedHosts, the README host table and the Privacy pane ledger. Settings › General gains an "AIrail Licence" section (Buy…, Enter Key…, Licensed to, Deactivate). Add a one-line trademark note (MIT does not grant the name/icon). Raise Dodo's payout threshold to $500–1,000; use a local ZAR transfer or Payoneer, not SWIFT ($25/wire; $5 fee on payouts under $1,000).

Price Nets after Dodo (4% + $0.40 US / 5.5% + $0.40 elsewhere) Fee share
$2 once $1.49 – $1.52 24 – 26%
$9 once $8.10 – $8.24 8 – 10%
$12 once $10.94 – $11.12 7 – 9%
$19 once $17.56 – $17.84 6 – 8%

Break-even vs the $99/yr Developer Program: nine licences. Base case year one after going public ≈ $1k; upside only if the free rail earns stars (Mac Mouse Fix ratio ≈ 3 sales per star, 33.7k sales at $2.99).

Licence note: MIT already permits selling the binary and keeping the licence check in public source; it also permits a fork removing it (Mac Mouse Fix lives with that). Do not relicense to source-available (Aseprite/BUSL/FSL/PolyForm), do not split a closed Pro package (AlDente-style forks follow), do not adopt a custom licence.

Options weighed: A free + Sponsors (baseline, do regardless) · B $12 soft licence (pick) · C open-core hard Pro $19 (no: every Pro candidate is free in CodexBar; a closed package breaks principle 5) · D pay-what-you-want DMG (no: URL leaks, cask needs a public download) · E $2 once (no: fee-eaten, reads as a toy) · F $2/month (no: rent, churn, easiest fork) · G sponsorware (no: prolongs the private state) · H Setapp (later: needs Developer ID + audience; usage-weighted share pays a background utility almost nothing).

Order of work under the no-Developer-ID constraint (2026-09-06)

Nothing in tier 1 blocks A, B or C needs the Developer ID. What does: notarization, Sparkle, the attested-provenance half of signing hygiene, the desktop widget, the Focus filter's communication entitlement, Setapp, and (by the research's own argument) charging money. So:

  1. Now — v0.3.0 code: block A (all ten), block B (all eleven), then the first three block C items (hairline-only island, Core Animation hairline, ambient headroom), then the ID-free block E items (drift fixtures, About/diagnostics, Privacy pane, honest README + scripted screenshots). Signing hygiene ships its hardened-runtime half now on the self-signed cert; attestation waits. Done 2026-09-07: main is at v0.3.0 with a draft release and the DMG attached; the Homebrew cask is written under dist/.
  2. Soft public, free: repo public, self-signed hardened DMG with honest "Open Anyway" steps for macOS 15+, GitHub Sponsors with a visible $99 goal: "notarized builds" (0% fee on personal sponsors, no Apple account needed), FUNDING.yml, a personal Homebrew tap (note --no-quarantine). Announce on X and the Claude/Codex/Cursor communities; hold the Show HN and Product Hunt for the notarized release.
  3. Developer ID from the first sponsor money (or own): notarize, staple, Sparkle, then the $12 soft licence via Dodo in the following release, then Show HN. The remaining block C (glint, announcements, morph), block D, the sandbox (decision 1 = later) and tier 2 follow.

Decisions — taken 2026-09-06

Vincent went with every pick below. One constraint added the same day: the Apple Developer ID waits until AIrail has earned the $99, so the order is re-sequenced (next section).

  1. Sandbox — (1) flip in the v0.3.0 Developer ID release, accept container paths; (2) stay unsandboxed, trust story = allowlist + hardened build + Privacy pane; (3) flip later once the tier-2 provider exception list is known. My pick: 3. Don't couple the launch to a kernel-policy migration.
  2. Demo face — (1) stand by the cut; (2) only a "Show project names" toggle on demand; (3) one names-only redaction Bool (email, plan, projects; rings real), mirroring can switch it on. My pick: 3.
  3. Notch and Island — (1) merge into one "Top" position now with a stored-value migration; (2) keep two, hairline-only idle for Island. My pick: 1.
  4. v0.2.1 hardened-runtime hotfix — (1) ship now with a re-created 10-year cert (re-prompt now and again at Developer ID); (2) hold for v0.3.0. My pick: 2. Only Vincent runs the current build.
  5. Pricing — (1) free + Sponsors only; (2) $12 soft licence, Pro labels later; (3) $12 with a Pro set from day one. My pick: 2, shipped in the first release after the launch, not in the launch.
  6. Pro labels — (1) recap card share + work/personal accounts; (2) those + desktop widget + plan fit; (3) none, support only. My pick: 1. Insights stay free.
  7. v0.3.0 scope — (1) blocks A, B, E; (2) also block C; (3) whatever is green when the Developer ID arrives. My pick: A, B, E plus the first three C items (hairline-only island, energy-honest hairline, ambient headroom).
  8. Usage ledger on disk — (1) yes, per-account JSON, 366-day prune, honest README sentence; (2) not yet. My pick: 1.
  9. Light appearance — (1) stay dark-only; (2) two-hour light HUD follow-up. My pick: 1.

Method and caveats

24 competitors researched (as of 2026-09-05). Ten lenses: Apple craft, insights, workflow, privacy, providers, power users, wow, devil's advocate, engineering quality, growth; monetization researched separately. One skeptical macOS engineer per idea checked every API claim (WebSearch/Apple docs), opened the files it touches, checked the six principles and wrote the refinement used as "How" above. The skeptic kept 95% of what reached it, so the tiering and the eight moments are an editorial cut. Effort figures assume building alone and are rough. Items whose first step is a device check: Focus filters for an accessory app, hidden-Dock detection, the Antigravity Keychain item, Setapp/Sparkle after the Developer ID.