diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7d72d2d0..9fd99ab7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,10 +9,6 @@ updates: cooldown: default-days: 7 groups: - ccusage-runtime: - patterns: - - ccusage - - '@ccusage/*' # react and react-dom assert at runtime that they resolve to the exact # same version, so they have to move in one PR. Bumping react alone # leaves react-dom behind in the lockfile and every desktop test dies @@ -39,5 +35,4 @@ updates: - version-update:semver-major labels: - dependencies - - ccusage open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51d434e6..98130858 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,11 +112,10 @@ jobs: pnpm test:corpus-contracts pnpm test:retrieval pnpm test:core-tools - - name: Prepare CLI, MCP, and ccusage companions + - name: Prepare CLI and MCP companions run: | pnpm core:prepare-mcp pnpm core:prepare-cli - pnpm core:prepare-ccusage pnpm core:qualify-cli - name: Rust core, CLI, and MCP contracts run: | diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 73c589ce..a8dfe6ba 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -51,6 +51,11 @@ separately approved safety design justify renewed investment. - v1.14.3 includes the post-v1.14.2 code-health and native qualification repairs. Subsequent documentation-only bookkeeping does not imply a newer desktop release; release truth remains the published `v1.14.3` tag. +- Unreleased source proposes retiring the general Usage workspace, provider + allowance checks, `codevetter usage`/`quota`, and the bundled `ccusage` + sidecar. ContextDaddy owns general agent usage. Per-verification token and + cost receipts and CodeVetter's History recovery remain. This proposal has + not been published in a desktop release. - Maintenance is limited to core verification reliability, regressions, security, dependency hygiene, and evidence requested by real users. New product surfaces require an explicit change in the investment decision. @@ -58,7 +63,8 @@ separately approved safety design justify renewed investment. ## Dependencies External: -- Bundled `ccusage` 20.0.20 sidecar — local, offline Claude/Codex/Grok usage accounting; exact updates are opened weekly and remain qualification-gated. +- Published v1.14.3 bundles `ccusage` 20.0.20 for local Claude/Codex/Grok usage + accounting; the unreleased source proposal above removes that sidecar. - Installed and authenticated Codex or Claude CLI for Work conversations; provider account policy remains external to CodeVetter. - GitHub Releases + GitHub Actions — `auto-release.yml` cuts a `v` release on native `Shared.xcconfig` version bumps; `release.yml` signs, notarizes, qualifies, and uploads the SwiftUI app, DMG, ZIP, and Sparkle `appcast.xml`. - Cloudflare Pages — hosts the landing page (`codevetter` project, codevetter.com). diff --git a/agents.md b/agents.md index 276e2271..39ed5b83 100644 --- a/agents.md +++ b/agents.md @@ -99,7 +99,7 @@ node scripts/check-docs.mjs # Validate docs (links, frontmatter, structure) - **Three synchronized surfaces**: SwiftUI, CLI, and MCP consume the same versioned Rust-owned receipt schemas. Do not fork business rules into the UI. - **DB is `rusqlite`, not `@tauri-apps/plugin-sql`.** Do not re-add `plugin-sql` (removed in the 2026-07-11 desloppification sweep). See `docs/architecture/data-model.md`. - **Single package manager: pnpm.** Do not reintroduce `package-lock.json` — dual-lockfile drift broke Cloudflare Pages in May 2026. See `docs/knowledge/failed-approaches.md`. -- **Nav (6 sections)**: Usage, Repo Unpack, Review, Testing, Performance, Settings. Full surface map in `docs/product/surfaces.md`. +- **Nav (6 sections)**: Explore, Review, Testing, Performance, Runs, Settings. Full surface map in `docs/product/surfaces.md`. - **GH Actions**: `ci.yml` (native + Rust + automation), `auto-release.yml` → `release.yml` (signed/notarized native app), `deploy-landing.yml`, `weekly.yml`, and `docs.yml`. See `docs/operations/`. - Husky pre-commit runs the staged secret scan when available; pre-push runs lint + secret scan. diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json index 1dd3fc4f..754c91fb 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json @@ -167,53 +167,6 @@ ], "next_step": "Refresh launch, settled RSS, responsiveness, energy, and long-session evidence on the exact current package with owner-approved foreground qualification." }, - { - "id": "evidence.local_usage", - "name": "Local agent usage", - "purpose": "Inspect local token, cache, cost, model, and session evidence without conflating it with cloud quota telemetry.", - "stage": "current", - "surfaces": { - "ui": { - "availability": "available", - "authority": "read", - "entrypoints": [ - "Native Usage (ccusage plus separate indexed Devin history)" - ] - }, - "cli": { - "availability": "available", - "authority": "read", - "entrypoints": [ - "codevetter usage" - ] - }, - "agent": { - "availability": "planned", - "authority": "none", - "entrypoints": [] - } - }, - "underlying_tools": [ - { - "name": "ccusage 20.0.20", - "role": "Normalizes offline Claude, Codex, and Grok local usage logs", - "requirement": "bundled pinned sidecar" - }, - { - "name": "CodeVetter Rust core", - "role": "Owns provider boundaries, ccusage normalization, separate SQLite Devin history, caching, and stale/unavailable states", - "requirement": "bundled" - } - ], - "data_boundary": "Local agent logs, optional read-only imported Codex roots, and indexed Devin sessions from the existing SQLite database; no provider credential or network access.", - "qualification": "partial", - "limitations": [ - "Indexed Devin sessions, generated/cache tokens, cost, and model rows follow 1w, 30d, 90d, and all-time windows through a separate Rust projection and are never included in ccusage totals.", - "Live provider quotas remain separate telemetry and are never inferred from local spend.", - "Native 1w, 30d, 90d, and all-time selection keeps ccusage chart, totals, models, and sessions aligned while the separate Devin desk follows the same selected window." - ], - "next_step": "Migrate live provider telemetry as a credential-safe separate projection, then expose the bounded report through scoped MCP." - }, { "id": "usage.history_roots", "name": "Additional Codex history roots", @@ -224,7 +177,7 @@ "availability": "available", "authority": "read_execute", "entrypoints": [ - "Native Usage settings" + "Native History settings" ] }, "cli": { @@ -252,10 +205,10 @@ "limitations": [ "The active CODEX_HOME remains automatic and is not duplicated in the additional-root receipt.", "A selected sessions or archived_sessions directory is normalized to its containing Codex home.", - "Reconciliation remains a separate explicit Usage action.", + "Root configuration does not run a transcript reconciliation or general usage scan.", "Agent and MCP surfaces receive no local history-root authority." ], - "next_step": "Keep local history-root mutation out of agent authority and preserve the bounded receipt as usage importers evolve." + "next_step": "Keep local history-root mutation out of agent authority and preserve the bounded archive receipt." }, { "id": "configuration.native_settings", @@ -441,7 +394,7 @@ "availability": "available", "authority": "read_execute", "entrypoints": [ - "Native Usage settings" + "Native History settings" ] }, "cli": { diff --git a/crates/codevetter-core/src/bin/codevetter.rs b/crates/codevetter-core/src/bin/codevetter.rs index 7396ce04..6453bdbf 100644 --- a/crates/codevetter-core/src/bin/codevetter.rs +++ b/crates/codevetter-core/src/bin/codevetter.rs @@ -24,7 +24,6 @@ use codevetter_core::commands::local_check::{ LocalCheckInput, LocalCheckPreflightReceipt, LocalCheckReceipt, LocalCheckStatus, LocalCheckTarget, LocalCheckVerdict, }; -use codevetter_core::commands::local_usage::{get_headless_local_usage_report, LocalUsageReport}; use codevetter_core::commands::mcp_access::{ run_mcp_settings_operation, McpSettingsOperation, McpSettingsReceipt, }; @@ -41,9 +40,6 @@ use codevetter_core::commands::performance_bridge::{ run_headless_performance, PerformanceAdapter, PerformanceOperation, PerformanceRunInput, PerformanceRunReceipt, }; -use codevetter_core::commands::provider_quota::{ - collect_provider_quotas, ProviderQuotaReceipt, ProviderQuotaSelection, -}; use codevetter_core::commands::qa_workspace::{ run_qa_workspace_headless, QaTargetPreset, QaWorkspaceMutation, QaWorkspaceReceipt, StoredQaWorkflow, @@ -115,8 +111,6 @@ Usage: codevetter watcher --operation [options] [--json] codevetter performance --operation [options] [--json] codevetter scope --consumer (--flow | --change | --codebase) [--repo ] [--json] - codevetter usage [--timezone ] [--refresh] [--json] - codevetter quota [--provider ] [--json] codevetter ops [--window-days <7|30|90>] [--json] codevetter unpack [--operation ] [--repo ] [--limit ] [--report-id ] [--json] codevetter qa --operation --repo [options] [--json] @@ -174,9 +168,6 @@ Options: --name Optional exact performance workload name --request-id Optional stable performance request id --subject-run-id Recorded performance run id for inspect - --timezone Usage reporting timezone (default: UTC) - --refresh Bypass the in-process usage cache - --provider Quota provider: all, claude, or codex (default: all) --window-days Ops aggregate window: 7, 30, or 90 (default: 30) --report-id Inspect one stored Repo Unpack snapshot --operation Repo Unpack operation: list, inspect, scan, compare, export, query, or internal query-worker @@ -294,19 +285,6 @@ struct ScopeArguments { output: OutputMode, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct UsageArguments { - timezone: Option, - refresh: bool, - output: OutputMode, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct QuotaArguments { - provider: ProviderQuotaSelection, - output: OutputMode, -} - #[derive(Debug, Clone, PartialEq, Eq)] struct OpsArguments { window_days: u32, @@ -537,8 +515,6 @@ enum CliCommand { Runs(RunsArguments), Performance(PerformanceArguments), Scope(ScopeArguments), - Usage(UsageArguments), - Quota(QuotaArguments), Ops(OpsArguments), Unpack(UnpackArguments), Settings(SettingsArguments), @@ -649,8 +625,6 @@ async fn run() -> Result { CliCommand::Runs(arguments) => run_runs(arguments), CliCommand::Performance(arguments) => run_performance(arguments).await, CliCommand::Scope(arguments) => run_scope(arguments).await, - CliCommand::Usage(arguments) => run_usage(arguments).await, - CliCommand::Quota(arguments) => run_quota(arguments), CliCommand::Ops(arguments) => run_ops(arguments), CliCommand::Unpack(arguments) => run_unpack(arguments), CliCommand::Settings(arguments) => run_settings(arguments), @@ -1608,53 +1582,6 @@ fn run_qa(arguments: QaArguments) -> Result { Ok(0) } -async fn run_usage(arguments: UsageArguments) -> Result { - let connection = open_read_only_app_database()?; - let report = get_headless_local_usage_report( - connection.as_ref(), - arguments.refresh, - arguments.timezone.as_deref(), - ) - .await?; - match arguments.output { - OutputMode::Json => println!( - "{}", - serde_json::to_string(&report) - .map_err(|error| format!("serialize local usage report: {error}"))? - ), - OutputMode::Human => print!("{}", render_human_usage(&report)), - } - Ok(match report.status.as_str() { - "ready" => 0, - "stale" => 1, - _ => 2, - }) -} - -fn run_quota(arguments: QuotaArguments) -> Result { - let receipt = collect_provider_quotas(arguments.provider); - match arguments.output { - OutputMode::Json => println!( - "{}", - serde_json::to_string(&receipt) - .map_err(|error| format!("serialize provider quota receipt: {error}"))? - ), - OutputMode::Human => print!("{}", render_human_quota(&receipt)), - } - let ready = receipt - .providers - .iter() - .filter(|provider| provider.status == "ready") - .count(); - Ok(if ready == receipt.providers.len() { - 0 - } else if ready > 0 { - 1 - } else { - 2 - }) -} - #[derive(serde::Serialize)] struct UnpackHistoryReceipt { schema_version: &'static str, @@ -2008,123 +1935,6 @@ fn render_human_runs(history: &RunHistoryReceipt) -> String { output } -fn render_human_usage(report: &LocalUsageReport) -> String { - let mut output = format!( - "Local usage · {} {} · {}\nstatus: {}{}\nagents: {}\ntokens: {} total · {} generated · {} cache read\ncost: ${:.2}\nperiods: {} daily · {} weekly · {} monthly · {} sessions\nsource: {}\n", - report.provenance.engine, - report.provenance.version, - report.provenance.timezone, - report.status, - if report.stale { " (stale)" } else { "" }, - if report.provenance.detected_agents.is_empty() { - "none".into() - } else { - report.provenance.detected_agents.join(", ") - }, - report.totals.total_tokens, - report.totals.generated_tokens(), - report.totals.cache_read_tokens, - report.totals.cost_usd, - report.daily.len(), - report.weekly.len(), - report.monthly.len(), - report.sessions.len(), - if report.provenance.source_fingerprint.is_empty() { - "unavailable" - } else { - &report.provenance.source_fingerprint - }, - ); - output.push_str("boundary: Claude, Codex, and Grok are accounted by ccusage; Devin and live provider quotas are separate.\n"); - if let Some(devin) = &report.devin { - output.push_str(&format!( - "devin: {} · {} sessions · {} generated · {} cache read · ${:.2} · all-time separate source\n", - devin.status, - devin.sessions, - devin.generated_tokens, - devin.cache_read_tokens, - devin.cost_usd, - )); - if !devin.windows.is_empty() { - output.push_str("devin windows:"); - for window in &devin.windows { - output.push_str(&format!( - " {} {} sessions / {} generated / ${:.2};", - window.window, window.sessions, window.generated_tokens, window.cost_usd - )); - } - output.push('\n'); - } - } - if let Some(error) = &report.error { - output.push_str(&format!("error [{}]: {}\n", error.category, error.message)); - } - if !report.provenance.excluded_agents.is_empty() { - output.push_str(&format!( - "excluded: {}\n", - report.provenance.excluded_agents.join(", ") - )); - } - output -} - -fn render_human_quota(receipt: &ProviderQuotaReceipt) -> String { - let mut output = format!( - "Provider quota · {}\nsource boundary: provider-reported limits; never inferred from local spend\n", - receipt.generated_at - ); - for provider in &receipt.providers { - output.push_str(&format!( - "{}: {}{} · {}\n", - provider.provider, - provider.status, - provider - .plan - .as_deref() - .map(|plan| format!(" ({plan})")) - .unwrap_or_default(), - provider.source - )); - for window in &provider.windows { - let reset = window - .reset_description - .as_deref() - .map(|value| format!(" · resets {value}")) - .or_else(|| { - window - .resets_at_unix - .map(|value| format!(" · resets at {value}")) - }) - .unwrap_or_default(); - output.push_str(&format!( - " {}: {:.0}% remaining · {:.0}% used{}\n", - window.label, window.remaining_percent, window.used_percent, reset - )); - } - if let Some(credits) = &provider.credits { - let amount = match (credits.used_amount, credits.limit_amount) { - (Some(used), Some(limit)) => format!(" · ${used:.2} / ${limit:.2} spent"), - _ => String::new(), - }; - output.push_str(&format!( - " credits: {}% remaining{}\n", - credits - .remaining_percent - .map(|value| format!("{value:.0}")) - .unwrap_or_else(|| "unknown".to_string()), - amount - )); - } - if let Some(count) = provider.reset_credits { - output.push_str(&format!(" full reset credits: {count}\n")); - } - if let Some(message) = &provider.message { - output.push_str(&format!(" {message}\n")); - } - } - output -} - fn render_human_unpack_history(receipt: &UnpackHistoryReceipt) -> String { if !receipt.database_available { return "No CodeVetter database is available. No stored Repo Unpack snapshots were changed.\n" @@ -2373,8 +2183,6 @@ fn parse_arguments( "runs" => return parse_runs(arguments), "performance" => return parse_performance(arguments, cwd), "scope" => return parse_scope(arguments, cwd), - "usage" => return parse_usage(arguments), - "quota" => return parse_quota(arguments), "ops" => return parse_ops(arguments), "unpack" => return parse_unpack(arguments), "settings" => return parse_settings(arguments), @@ -3145,43 +2953,6 @@ fn parse_retention(mut arguments: impl Iterator) -> Result) -> Result { - let mut timezone = None; - let mut refresh = false; - let mut output = OutputMode::Human; - while let Some(argument) = arguments.next() { - match argument.as_str() { - "--timezone" => timezone = Some(required_value(&mut arguments, "--timezone")?), - "--refresh" => refresh = true, - "--json" => output = OutputMode::Json, - "--help" | "-h" => return Ok(CliCommand::Help), - _ => return Err(format!("unknown usage argument `{argument}`")), - } - } - Ok(CliCommand::Usage(UsageArguments { - timezone, - refresh, - output, - })) -} - -fn parse_quota(mut arguments: impl Iterator) -> Result { - let mut provider = ProviderQuotaSelection::All; - let mut output = OutputMode::Human; - while let Some(argument) = arguments.next() { - match argument.as_str() { - "--provider" => { - provider = - ProviderQuotaSelection::parse(&required_value(&mut arguments, "--provider")?)?; - } - "--json" => output = OutputMode::Json, - "--help" | "-h" => return Ok(CliCommand::Help), - _ => return Err(format!("unknown quota argument `{argument}`")), - } - } - Ok(CliCommand::Quota(QuotaArguments { provider, output })) -} - fn parse_ops(mut arguments: impl Iterator) -> Result { let mut window_days = 30_u32; let mut output = OutputMode::Human; diff --git a/crates/codevetter-core/src/capabilities.rs b/crates/codevetter-core/src/capabilities.rs index b9e31426..2cac82b8 100644 --- a/crates/codevetter-core/src/capabilities.rs +++ b/crates/codevetter-core/src/capabilities.rs @@ -192,47 +192,6 @@ pub fn capability_registry() -> CapabilityRegistry { &["Results are workload-specific.", "A missing clean baseline blocks paired-improvement claims.", "Native intent/change/codebase scope resolution, digest-validated recorded-run inspection, the diagnosis-to-paired campaign handoff, and periodic owned-process-tree RSS/process evidence are available. Sampling can miss peaks between 75 ms observations.", "The shared evidence-scope fixture passes Rust, CLI, native, and read-only MCP projections; only UI and CLI retain workload-execution authority.", "Native release launch, steady app RSS, bridge latency, 1,000-event progress throughput, cancellation, worker crash recovery, and five large-receipt decode/render surfaces pass explicit gates."], "Refresh launch, settled RSS, responsiveness, energy, and long-session evidence on the exact current package with owner-approved foreground qualification.", ), - capability( - "evidence.local_usage", - "Local agent usage", - "Inspect local token, cache, cost, model, and session evidence without conflating it with cloud quota telemetry.", - CapabilityStage::Current, - surfaces( - projection( - Availability::Available, - Authority::Read, - &[ - "Native Usage (ccusage plus separate indexed Devin history)", - ], - ), - projection( - Availability::Available, - Authority::Read, - &["codevetter usage"], - ), - projection(Availability::Planned, Authority::None, &[]), - ), - &[ - tool( - "ccusage 20.0.20", - "Normalizes offline Claude, Codex, and Grok local usage logs", - "bundled pinned sidecar", - ), - tool( - "CodeVetter Rust core", - "Owns provider boundaries, ccusage normalization, separate SQLite Devin history, caching, and stale/unavailable states", - "bundled", - ), - ], - "Local agent logs, optional read-only imported Codex roots, and indexed Devin sessions from the existing SQLite database; no provider credential or network access.", - Qualification::Partial, - &[ - "Indexed Devin sessions, generated/cache tokens, cost, and model rows follow 1w, 30d, 90d, and all-time windows through a separate Rust projection and are never included in ccusage totals.", - "Live provider quotas remain separate telemetry and are never inferred from local spend.", - "Native 1w, 30d, 90d, and all-time selection keeps ccusage chart, totals, models, and sessions aligned while the separate Devin desk follows the same selected window.", - ], - "Migrate live provider telemetry as a credential-safe separate projection, then expose the bounded report through scoped MCP.", - ), capability( "usage.history_roots", "Additional Codex history roots", @@ -242,7 +201,7 @@ pub fn capability_registry() -> CapabilityRegistry { projection( Availability::Available, Authority::ReadExecute, - &["Native Usage settings"], + &["Native History settings"], ), projection( Availability::Available, @@ -261,10 +220,10 @@ pub fn capability_registry() -> CapabilityRegistry { &[ "The active CODEX_HOME remains automatic and is not duplicated in the additional-root receipt.", "A selected sessions or archived_sessions directory is normalized to its containing Codex home.", - "Reconciliation remains a separate explicit Usage action.", + "Root configuration does not run a transcript reconciliation or general usage scan.", "Agent and MCP surfaces receive no local history-root authority.", ], - "Keep local history-root mutation out of agent authority and preserve the bounded receipt as usage importers evolve.", + "Keep local history-root mutation out of agent authority and preserve the bounded archive receipt.", ), capability( "configuration.native_settings", @@ -409,7 +368,7 @@ pub fn capability_registry() -> CapabilityRegistry { projection( Availability::Available, Authority::ReadExecute, - &["Native Usage settings"], + &["Native History settings"], ), projection( Availability::Available, diff --git a/crates/codevetter-core/src/codevetter_cli_tests.rs b/crates/codevetter-core/src/codevetter_cli_tests.rs index 0126042e..0309491a 100644 --- a/crates/codevetter-core/src/codevetter_cli_tests.rs +++ b/crates/codevetter-core/src/codevetter_cli_tests.rs @@ -739,149 +739,12 @@ fn runs_parser_and_human_output_are_bounded() { } #[test] -fn usage_parser_and_human_output_preserve_provider_boundaries() { +fn general_agent_usage_commands_are_retired() { let cwd = Path::new("/tmp/widget"); - let CliCommand::Usage(arguments) = parse_arguments( - [ - "usage".into(), - "--timezone".into(), - "Asia/Kolkata".into(), - "--refresh".into(), - "--json".into(), - ], - cwd, - ) - .expect("usage arguments") else { - panic!("expected usage"); - }; - assert_eq!(arguments.timezone.as_deref(), Some("Asia/Kolkata")); - assert!(arguments.refresh); - assert_eq!(arguments.output, OutputMode::Json); - assert!(parse_arguments(["usage".into(), "--unknown".into()], cwd).is_err()); - - let report: LocalUsageReport = serde_json::from_value(serde_json::json!({ - "status": "ready", - "stale": false, - "error": null, - "provenance": { - "engine": "ccusage", - "version": "20.0.20", - "generated_at": "2026-08-31T00:00:00Z", - "timezone": "Asia/Kolkata", - "window": "all", - "detected_agents": ["claude", "codex", "grok"], - "excluded_agents": ["devin"], - "codex_roots": ["/tmp/codex"], - "source_fingerprint": "sha256:fixture", - "pricing_complete": true, - "fallback_models": [], - "unpriced_models": [] - }, - "daily": [], - "weekly": [], - "monthly": [], - "sessions": [], - "totals": { - "input_tokens": 100, - "cache_creation_tokens": 20, - "cache_read_tokens": 300, - "output_tokens": 40, - "total_tokens": 460, - "cost_usd": 1.25 - }, - "devin": { - "status": "ready", - "source": "CodeVetter SQLite", - "sessions": 3, - "generated_tokens": 1200, - "cache_read_tokens": 400, - "output_tokens": 100, - "cost_usd": 0.52, - "models": [], - "windows": [{ - "window": "1w", - "since": "2026-08-26", - "sessions": 2, - "generated_tokens": 800, - "cache_read_tokens": 250, - "cost_usd": 0.31, - "models": [] - }], - "limitations": ["Devin remains separate from ccusage totals."] - } - })) - .expect("usage fixture"); - let output = render_human_usage(&report); - assert!(output.contains("Local usage · ccusage 20.0.20 · Asia/Kolkata")); - assert!(output.contains("tokens: 460 total · 160 generated · 300 cache read")); - assert!(output.contains("Devin and live provider quotas are separate")); - assert!(output.contains("devin windows: 1w 2 sessions / 800 generated / $0.31")); - assert!(output.contains("excluded: devin")); -} - -#[test] -fn quota_parser_and_human_output_preserve_remaining_and_unavailable_states() { - let cwd = Path::new("/tmp/widget"); - let CliCommand::Quota(arguments) = parse_arguments( - [ - "quota".into(), - "--provider".into(), - "codex".into(), - "--json".into(), - ], - cwd, - ) - .expect("quota arguments") else { - panic!("expected quota"); - }; - assert_eq!(arguments.provider, ProviderQuotaSelection::Codex); - assert_eq!(arguments.output, OutputMode::Json); - assert!(parse_arguments(["quota".into(), "--provider".into(), "unknown".into()], cwd).is_err()); - - let receipt = ProviderQuotaReceipt { - schema_version: "codevetter.provider-quota/v1".into(), - generated_at: "2026-09-03T00:00:00Z".into(), - providers: vec![ - codevetter_core::commands::provider_quota::ProviderQuotaStatus { - provider: "codex".into(), - status: "ready".into(), - source: "codex app-server account/rateLimits/read".into(), - checked_at: "2026-09-03T00:00:00Z".into(), - plan: Some("pro".into()), - windows: vec![ - codevetter_core::commands::provider_quota::ProviderQuotaWindow { - id: "codex.primary".into(), - label: "Weekly window".into(), - used_percent: 92.0, - remaining_percent: 8.0, - window_duration_minutes: Some(10_080), - resets_at_unix: Some(1_788_750_854), - reset_description: None, - }, - ], - credits: None, - reset_credits: Some(1), - message: None, - }, - codevetter_core::commands::provider_quota::ProviderQuotaStatus { - provider: "claude".into(), - status: "unavailable".into(), - source: "Claude Code /usage".into(), - checked_at: "2026-09-03T00:00:00Z".into(), - plan: None, - windows: vec![], - credits: None, - reset_credits: None, - message: Some("Open Claude Code and run /usage.".into()), - }, - ], - limitations: vec![], - }; - let output = render_human_quota(&receipt); - assert!(output.contains("Weekly window: 8% remaining")); - assert!(output.contains("full reset credits: 1")); - assert!(output.contains("claude: unavailable")); - assert!(!output.contains("0% remaining\n Open Claude")); + assert!(parse_arguments(["usage".into()], cwd).is_err()); + assert!(parse_arguments(["quota".into()], cwd).is_err()); + assert!(!HELP.contains("codevetter usage")); + assert!(!HELP.contains("codevetter quota")); } #[test] diff --git a/crates/codevetter-core/src/commands/local_usage.rs b/crates/codevetter-core/src/commands/local_usage.rs deleted file mode 100644 index 8c71b268..00000000 --- a/crates/codevetter-core/src/commands/local_usage.rs +++ /dev/null @@ -1,1449 +0,0 @@ -use crate::db::queries; -use crate::DbState; -use chrono::{Duration as ChronoDuration, Local, NaiveDate, Utc}; -use rusqlite::Connection; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::{Arc, OnceLock}; -use std::time::{Duration, Instant}; -use tauri::State; -use tokio::io::{AsyncRead, AsyncReadExt}; -use tokio::process::Command; -use tokio::sync::Mutex; - -#[path = "local_usage_projects.rs"] -mod projects; -pub use projects::LocalUsageProject; - -const CCUSAGE_VERSION: &str = "20.0.20"; -const CACHE_TTL: Duration = Duration::from_secs(30); -const EXECUTION_TIMEOUT: Duration = Duration::from_secs(30); -const MAX_STDOUT_BYTES: usize = 32 * 1024 * 1024; -const MAX_STDERR_BYTES: usize = 1024 * 1024; -const ACCOUNTED_AGENTS: [&str; 3] = ["claude", "codex", "grok"]; - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] -pub struct LocalUsageTotals { - pub input_tokens: u64, - pub cache_creation_tokens: u64, - pub cache_read_tokens: u64, - pub output_tokens: u64, - pub total_tokens: u64, - pub cost_usd: f64, -} - -impl LocalUsageTotals { - pub fn generated_tokens(&self) -> u64 { - self.input_tokens - .saturating_add(self.cache_creation_tokens) - .saturating_add(self.output_tokens) - } - - fn checked_add(&self, other: &Self) -> Result { - Ok(Self { - input_tokens: self - .input_tokens - .checked_add(other.input_tokens) - .ok_or_else(|| "ccusage input token total overflowed".to_string())?, - cache_creation_tokens: self - .cache_creation_tokens - .checked_add(other.cache_creation_tokens) - .ok_or_else(|| "ccusage cache creation total overflowed".to_string())?, - cache_read_tokens: self - .cache_read_tokens - .checked_add(other.cache_read_tokens) - .ok_or_else(|| "ccusage cache read total overflowed".to_string())?, - output_tokens: self - .output_tokens - .checked_add(other.output_tokens) - .ok_or_else(|| "ccusage output token total overflowed".to_string())?, - total_tokens: self - .total_tokens - .checked_add(other.total_tokens) - .ok_or_else(|| "ccusage token total overflowed".to_string())?, - cost_usd: self.cost_usd + other.cost_usd, - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageModel { - pub model: String, - pub totals: LocalUsageTotals, - pub fallback: bool, - pub priced: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageAgent { - pub agent: String, - pub totals: LocalUsageTotals, - pub models: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsagePeriod { - pub period: String, - pub totals: LocalUsageTotals, - pub agents: Vec, - pub models: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub projects: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageSession { - pub session_id: String, - pub agent: String, - pub last_activity: Option, - /// Working directory recovered from the agent's own session storage. - /// `None` means unsupported or unreconciled; render as Unattributed. - #[serde(default)] - pub project: Option, - pub reasoning_output_tokens: u64, - pub totals: LocalUsageTotals, - pub models: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageProvenance { - pub engine: String, - pub version: String, - pub generated_at: String, - pub timezone: String, - pub window: String, - pub detected_agents: Vec, - pub excluded_agents: Vec, - pub codex_roots: Vec, - pub source_fingerprint: String, - pub pricing_complete: bool, - pub fallback_models: Vec, - pub unpriced_models: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageFailure { - pub category: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DevinUsageModel { - pub model: String, - pub sessions: i64, - pub generated_tokens: i64, - pub cache_read_tokens: i64, - pub cost_usd: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DevinUsageWindow { - pub window: String, - pub since: Option, - pub sessions: i64, - pub generated_tokens: i64, - pub cache_read_tokens: i64, - pub cost_usd: f64, - pub models: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DevinUsageSummary { - pub status: String, - pub source: String, - pub sessions: i64, - pub generated_tokens: i64, - pub cache_read_tokens: i64, - pub output_tokens: i64, - pub cost_usd: f64, - pub models: Vec, - #[serde(default)] - pub windows: Vec, - pub limitations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageReport { - pub status: String, - pub stale: bool, - pub error: Option, - pub provenance: LocalUsageProvenance, - pub daily: Vec, - pub weekly: Vec, - pub monthly: Vec, - pub sessions: Vec, - pub totals: LocalUsageTotals, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub devin: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawReport { - #[serde(default)] - daily: Vec, - #[serde(default)] - weekly: Vec, - #[serde(default)] - monthly: Vec, - #[serde(default, rename = "session")] - sessions: Vec, - #[serde(rename = "totals")] - _totals: RawTotals, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawPeriod { - #[serde(rename = "agent")] - _agent: String, - #[serde(default)] - agents: Vec, - #[serde(flatten)] - _totals: RawTotals, - #[serde(default)] - metadata: RawMetadata, - #[serde(default, rename = "modelBreakdowns")] - _model_breakdowns: Vec, - #[serde(default, rename = "modelsUsed")] - _models_used: Vec, - period: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawAgent { - agent: String, - #[serde(flatten)] - totals: RawTotals, - #[serde(default)] - model_breakdowns: Vec, - #[serde(default, rename = "modelsUsed")] - _models_used: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawSession { - agent: String, - #[serde(flatten)] - totals: RawTotals, - #[serde(default)] - metadata: RawMetadata, - #[serde(default)] - model_breakdowns: Vec, - #[serde(default, rename = "modelsUsed")] - _models_used: Vec, - period: String, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawMetadata { - #[serde(default)] - agents: Vec, - last_activity: Option, - #[serde(default)] - reasoning_output_tokens: u64, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawModel { - cache_creation_tokens: u64, - cache_read_tokens: u64, - cost: f64, - input_tokens: u64, - #[serde(default)] - is_fallback: bool, - model_name: String, - output_tokens: u64, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawTotals { - cache_creation_tokens: u64, - cache_read_tokens: u64, - input_tokens: u64, - output_tokens: u64, - total_cost: f64, - total_tokens: u64, -} - -impl From<&RawTotals> for LocalUsageTotals { - fn from(value: &RawTotals) -> Self { - Self { - input_tokens: value.input_tokens, - cache_creation_tokens: value.cache_creation_tokens, - cache_read_tokens: value.cache_read_tokens, - output_tokens: value.output_tokens, - total_tokens: value.total_tokens, - cost_usd: value.total_cost, - } - } -} - -#[derive(Default)] -struct UsageCache { - report: Option, - cached_at: Option, -} - -fn cache() -> &'static Arc> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Arc::new(Mutex::new(UsageCache::default()))) -} - -#[tauri::command] -pub async fn get_local_usage_report( - db: State<'_, DbState>, - refresh: Option, - timezone: Option, -) -> Result { - let roots = { - let connection = db.0.lock().map_err(|error| error.to_string())?; - codex_roots(Some(&connection))? - }; - let mut report = - get_local_usage_report_for_roots(roots, refresh.unwrap_or(false), timezone.as_deref()) - .await?; - let connection = db.0.lock().map_err(|error| error.to_string())?; - report.devin = Some(project_devin_usage(&connection)?); - Ok(report) -} - -/// Read the local usage report without requiring Tauri state. -/// -/// Transport adapters may provide an existing CodeVetter connection so imported -/// Codex roots remain consistent with the desktop app. Passing `None` keeps the -/// operation read-only and falls back to environment/default roots. -pub async fn get_headless_local_usage_report( - connection: Option<&Connection>, - refresh: bool, - timezone: Option<&str>, -) -> Result { - let roots = codex_roots(connection)?; - let mut report = get_local_usage_report_for_roots(roots, refresh, timezone).await?; - report.devin = connection.map(project_devin_usage).transpose()?; - Ok(report) -} - -fn project_devin_usage(connection: &Connection) -> Result { - project_devin_usage_at(connection, Local::now().date_naive()) -} - -fn project_devin_usage_at( - connection: &Connection, - today: NaiveDate, -) -> Result { - let row = queries::get_agent_usage_breakdown(connection) - .map_err(|error| error.to_string())? - .into_iter() - .find(|row| row.agent_type == "devin"); - let has_row = row.is_some(); - let excluded_agents = [ - "claude-code", - "codex", - "cursor", - "grok", - "google", - "openai", - "openrouter", - ] - .map(str::to_string); - let row = row.unwrap_or(queries::AgentUsageRow { - agent_type: "devin".into(), - sessions: 0, - real_input_tokens: 0, - cache_read_tokens: 0, - output_tokens: 0, - week_real_input_tokens: 0, - week_output_tokens: 0, - cost: 0.0, - }); - let mut windows = Vec::with_capacity(4); - for (window, days) in [ - ("1w", Some(7_i64)), - ("30d", Some(30_i64)), - ("90d", Some(90_i64)), - ("all", None), - ] { - let since = days.map(|days| { - (today - ChronoDuration::days(days - 1)) - .format("%Y-%m-%d") - .to_string() - }); - let models = project_devin_models(connection, since.as_deref(), &excluded_agents)?; - let (sessions, generated_tokens, cache_read_tokens, cost_usd) = match since.as_deref() { - Some(since) => { - let rows = queries::get_agent_usage_by_day_since(connection, since) - .map_err(|error| error.to_string())?; - let (generated, cache, cost) = rows - .into_iter() - .filter(|row| row.agent_type == "devin") - .fold((0_i64, 0_i64, 0.0_f64), |totals, row| { - ( - totals.0.saturating_add(row.generated), - totals.1.saturating_add(row.cache), - totals.2 + row.cost, - ) - }); - let sessions = - queries::get_agent_session_count_since(connection, "devin", Some(since)) - .map_err(|error| error.to_string())?; - (sessions, generated, cache, cost) - } - None => ( - row.sessions, - row.real_input_tokens.saturating_add(row.output_tokens), - row.cache_read_tokens, - row.cost, - ), - }; - windows.push(DevinUsageWindow { - window: window.into(), - since, - sessions, - generated_tokens, - cache_read_tokens, - cost_usd, - models, - }); - } - let models = windows - .iter() - .find(|window| window.window == "all") - .map(|window| window.models.clone()) - .unwrap_or_default(); - let status = if has_row || !models.is_empty() { - "ready" - } else { - "empty" - }; - Ok(DevinUsageSummary { - status: status.into(), - source: "CodeVetter SQLite · indexed Devin sessions.db".into(), - sessions: row.sessions, - generated_tokens: row.real_input_tokens.saturating_add(row.output_tokens), - cache_read_tokens: row.cache_read_tokens, - output_tokens: row.output_tokens, - cost_usd: row.cost, - models, - windows, - limitations: vec![ - "Devin remains separate from ccusage totals.".into(), - "This local history is not live quota telemetry.".into(), - ], - }) -} - -fn project_devin_models( - connection: &Connection, - since: Option<&str>, - excluded_agents: &[String], -) -> Result, String> { - queries::get_usage_by_model( - connection, - super::history::estimate_cost, - since, - None, - None, - excluded_agents, - ) - .map_err(|error| error.to_string()) - .map(|rows| { - rows.into_iter() - .map(|model| DevinUsageModel { - model: model.model, - sessions: model.sessions, - generated_tokens: model.generated, - cache_read_tokens: model.cache, - cost_usd: model.cost, - }) - .collect() - }) -} - -async fn get_local_usage_report_for_roots( - roots: Vec, - refresh: bool, - timezone: Option<&str>, -) -> Result { - let timezone = normalize_timezone(timezone); - let mut cache = cache().lock().await; - if !refresh { - if let (Some(report), Some(cached_at)) = (&cache.report, cache.cached_at) { - if cached_at.elapsed() < CACHE_TTL && report.provenance.timezone == timezone { - return Ok(report.clone()); - } - } - } - - match load_report(&timezone, &roots).await { - Ok(report) => { - cache.report = Some(report.clone()); - cache.cached_at = Some(Instant::now()); - Ok(report) - } - Err(failure) => { - if let Some(report) = cache.report.as_mut() { - report.status = "stale".into(); - report.stale = true; - report.error = Some(failure); - return Ok(report.clone()); - } - Ok(unavailable_report(timezone, roots, failure)) - } - } -} - -fn codex_roots(connection: Option<&Connection>) -> Result, String> { - let mut roots = BTreeSet::new(); - if let Ok(value) = std::env::var("CODEX_HOME") { - roots.extend( - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty() && !value.contains(',')) - .map(str::to_string), - ); - } - if roots.is_empty() { - if let Ok(home) = std::env::var("HOME") { - roots.insert( - PathBuf::from(home) - .join(".codex") - .to_string_lossy() - .to_string(), - ); - } - } - if let Some(connection) = connection { - if let Ok(Some(raw)) = queries::get_preference(connection, "codex_usage_import_roots") { - if let Ok(imports) = serde_json::from_str::>(&raw) { - roots.extend( - imports - .into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty() && !value.contains(',')), - ); - } - } - } - Ok(roots.into_iter().collect()) -} - -async fn load_report( - timezone: &str, - roots: &[String], -) -> Result { - let binary = resolve_ccusage_binary().ok_or_else(|| { - failure( - "missing_binary", - "The bundled ccusage executable is missing. Reinstall or update CodeVetter.", - ) - })?; - let stdout = run_ccusage(&binary, timezone, roots, ExecutionLimits::default()).await?; - let mut report = normalize_report(&stdout, timezone, roots) - .map_err(|message| failure("invalid_report", message))?; - if report - .provenance - .detected_agents - .iter() - .any(|agent| agent == "claude") - { - // Optional enrichment cannot make otherwise usable accounting unavailable. - if let Ok(bytes) = run_ccusage_section( - &binary, - timezone, - roots, - ExecutionLimits { - timeout: Duration::from_secs(5), - ..ExecutionLimits::default() - }, - true, - ) - .await - { - if projects::attach(&mut report, &bytes).is_ok() { - let mut hash = Sha256::new(); - hash.update(&stdout); - hash.update(&bytes); - report.provenance.source_fingerprint = format!("sha256:{:x}", hash.finalize()); - } - } - } - Ok(report) -} - -#[derive(Clone, Copy)] -struct ExecutionLimits { - timeout: Duration, - stdout_bytes: usize, - stderr_bytes: usize, -} - -impl Default for ExecutionLimits { - fn default() -> Self { - Self { - timeout: EXECUTION_TIMEOUT, - stdout_bytes: MAX_STDOUT_BYTES, - stderr_bytes: MAX_STDERR_BYTES, - } - } -} - -async fn run_ccusage( - binary: &Path, - timezone: &str, - roots: &[String], - limits: ExecutionLimits, -) -> Result, LocalUsageFailure> { - run_ccusage_section(binary, timezone, roots, limits, false).await -} - -async fn run_ccusage_section( - binary: &Path, - timezone: &str, - roots: &[String], - limits: ExecutionLimits, - projects: bool, -) -> Result, LocalUsageFailure> { - let config_path = std::env::temp_dir().join(format!( - "codevetter-ccusage-config-{}-{}.json", - std::process::id(), - projects - )); - std::fs::write(&config_path, b"{}") - .map_err(|error| failure("config", format!("Create ccusage config: {error}")))?; - let mut command = Command::new(binary); - if projects { - command.args(["claude", "daily", "--instances"]); - } else { - command.args([ - "daily", - "--sections", - "daily,weekly,monthly,session", - "--by-agent", - ]); - } - command - .args(["--json", "--offline", "--timezone", timezone, "--config"]) - .arg(&config_path) - .arg("--no-color") - .current_dir(std::env::temp_dir()) - .env_remove("CCUSAGE_TIMEZONE") - .env_remove("LOG_LEVEL") - .env("NO_COLOR", "1") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - if !roots.is_empty() { - command.env("CODEX_HOME", roots.join(",")); - } - let mut child = command - .spawn() - .map_err(|error| failure("launch", format!("Launch bundled ccusage: {error}")))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| failure("launch", "ccusage stdout was unavailable"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| failure("launch", "ccusage stderr was unavailable"))?; - let execution = tokio::time::timeout(limits.timeout, async { - let (status, stdout, stderr) = tokio::join!( - child.wait(), - read_capped(stdout, limits.stdout_bytes), - read_capped(stderr, limits.stderr_bytes) - ); - (status, stdout, stderr) - }) - .await; - let _ = std::fs::remove_file(&config_path); - - let (status, stdout, stderr) = match execution { - Ok(result) => result, - Err(_) => { - let _ = child.kill().await; - return Err(failure( - "timeout", - format!( - "ccusage exceeded the {} second limit", - limits.timeout.as_secs() - ), - )); - } - }; - let status = status.map_err(|error| failure("wait", format!("Wait for ccusage: {error}")))?; - let (stdout, stdout_exceeded) = - stdout.map_err(|error| failure("read", format!("Read ccusage output: {error}")))?; - let (stderr, stderr_exceeded) = - stderr.map_err(|error| failure("read", format!("Read ccusage diagnostics: {error}")))?; - if stdout_exceeded { - return Err(failure( - "oversized_output", - format!("ccusage output exceeded {} bytes", limits.stdout_bytes), - )); - } - if stderr_exceeded { - return Err(failure( - "oversized_diagnostics", - format!("ccusage diagnostics exceeded {} bytes", limits.stderr_bytes), - )); - } - if !status.success() { - let detail = String::from_utf8_lossy(&stderr); - return Err(failure( - "non_zero_exit", - format!("ccusage exited with {status}: {}", detail.trim()), - )); - } - Ok(stdout) -} - -async fn read_capped( - mut reader: R, - limit: usize, -) -> std::io::Result<(Vec, bool)> { - let mut output = Vec::with_capacity(limit.min(64 * 1024)); - let mut exceeded = false; - let mut buffer = [0u8; 8192]; - loop { - let read = reader.read(&mut buffer).await?; - if read == 0 { - break; - } - let remaining = limit.saturating_sub(output.len()); - if remaining > 0 { - output.extend_from_slice(&buffer[..read.min(remaining)]); - } - if read > remaining { - exceeded = true; - } - } - Ok((output, exceeded)) -} - -fn normalize_report( - bytes: &[u8], - timezone: &str, - roots: &[String], -) -> Result { - let raw: RawReport = serde_json::from_slice(bytes) - .map_err(|error| format!("Parse pinned ccusage JSON contract: {error}"))?; - - let daily = raw - .daily - .iter() - .map(normalize_accounted_period) - .collect::, _>>()?; - let weekly = raw - .weekly - .iter() - .map(normalize_accounted_period) - .collect::, _>>()?; - let monthly = raw - .monthly - .iter() - .map(normalize_accounted_period) - .collect::, _>>()?; - let mut sessions = raw - .sessions - .iter() - .filter(|session| is_accounted_agent(&session.agent)) - .map(|session| LocalUsageSession { - session_id: session.period.clone(), - agent: session.agent.clone(), - last_activity: session.metadata.last_activity.clone(), - project: None, - reasoning_output_tokens: session.metadata.reasoning_output_tokens, - totals: (&session.totals).into(), - models: normalize_models(&session.model_breakdowns), - }) - .collect::>(); - attribute_session_projects(&mut sessions, roots); - let totals = daily - .iter() - .try_fold(LocalUsageTotals::default(), |totals, period| { - totals.checked_add(&period.totals) - })?; - let mut detected_agents = BTreeSet::new(); - let mut excluded_agents = BTreeSet::new(); - for period in &raw.daily { - for agent in period - .metadata - .agents - .iter() - .chain(period.agents.iter().map(|agent| &agent.agent)) - { - if is_accounted_agent(agent) { - detected_agents.insert(agent.clone()); - } else { - excluded_agents.insert(agent.clone()); - } - } - } - for session in &raw.sessions { - if is_accounted_agent(&session.agent) { - detected_agents.insert(session.agent.clone()); - } else { - excluded_agents.insert(session.agent.clone()); - } - } - let models = daily - .iter() - .flat_map(|period| period.agents.iter()) - .flat_map(|agent| agent.models.iter()) - .chain(sessions.iter().flat_map(|session| session.models.iter())); - let mut fallback_models = BTreeSet::new(); - let mut unpriced_models = BTreeSet::new(); - for model in models { - if model.fallback { - fallback_models.insert(model.model.clone()); - } - if !model.priced { - unpriced_models.insert(model.model.clone()); - } - } - let fingerprint = format!("sha256:{:x}", Sha256::digest(bytes)); - Ok(LocalUsageReport { - status: "ready".into(), - stale: false, - error: None, - provenance: LocalUsageProvenance { - engine: "ccusage".into(), - version: CCUSAGE_VERSION.into(), - generated_at: Utc::now().to_rfc3339(), - timezone: timezone.into(), - window: "all".into(), - detected_agents: detected_agents.into_iter().collect(), - excluded_agents: excluded_agents.into_iter().collect(), - codex_roots: roots.to_vec(), - source_fingerprint: fingerprint, - pricing_complete: unpriced_models.is_empty(), - fallback_models: fallback_models.into_iter().collect(), - unpriced_models: unpriced_models.into_iter().collect(), - }, - daily, - weekly, - monthly, - sessions, - totals, - devin: None, - }) -} - -/// Attribute sessions to projects using each agent's own storage layout — -/// the same files ccusage reads. Claude maps `~/.claude/projects// -/// .jsonl` back to a decoded working directory; Codex rollouts -/// record `cwd` in their session_meta line. Anything unreadable or unmatched -/// stays `None` and renders as Unattributed rather than being guessed from -/// the session id or model. -fn attribute_session_projects(sessions: &mut [LocalUsageSession], roots: &[String]) { - let needed: BTreeSet = sessions - .iter() - .map(|session| session.session_id.clone()) - .collect(); - if needed.is_empty() { - return; - } - let claude = claude_project_index(&needed); - let codex = codex_project_index(&needed, roots); - for session in sessions.iter_mut() { - session.project = match session.agent.as_str() { - "claude" => claude.get(&session.session_id).cloned(), - "codex" => codex.get(&session.session_id).cloned(), - _ => None, - }; - } -} - -fn claude_project_index(needed: &BTreeSet) -> BTreeMap { - let Some(projects) = std::env::var_os("HOME") - .map(PathBuf::from) - .map(|home| home.join(".claude").join("projects")) - else { - return BTreeMap::new(); - }; - claude_project_index_in(&projects, needed) -} - -fn claude_project_index_in(projects: &Path, needed: &BTreeSet) -> BTreeMap { - let mut index = BTreeMap::new(); - let Ok(entries) = std::fs::read_dir(projects) else { - return index; - }; - for entry in entries.flatten() { - let dir = entry.path(); - if !dir.is_dir() { - continue; - } - let Some(slug) = dir.file_name().and_then(|name| name.to_str()) else { - continue; - }; - let Ok(files) = std::fs::read_dir(&dir) else { - continue; - }; - for file in files.flatten() { - let path = file.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; - } - let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - if needed.contains(stem) { - index.insert(stem.to_string(), decode_claude_project_slug(slug)); - } - } - } - index -} - -/// Claude encodes the working directory into the folder name by replacing -/// `/` (and some other separators) with `-`. The mapping is lossy for names -/// that already contain dashes, so the decoded path is a faithful label but -/// not a guaranteed real path. -fn decode_claude_project_slug(slug: &str) -> String { - let decoded = slug.replace('-', "/"); - if decoded.starts_with('/') { - decoded - } else { - format!("/{decoded}") - } -} - -fn codex_project_index(needed: &BTreeSet, roots: &[String]) -> BTreeMap { - let mut homes: Vec = roots.iter().map(PathBuf::from).collect(); - if homes.is_empty() { - if let Some(home) = std::env::var_os("HOME") { - homes.push(PathBuf::from(home).join(".codex")); - } - } - let mut index = BTreeMap::new(); - for root in homes { - index.extend(scan_codex_rollouts(&root.join("sessions"), needed, 0)); - } - index -} - -fn scan_codex_rollouts( - dir: &Path, - needed: &BTreeSet, - depth: u8, -) -> BTreeMap { - let mut index = BTreeMap::new(); - if depth > 5 { - return index; - } - let Ok(entries) = std::fs::read_dir(dir) else { - return index; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - index.extend(scan_codex_rollouts(&path, needed, depth + 1)); - continue; - } - let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - for id in needed { - if stem.ends_with(id.as_str()) { - if let Some(cwd) = codex_rollout_cwd(&path) { - index.insert(id.clone(), cwd); - } - } - } - } - index -} - -/// Codex rollout files start with a session_meta line carrying `cwd`; only -/// the first line is read so large sessions stay cheap. -fn codex_rollout_cwd(path: &Path) -> Option { - let file = std::fs::File::open(path).ok()?; - let mut line = String::new(); - std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut line).ok()?; - let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?; - value - .pointer("/payload/cwd") - .and_then(|cwd| cwd.as_str()) - .map(str::to_string) -} - -fn normalize_accounted_period(period: &RawPeriod) -> Result { - let agents = period - .agents - .iter() - .filter(|agent| is_accounted_agent(&agent.agent)) - .map(|agent| LocalUsageAgent { - agent: agent.agent.clone(), - totals: (&agent.totals).into(), - models: normalize_models(&agent.model_breakdowns), - }) - .collect::>(); - let totals = agents - .iter() - .try_fold(LocalUsageTotals::default(), |totals, agent| { - totals.checked_add(&agent.totals) - })?; - let models = merge_models(agents.iter().flat_map(|agent| agent.models.iter()))?; - Ok(LocalUsagePeriod { - period: period.period.clone(), - totals, - agents, - models, - projects: Vec::new(), - }) -} - -fn is_accounted_agent(agent: &str) -> bool { - ACCOUNTED_AGENTS.contains(&agent) -} - -fn merge_models<'a>( - models: impl Iterator, -) -> Result, String> { - let mut merged = std::collections::BTreeMap::::new(); - for model in models { - match merged.get_mut(&model.model) { - Some(current) => { - current.totals = current.totals.checked_add(&model.totals)?; - current.fallback |= model.fallback; - current.priced &= model.priced; - } - None => { - merged.insert(model.model.clone(), model.clone()); - } - } - } - Ok(merged.into_values().collect()) -} - -fn normalize_models(models: &[RawModel]) -> Vec { - models - .iter() - .map(|model| { - let totals = LocalUsageTotals { - input_tokens: model.input_tokens, - cache_creation_tokens: model.cache_creation_tokens, - cache_read_tokens: model.cache_read_tokens, - output_tokens: model.output_tokens, - total_tokens: model.input_tokens - + model.cache_creation_tokens - + model.cache_read_tokens - + model.output_tokens, - cost_usd: model.cost, - }; - LocalUsageModel { - model: model.model_name.clone(), - fallback: model.is_fallback, - priced: totals.total_tokens == 0 || totals.cost_usd > 0.0, - totals, - } - }) - .collect() -} - -fn unavailable_report( - timezone: String, - roots: Vec, - error: LocalUsageFailure, -) -> LocalUsageReport { - LocalUsageReport { - status: "unavailable".into(), - stale: false, - error: Some(error), - provenance: LocalUsageProvenance { - engine: "ccusage".into(), - version: CCUSAGE_VERSION.into(), - generated_at: Utc::now().to_rfc3339(), - timezone, - window: "all".into(), - detected_agents: Vec::new(), - excluded_agents: Vec::new(), - codex_roots: roots, - source_fingerprint: String::new(), - pricing_complete: false, - fallback_models: Vec::new(), - unpriced_models: Vec::new(), - }, - daily: Vec::new(), - weekly: Vec::new(), - monthly: Vec::new(), - sessions: Vec::new(), - totals: LocalUsageTotals::default(), - devin: None, - } -} - -fn normalize_timezone(value: Option<&str>) -> String { - let value = value.unwrap_or("UTC").trim(); - if value.is_empty() - || value.len() > 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'+')) - { - "UTC".into() - } else { - value.into() - } -} - -fn resolve_ccusage_binary() -> Option { - if let Some(path) = std::env::var_os("CODEVETTER_CCUSAGE_BIN") { - let path = PathBuf::from(path); - if path.is_file() { - return Some(path); - } - } - let name = if cfg!(windows) { - "ccusage.exe" - } else { - "ccusage" - }; - if let Ok(current) = std::env::current_exe() { - if let Some(parent) = current.parent() { - let bundled = parent.join(name); - if bundled.is_file() { - return Some(bundled); - } - } - } - let development = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("binaries") - .join(format!( - "ccusage-{}{}", - host_target(), - if cfg!(windows) { ".exe" } else { "" } - )); - development.is_file().then_some(development) -} - -fn host_target() -> &'static str { - if cfg!(all(target_os = "macos", target_arch = "aarch64")) { - "aarch64-apple-darwin" - } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { - "x86_64-apple-darwin" - } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { - "aarch64-unknown-linux-gnu" - } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { - "x86_64-unknown-linux-gnu" - } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { - "aarch64-pc-windows-msvc" - } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) { - "x86_64-pc-windows-msvc" - } else { - "unsupported" - } -} - -fn failure(category: impl Into, message: impl Into) -> LocalUsageFailure { - LocalUsageFailure { - category: category.into(), - message: message.into(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use tempfile::TempDir; - - const UNIFIED: &[u8] = include_bytes!("../../tests/fixtures/ccusage/unified.json"); - const EMPTY: &[u8] = include_bytes!("../../tests/fixtures/ccusage/empty.json"); - - #[test] - fn normalizes_pinned_unified_contract() { - let report = normalize_report(UNIFIED, "Asia/Kolkata", &["/tmp/codex".into()]).unwrap(); - assert_eq!(report.status, "ready"); - assert_eq!(report.totals.total_tokens, 37); - assert_eq!(report.daily[0].agents.len(), 2); - assert_eq!(report.sessions.len(), 2); - assert_eq!(report.provenance.fallback_models, vec!["gpt-5.6-sol"]); - assert_eq!(report.provenance.unpriced_models, vec!["claude-opus-5"]); - assert!(!report.provenance.pricing_complete); - } - - #[test] - fn excludes_non_chart_agents() { - let with_opencode = String::from_utf8(UNIFIED.to_vec()).unwrap().replacen( - "\"agents\": [\"claude\", \"codex\"]", - "\"agents\": [\"claude\", \"codex\", \"opencode\"]", - 1, - ); - let report = normalize_report(with_opencode.as_bytes(), "UTC", &[]).unwrap(); - assert_eq!(report.provenance.detected_agents, ["claude", "codex"]); - assert_eq!(report.provenance.excluded_agents, ["opencode"]); - assert_eq!(report.totals.total_tokens, 37); - } - - #[test] - fn accounts_for_grok_but_not_devin() { - assert!(is_accounted_agent("claude")); - assert!(is_accounted_agent("codex")); - assert!(is_accounted_agent("grok")); - assert!(!is_accounted_agent("devin")); - } - - #[test] - fn accepts_an_empty_report() { - let report = normalize_report(EMPTY, "UTC", &[]).unwrap(); - assert_eq!(report.totals, LocalUsageTotals::default()); - assert!(report.daily.is_empty()); - assert!(report.provenance.pricing_complete); - assert!(report.devin.is_none()); - } - - #[test] - fn projects_devin_as_a_separate_local_source() { - let connection = Connection::open_in_memory().unwrap(); - crate::db::schema::run_migrations(&connection).unwrap(); - connection - .execute_batch( - "INSERT INTO cc_projects (id, display_name, dir_path, created_at) - VALUES ('devin-project', 'Devin', '/fixture/devin', '2026-09-01T00:00:00Z'); - INSERT INTO cc_sessions ( - id, project_id, agent_type, model_used, total_input_tokens, - total_output_tokens, cache_read_tokens, cache_creation_tokens, - estimated_cost_usd, last_message - ) VALUES ( - 'devin-session', 'devin-project', 'devin', 'glm-5.2', 1200, - 300, 200, 100, 0.0042, '2026-09-01T00:00:00Z' - ); - INSERT INTO cc_sessions ( - id, project_id, agent_type, model_used, total_input_tokens, - total_output_tokens, cache_read_tokens, cache_creation_tokens, - estimated_cost_usd, last_message - ) VALUES ( - 'devin-old', 'devin-project', 'devin', 'glm-5.2', 600, - 100, 50, 0, 0.0020, '2026-05-01T00:00:00Z' - ); - INSERT INTO cc_session_days (session_id, day, msg_count) VALUES - ('devin-session', '2026-09-01', 10), - ('devin-old', '2026-05-01', 5);", - ) - .unwrap(); - - let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(); - let summary = project_devin_usage_at(&connection, today).unwrap(); - - assert_eq!(summary.status, "ready"); - assert_eq!(summary.sessions, 2); - assert_eq!(summary.generated_tokens, 1850); - assert_eq!(summary.cache_read_tokens, 250); - assert_eq!(summary.output_tokens, 400); - assert_eq!(summary.models[0].model, "glm-5.2"); - let week = summary - .windows - .iter() - .find(|window| window.window == "1w") - .unwrap(); - assert_eq!(week.since.as_deref(), Some("2026-08-26")); - assert_eq!(week.sessions, 1); - assert_eq!(week.generated_tokens, 1200); - assert_eq!(week.cache_read_tokens, 200); - let all = summary - .windows - .iter() - .find(|window| window.window == "all") - .unwrap(); - assert_eq!(all.sessions, 2); - assert_eq!(all.generated_tokens, summary.generated_tokens); - assert!(summary - .limitations - .iter() - .any(|limitation| limitation.contains("not live quota"))); - } - - #[test] - fn decodes_claude_project_slugs() { - assert_eq!( - decode_claude_project_slug("-Users-test-fleet-codevetter"), - "/Users/test/fleet/codevetter" - ); - assert_eq!(decode_claude_project_slug("loose"), "/loose"); - } - - #[test] - fn attributes_claude_sessions_from_project_directories() { - let directory = TempDir::new().unwrap(); - let project_dir = directory.path().join("-Users-test-fleet-codevetter"); - fs::create_dir_all(&project_dir).unwrap(); - fs::write(project_dir.join("session-a.jsonl"), "{}").unwrap(); - fs::write(project_dir.join("session-b.json"), "{}").unwrap(); - let other = directory.path().join("-tmp-scratch"); - fs::create_dir_all(&other).unwrap(); - fs::write(other.join("session-c.jsonl"), "{}").unwrap(); - - let needed = BTreeSet::from(["session-a".to_string(), "missing".to_string()]); - let index = claude_project_index_in(directory.path(), &needed); - - assert_eq!( - index.get("session-a").map(String::as_str), - Some("/Users/test/fleet/codevetter") - ); - assert!(!index.contains_key("missing")); - assert!(!index.contains_key("session-b")); - } - - #[test] - fn attributes_codex_sessions_from_rollout_cwd() { - let root = TempDir::new().unwrap(); - let rollout_dir = root - .path() - .join("sessions") - .join("2026") - .join("09") - .join("15"); - fs::create_dir_all(&rollout_dir).unwrap(); - fs::write( - rollout_dir.join("rollout-2026-09-15T10-00-00-session-codex-1.jsonl"), - "{\"type\":\"session_meta\",\"payload\":{\"id\":\"session-codex-1\",\"cwd\":\"/work/fleet/starboard\"}}\n{\"type\":\"turn\"}\n", - ) - .unwrap(); - fs::write( - rollout_dir.join("rollout-2026-09-15T11-00-00-session-codex-2.jsonl"), - "not json\n", - ) - .unwrap(); - - let needed = BTreeSet::from([ - "session-codex-1".to_string(), - "session-codex-2".to_string(), - "session-codex-3".to_string(), - ]); - let roots = vec![root.path().to_string_lossy().to_string()]; - let index = codex_project_index(&needed, &roots); - - assert_eq!( - index.get("session-codex-1").map(String::as_str), - Some("/work/fleet/starboard") - ); - assert!(!index.contains_key("session-codex-2")); - assert!(!index.contains_key("session-codex-3")); - } - - #[test] - fn leaves_unmatched_sessions_unattributed() { - let directory = TempDir::new().unwrap(); - let roots = vec![directory.path().to_string_lossy().to_string()]; - let mut sessions = vec![ - LocalUsageSession { - session_id: "unknown-1".into(), - agent: "claude".into(), - last_activity: None, - project: None, - reasoning_output_tokens: 0, - totals: LocalUsageTotals::default(), - models: vec![], - }, - LocalUsageSession { - session_id: "unknown-2".into(), - agent: "grok".into(), - last_activity: None, - project: None, - reasoning_output_tokens: 0, - totals: LocalUsageTotals::default(), - models: vec![], - }, - ]; - attribute_session_projects(&mut sessions, &roots); - assert!(sessions.iter().all(|session| session.project.is_none())); - } - - #[test] - fn rejects_invalid_json() { - assert!(normalize_report(b"not-json", "UTC", &[]).is_err()); - } - - #[test] - fn normalizes_only_safe_timezone_values() { - assert_eq!(normalize_timezone(Some("Asia/Kolkata")), "Asia/Kolkata"); - assert_eq!(normalize_timezone(Some("$(touch nope)")), "UTC"); - } - - #[cfg(unix)] - fn executable(script: &str) -> (TempDir, PathBuf) { - let directory = TempDir::new().unwrap(); - let path = directory.path().join("ccusage"); - fs::write(&path, script).unwrap(); - let mut permissions = fs::metadata(&path).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).unwrap(); - (directory, path) - } - - #[cfg(unix)] - #[tokio::test] - async fn executes_a_real_process_and_reads_json() { - let (directory, binary) = - executable("#!/bin/sh\nexec /bin/cat \"$(dirname \"$0\")/report.json\"\n"); - fs::write(directory.path().join("report.json"), EMPTY).unwrap(); - let output = run_ccusage( - &binary, - "UTC", - &[], - ExecutionLimits { - timeout: Duration::from_secs(2), - stdout_bytes: 4096, - stderr_bytes: 4096, - }, - ) - .await - .unwrap(); - assert!(normalize_report(&output, "UTC", &[]).is_ok()); - } - - #[cfg(unix)] - #[tokio::test] - async fn categorizes_nonzero_oversized_and_timeout_failures() { - let (_directory, binary) = executable("#!/bin/sh\necho broken >&2\nexit 7\n"); - let error = run_ccusage(&binary, "UTC", &[], ExecutionLimits::default()) - .await - .unwrap_err(); - assert_eq!(error.category, "non_zero_exit"); - - let (_directory, binary) = executable("#!/bin/sh\nprintf '123456789'\n"); - let error = run_ccusage( - &binary, - "UTC", - &[], - ExecutionLimits { - timeout: Duration::from_secs(2), - stdout_bytes: 4, - stderr_bytes: 4, - }, - ) - .await - .unwrap_err(); - assert_eq!(error.category, "oversized_output"); - - let (_directory, binary) = executable("#!/bin/sh\nsleep 2\n"); - let error = run_ccusage( - &binary, - "UTC", - &[], - ExecutionLimits { - timeout: Duration::from_millis(20), - stdout_bytes: 4, - stderr_bytes: 4, - }, - ) - .await - .unwrap_err(); - assert_eq!(error.category, "timeout"); - } -} diff --git a/crates/codevetter-core/src/commands/local_usage_projects.rs b/crates/codevetter-core/src/commands/local_usage_projects.rs deleted file mode 100644 index e010f369..00000000 --- a/crates/codevetter-core/src/commands/local_usage_projects.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Optional ccusage Claude project attribution. The unified daily ledger stays authoritative. -use super::{LocalUsageReport, LocalUsageTotals}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LocalUsageProject { - pub project: String, - pub agent: String, - pub totals: LocalUsageTotals, -} - -#[derive(Deserialize)] -struct ProjectReport { - projects: BTreeMap>, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProjectDay { - date: String, - input_tokens: u64, - output_tokens: u64, - cache_creation_tokens: u64, - cache_read_tokens: u64, - total_tokens: u64, - total_cost: f64, -} - -fn within(value: &LocalUsageTotals, limit: &LocalUsageTotals) -> bool { - value.input_tokens <= limit.input_tokens - && value.output_tokens <= limit.output_tokens - && value.cache_creation_tokens <= limit.cache_creation_tokens - && value.cache_read_tokens <= limit.cache_read_tokens - && value.total_tokens <= limit.total_tokens - && value.cost_usd.is_finite() - && value.cost_usd >= 0.0 - && value.cost_usd <= limit.cost_usd + 0.000_001 -} - -pub(super) fn attach(report: &mut LocalUsageReport, bytes: &[u8]) -> Result<(), String> { - let raw: ProjectReport = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; - let mut days: BTreeMap> = BTreeMap::new(); - let mut seen = BTreeSet::new(); - for (project, rows) in raw.projects { - if project.trim().is_empty() - || project.len() > 4096 - || project.chars().any(char::is_control) - { - return Err("Invalid ccusage project identity".into()); - } - for row in rows { - if chrono::NaiveDate::parse_from_str(&row.date, "%Y-%m-%d").is_err() - || !seen.insert((project.clone(), row.date.clone())) - { - return Err("Invalid or duplicate project day".into()); - } - let totals = LocalUsageTotals { - input_tokens: row.input_tokens, - output_tokens: row.output_tokens, - cache_creation_tokens: row.cache_creation_tokens, - cache_read_tokens: row.cache_read_tokens, - total_tokens: row.total_tokens, - cost_usd: row.total_cost, - }; - if !totals.cost_usd.is_finite() || totals.cost_usd < 0.0 { - return Err("Invalid project cost".into()); - } - days.entry(row.date).or_default().push(LocalUsageProject { - project: project.clone(), - agent: "claude".into(), - totals, - }); - } - } - // Two scans can race with a new log entry. Reject mismatched days rather than - // attributing more tokens or cost than the canonical provider ledger contains. - let mut reconciled = Vec::new(); - for (index, day) in report.daily.iter().enumerate() { - let Some(rows) = days.remove(&day.period) else { - continue; - }; - let Some(claude) = day.agents.iter().find(|a| a.agent == "claude") else { - continue; - }; - let summed = rows - .iter() - .try_fold(LocalUsageTotals::default(), |sum, row| { - sum.checked_add(&row.totals) - })?; - if within(&summed, &claude.totals) { - reconciled.push((index, rows)); - } - } - for (index, rows) in reconciled { - report.daily[index].projects = rows; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - fn fixture(input: u64, cost: f64) -> Vec { - serde_json::to_vec(&serde_json::json!({"projects":{"repo-a":[{ - "date":"2026-08-16", "inputTokens":input,"outputTokens":0, - "cacheCreationTokens":0,"cacheReadTokens":0,"totalTokens":input,"totalCost":cost - }]}})) - .unwrap() - } - fn report() -> LocalUsageReport { - let mut report = super::super::normalize_report( - include_bytes!("../../tests/fixtures/ccusage/unified.json"), - "UTC", - &[], - ) - .unwrap(); - report.daily[0].period = "2026-08-16".into(); - report - } - #[test] - fn projects_reconcile_without_changing_accounting() { - let mut report = report(); - let totals = report.totals.clone(); - attach(&mut report, &fixture(1, 0.0)).unwrap(); - assert_eq!(report.daily[0].projects[0].project, "repo-a"); - assert_eq!(report.totals, totals); - } - #[test] - fn excess_or_invalid_attribution_does_not_replace_the_ledger() { - let mut report = report(); - attach(&mut report, &fixture(u64::MAX, 0.0)).unwrap(); - assert!(report.daily[0].projects.is_empty()); - assert!(attach(&mut report, &fixture(1, -1.0)).is_err()); - assert!(attach(&mut report, b"{}").is_err()); - } - - #[test] - fn overflow_does_not_partially_enrich_a_report() { - let mut report = report(); - let mut second = report.daily[0].clone(); - second.period = "2026-08-17".into(); - report.daily.push(second); - let mut raw: serde_json::Value = serde_json::from_slice(&fixture(1, 0.0)).unwrap(); - let mut huge = raw["projects"]["repo-a"][0].clone(); - huge["date"] = "2026-08-17".into(); - huge["inputTokens"] = u64::MAX.into(); - raw["projects"]["repo-a"] - .as_array_mut() - .unwrap() - .push(huge.clone()); - raw["projects"]["repo-b"] = serde_json::json!([huge]); - assert!(attach(&mut report, &serde_json::to_vec(&raw).unwrap()).is_err()); - assert!(report.daily.iter().all(|day| day.projects.is_empty())); - } -} diff --git a/crates/codevetter-core/src/commands/mod.rs b/crates/codevetter-core/src/commands/mod.rs index dfbc7de8..31503a0a 100644 --- a/crates/codevetter-core/src/commands/mod.rs +++ b/crates/codevetter-core/src/commands/mod.rs @@ -33,7 +33,6 @@ pub mod history_summary_graph; pub mod intel; pub mod local_check; pub mod local_qualification; -pub mod local_usage; pub mod managed_work; pub mod mcp_access; pub mod native_agent_island; @@ -47,7 +46,6 @@ mod perf_bench; pub mod performance_bridge; pub mod preferences; pub mod procedure_events; -pub mod provider_quota; pub mod qa_workspace; pub mod repo_query; pub mod repo_workspace; diff --git a/crates/codevetter-core/src/commands/native_settings.rs b/crates/codevetter-core/src/commands/native_settings.rs index bc3bc217..3065ecc8 100644 --- a/crates/codevetter-core/src/commands/native_settings.rs +++ b/crates/codevetter-core/src/commands/native_settings.rs @@ -67,13 +67,6 @@ const ROLES: &[(&str, &str)] = &[ ]; const CONCURRENCY: &[(&str, &str)] = &[("1", "1"), ("2", "2"), ("3", "3"), ("5", "5"), ("10", "10")]; -const TRAY_CADENCE: &[(&str, &str)] = &[ - ("manual", "Manual only"), - ("60", "Every minute"), - ("120", "Every 2 minutes"), - ("300", "Every 5 minutes"), - ("900", "Every 15 minutes"), -]; const ISLAND_VOLUME: &[(&str, &str)] = &[("0.5", "Quiet"), ("0.8", "Balanced"), ("1", "Full")]; const ISLAND_PACE: &[(&str, &str)] = &[("0.4", "Measured"), ("0.48", "Balanced"), ("0.56", "Quick")]; @@ -206,24 +199,6 @@ const DEFINITIONS: &[NativeSettingDefinition] = &[ "false", EMPTY_OPTIONS, ), - definition( - "notify_quota_thresholds", - "notifications", - "Provider Quota Thresholds", - "Notify only from observed provider-window telemetry.", - NativeSettingKind::Toggle, - "true", - EMPTY_OPTIONS, - ), - definition( - "notify_session_usage_thresholds", - "notifications", - "Session Usage Thresholds", - "Notify from indexed session context estimates when enabled.", - NativeSettingKind::Toggle, - "false", - EMPTY_OPTIONS, - ), definition( "notification_sound", "notifications", @@ -233,15 +208,6 @@ const DEFINITIONS: &[NativeSettingDefinition] = &[ "true", EMPTY_OPTIONS, ), - definition( - "tray_refresh_cadence_secs", - "notifications", - "Menu Bar Refresh Cadence", - "Polling cadence for observed live-provider usage.", - NativeSettingKind::Choice, - "300", - TRAY_CADENCE, - ), definition( "native_agent_island_enabled", "agent_island", diff --git a/crates/codevetter-core/src/commands/provider_quota.rs b/crates/codevetter-core/src/commands/provider_quota.rs deleted file mode 100644 index 9c428043..00000000 --- a/crates/codevetter-core/src/commands/provider_quota.rs +++ /dev/null @@ -1,913 +0,0 @@ -use std::io::{BufRead, BufReader, Read, Write}; -use std::process::{Command, Stdio}; -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; - -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - -use super::review::resolve_cli_path; - -const CODEX_TIMEOUT: Duration = Duration::from_secs(8); -const CLAUDE_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); -const CLAUDE_TIMEOUT: Duration = Duration::from_secs(15); -const OUTPUT_LIMIT_BYTES: usize = 256 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProviderQuotaSelection { - All, - Claude, - Codex, -} - -impl ProviderQuotaSelection { - pub fn parse(value: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "all" => Ok(Self::All), - "claude" | "anthropic" => Ok(Self::Claude), - "codex" | "openai" => Ok(Self::Codex), - _ => Err("--provider must be all, claude, or codex".to_string()), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProviderQuotaReceipt { - pub schema_version: String, - pub generated_at: String, - pub providers: Vec, - pub limitations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProviderQuotaStatus { - pub provider: String, - pub status: String, - pub source: String, - pub checked_at: String, - pub plan: Option, - pub windows: Vec, - pub credits: Option, - pub reset_credits: Option, - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProviderQuotaWindow { - pub id: String, - pub label: String, - pub used_percent: f64, - pub remaining_percent: f64, - pub window_duration_minutes: Option, - pub resets_at_unix: Option, - pub reset_description: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProviderCreditBalance { - pub used_percent: Option, - pub remaining_percent: Option, - pub used_amount: Option, - pub limit_amount: Option, - pub currency: Option, - pub reset_description: Option, -} - -pub fn collect_provider_quotas(selection: ProviderQuotaSelection) -> ProviderQuotaReceipt { - let providers = match selection { - ProviderQuotaSelection::Claude => vec![collect_claude_quota()], - ProviderQuotaSelection::Codex => vec![collect_codex_quota()], - ProviderQuotaSelection::All => thread::scope(|scope| { - let claude = scope.spawn(collect_claude_quota); - let codex = scope.spawn(collect_codex_quota); - vec![ - claude.join().unwrap_or_else(|_| { - unavailable( - "claude", - "Claude Code /usage", - "Claude quota collection stopped unexpectedly", - ) - }), - codex.join().unwrap_or_else(|_| { - unavailable( - "codex", - "codex app-server account/rateLimits/read", - "Codex quota collection stopped unexpectedly", - ) - }), - ] - }), - }; - ProviderQuotaReceipt { - schema_version: "codevetter.provider-quota/v1".to_string(), - generated_at: chrono::Utc::now().to_rfc3339(), - providers, - limitations: vec![ - "Provider quota is reported independently from local token and cost history." - .to_string(), - "Claude is read through Claude Code's own interactive /usage view; Codex is read through the official app-server account/rateLimits/read method." - .to_string(), - "Unavailable provider telemetry is never represented as zero usage.".to_string(), - ], - } -} - -fn unavailable(provider: &str, source: &str, message: impl Into) -> ProviderQuotaStatus { - ProviderQuotaStatus { - provider: provider.to_string(), - status: "unavailable".to_string(), - source: source.to_string(), - checked_at: chrono::Utc::now().to_rfc3339(), - plan: None, - windows: Vec::new(), - credits: None, - reset_credits: None, - message: Some(message.into()), - } -} - -fn collect_codex_quota() -> ProviderQuotaStatus { - let executable = resolve_cli_path("codex"); - let mut child = match Command::new(&executable) - .arg("app-server") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - { - Ok(child) => child, - Err(error) => { - return unavailable( - "codex", - "codex app-server account/rateLimits/read", - format!("Codex app-server is unavailable: {error}"), - ); - } - }; - - let Some(mut stdin) = child.stdin.take() else { - let _ = child.kill(); - return unavailable( - "codex", - "codex app-server account/rateLimits/read", - "Codex app-server stdin is unavailable", - ); - }; - let Some(stdout) = child.stdout.take() else { - let _ = child.kill(); - return unavailable( - "codex", - "codex app-server account/rateLimits/read", - "Codex app-server stdout is unavailable", - ); - }; - - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - for line in BufReader::new(stdout).lines() { - if tx.send(line).is_err() { - break; - } - } - }); - - let requests = [ - json!({ - "method": "initialize", - "id": 0, - "params": { - "clientInfo": { - "name": "codevetter", - "title": "CodeVetter", - "version": env!("CARGO_PKG_VERSION") - } - } - }), - json!({"method": "initialized", "params": {}}), - json!({"method": "account/rateLimits/read", "id": 1}), - ]; - for request in requests { - if writeln!(stdin, "{request}").is_err() { - let _ = child.kill(); - let _ = child.wait(); - return unavailable( - "codex", - "codex app-server account/rateLimits/read", - "Could not write the Codex rate-limit request", - ); - } - } - let _ = stdin.flush(); - - let deadline = Instant::now() + CODEX_TIMEOUT; - let result = loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - break Err("Codex rate-limit request timed out".to_string()); - } - match rx.recv_timeout(remaining.min(Duration::from_millis(500))) { - Ok(Ok(line)) => { - let Ok(value) = serde_json::from_str::(&line) else { - continue; - }; - if value.get("id").and_then(Value::as_i64) == Some(1) { - break parse_codex_rate_limits(&value); - } - } - Ok(Err(error)) => break Err(format!("Could not read Codex app-server: {error}")), - Err(mpsc::RecvTimeoutError::Timeout) => continue, - Err(mpsc::RecvTimeoutError::Disconnected) => { - break Err("Codex app-server closed before returning rate limits".to_string()); - } - } - }; - let _ = child.kill(); - let _ = child.wait(); - result.unwrap_or_else(|error| { - unavailable("codex", "codex app-server account/rateLimits/read", error) - }) -} - -fn parse_codex_rate_limits(response: &Value) -> Result { - if let Some(error) = response.get("error") { - return Err(format!("Codex returned an error: {error}")); - } - let result = response - .get("result") - .ok_or_else(|| "Codex returned no rate-limit result".to_string())?; - let buckets = result - .get("rateLimitsByLimitId") - .and_then(Value::as_object) - .or_else(|| { - result - .get("rateLimits") - .and_then(Value::as_object) - .map(|_| result.as_object().expect("result is an object")) - }); - let mut windows = Vec::new(); - let mut plan = None; - if let Some(buckets) = buckets { - if let Some(single) = result - .get("rateLimits") - .filter(|_| !result["rateLimitsByLimitId"].is_object()) - { - append_codex_bucket("codex", single, &mut windows, &mut plan); - } else if let Some(account) = buckets.get("codex") { - // The account-level Codex allowance is the useful product signal. Model-specific - // promotional buckets (for example Spark) are intentionally not mixed into it. - append_codex_bucket("codex", account, &mut windows, &mut plan); - } else { - for (bucket_id, bucket) in buckets { - if bucket_id == "rateLimits" || bucket_id == "rateLimitResetCredits" { - continue; - } - let bucket_name = bucket - .get("limitName") - .and_then(Value::as_str) - .unwrap_or(bucket_id); - if bucket_id.to_ascii_lowercase().contains("spark") - || bucket_name.to_ascii_lowercase().contains("spark") - { - continue; - } - append_codex_bucket(bucket_id, bucket, &mut windows, &mut plan); - } - } - } - if windows.is_empty() { - return Err("Codex did not report any quota windows for this account".to_string()); - } - windows.sort_by(|left, right| { - left.window_duration_minutes - .unwrap_or(u64::MAX) - .cmp(&right.window_duration_minutes.unwrap_or(u64::MAX)) - .then_with(|| left.label.cmp(&right.label)) - }); - let reset_credits = result - .pointer("/rateLimitResetCredits/availableCount") - .and_then(Value::as_u64); - Ok(ProviderQuotaStatus { - provider: "codex".to_string(), - status: "ready".to_string(), - source: "codex app-server account/rateLimits/read".to_string(), - checked_at: chrono::Utc::now().to_rfc3339(), - plan, - windows, - credits: None, - reset_credits, - message: None, - }) -} - -fn append_codex_bucket( - bucket_id: &str, - bucket: &Value, - windows: &mut Vec, - plan: &mut Option, -) { - if plan.is_none() { - *plan = bucket - .get("planType") - .and_then(Value::as_str) - .map(str::to_string); - } - let bucket_name = bucket - .get("limitName") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(if bucket_id == "codex" { - "Codex" - } else { - bucket_id - }); - for (window_id, suffix) in [("primary", "primary"), ("secondary", "secondary")] { - let Some(window) = bucket.get(window_id) else { - continue; - }; - let Some(used) = window.get("usedPercent").and_then(Value::as_f64) else { - continue; - }; - let duration = window.get("windowDurationMins").and_then(Value::as_u64); - let duration_label = match duration { - Some(300) => "5-hour window".to_string(), - Some(10_080) => "Weekly window".to_string(), - Some(minutes) if minutes % 1_440 == 0 => format!("{}-day window", minutes / 1_440), - Some(minutes) if minutes % 60 == 0 => format!("{}-hour window", minutes / 60), - Some(minutes) => format!("{minutes}-minute window"), - None => suffix.to_string(), - }; - let label = if bucket_id == "codex" { - duration_label - } else { - format!("{bucket_name} · {duration_label}") - }; - windows.push(ProviderQuotaWindow { - id: format!("{bucket_id}.{window_id}"), - label, - used_percent: used.clamp(0.0, 100.0), - remaining_percent: (100.0 - used).clamp(0.0, 100.0), - window_duration_minutes: duration, - resets_at_unix: window.get("resetsAt").and_then(Value::as_i64), - reset_description: None, - }); - } -} - -fn collect_claude_quota() -> ProviderQuotaStatus { - let executable = resolve_cli_path("claude"); - let pty_system = native_pty_system(); - let pair = match pty_system.openpty(PtySize { - rows: 64, - cols: 132, - pixel_width: 0, - pixel_height: 0, - }) { - Ok(pair) => pair, - Err(error) => { - return unavailable( - "claude", - "Claude Code /usage", - format!("Could not open a hidden Claude terminal: {error}"), - ); - } - }; - let mut reader = match pair.master.try_clone_reader() { - Ok(reader) => reader, - Err(error) => { - return unavailable( - "claude", - "Claude Code /usage", - format!("Could not read the hidden Claude terminal: {error}"), - ); - } - }; - let mut writer = match pair.master.take_writer() { - Ok(writer) => writer, - Err(error) => { - return unavailable( - "claude", - "Claude Code /usage", - format!("Could not write to the hidden Claude terminal: {error}"), - ); - } - }; - let mut command = CommandBuilder::new(&executable); - command.arg("--safe-mode"); - command.arg("--ax-screen-reader"); - command.cwd(std::env::temp_dir()); - let mut child = match pair.slave.spawn_command(command) { - Ok(child) => child, - Err(error) => { - return unavailable( - "claude", - "Claude Code /usage", - format!("Claude Code is unavailable: {error}"), - ); - } - }; - drop(pair.slave); - let (tx, rx) = mpsc::channel::>(); - thread::spawn(move || { - let mut buffer = [0_u8; 8192]; - loop { - match reader.read(&mut buffer) { - Ok(0) | Err(_) => break, - Ok(count) => { - if tx.send(buffer[..count].to_vec()).is_err() { - break; - } - } - } - } - }); - - let mut bytes = Vec::new(); - wait_for_claude_startup(&rx, &mut bytes); - if writer.write_all(b"/usage\r").is_err() || writer.flush().is_err() { - let _ = child.kill(); - let _ = child.wait(); - return unavailable( - "claude", - "Claude Code /usage", - "Could not request Claude Code usage", - ); - } - - let deadline = Instant::now() + CLAUDE_TIMEOUT; - let mut last_output = Instant::now(); - loop { - if Instant::now() >= deadline { - break; - } - match rx.recv_timeout(Duration::from_millis(250)) { - Ok(chunk) => { - last_output = Instant::now(); - let remaining = OUTPUT_LIMIT_BYTES.saturating_sub(bytes.len()); - bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); - if claude_capture_complete(&bytes) { - break; - } - } - Err(mpsc::RecvTimeoutError::Timeout) => { - if claude_required_windows_available(&bytes) - && last_output.elapsed() >= Duration::from_millis(900) - { - break; - } - } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - } - } - let _ = child.kill(); - let _ = child.wait(); - let output = strip_terminal_sequences(&String::from_utf8_lossy(&bytes)); - parse_claude_usage_text(&output) - .unwrap_or_else(|error| unavailable("claude", "Claude Code /usage", error)) -} - -fn wait_for_claude_startup(rx: &mpsc::Receiver>, bytes: &mut Vec) { - let deadline = Instant::now() + CLAUDE_STARTUP_TIMEOUT; - let mut saw_banner = false; - let mut last_output = Instant::now(); - loop { - if Instant::now() >= deadline { - break; - } - match rx.recv_timeout(Duration::from_millis(100)) { - Ok(chunk) => { - last_output = Instant::now(); - let remaining = OUTPUT_LIMIT_BYTES.saturating_sub(bytes.len()); - bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); - saw_banner = strip_terminal_sequences(&String::from_utf8_lossy(bytes)) - .contains("Claude Code v"); - } - Err(mpsc::RecvTimeoutError::Timeout) => { - if saw_banner && last_output.elapsed() >= Duration::from_millis(400) { - break; - } - } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - } - } -} - -fn claude_capture_complete(bytes: &[u8]) -> bool { - if !claude_required_windows_available(bytes) { - return false; - } - let output = strip_terminal_sequences(&String::from_utf8_lossy(bytes)); - let Ok(status) = parse_claude_usage_text(&output) else { - return false; - }; - status - .credits - .as_ref() - .and_then(|credits| credits.reset_description.as_ref()) - .is_some() -} - -fn claude_required_windows_available(bytes: &[u8]) -> bool { - let output = strip_terminal_sequences(&String::from_utf8_lossy(bytes)); - let Ok(status) = parse_claude_usage_text(&output) else { - return false; - }; - status.windows.iter().any(|window| window.id == "current") - && status.windows.iter().any(|window| window.id == "weekly") -} - -fn parse_claude_usage_text(output: &str) -> Result { - #[derive(Clone)] - enum Section { - Current, - Weekly, - ModelWeekly(String), - Credits, - } - let mut section = None; - let mut windows: Vec = Vec::new(); - let mut credits: Option = None; - let mut credit_reset = None; - let mut pending_percent: Option = None; - let mut plan = None; - - // PTY redraws can delimit cells with a bare carriage return. Splitting only on `lines()` - // made the current-session heading and percentage intermittently share one logical line. - for raw_line in output.split(['\n', '\r']) { - let line = raw_line.trim(); - if line.contains(" · Claude ") && plan.is_none() { - plan = line - .split(" · ") - .nth(1) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - } - if line == "Current session" { - section = Some(Section::Current); - pending_percent = None; - continue; - } - if line == "Current week (all models)" { - section = Some(Section::Weekly); - pending_percent = None; - continue; - } - if line.starts_with("Current week (") { - let model = line - .trim_start_matches("Current week (") - .trim_end_matches(')') - .trim() - .to_string(); - section = Some(Section::ModelWeekly(model)); - pending_percent = None; - continue; - } - if line == "Usage credits" { - section = Some(Section::Credits); - pending_percent = None; - continue; - } - if let Some(percent) = parse_used_percent(line) { - pending_percent = Some(percent); - continue; - } - if line.starts_with("Resets ") { - let reset = line.trim_start_matches("Resets ").trim().to_string(); - match (§ion, pending_percent) { - (Some(Section::Current), Some(used_percent)) => upsert_window( - &mut windows, - "current", - "Current window", - used_percent, - Some(reset), - ), - (Some(Section::Weekly), Some(used_percent)) => upsert_window( - &mut windows, - "weekly", - "Weekly window", - used_percent, - Some(reset), - ), - (Some(Section::ModelWeekly(model)), Some(used_percent)) => upsert_window( - &mut windows, - &format!("weekly_model_{}", quota_id_component(model)), - &format!("{model} weekly window"), - used_percent, - Some(reset), - ), - (Some(Section::Credits), _) => { - if credit_reset.is_none() { - credit_reset = Some(reset); - } - if let Some(balance) = credits.as_mut() { - if balance.reset_description.is_none() { - balance.reset_description.clone_from(&credit_reset); - } - } - } - _ => {} - } - pending_percent = None; - continue; - } - let Some(used_percent) = pending_percent else { - continue; - }; - if matches!(§ion, Some(Section::Credits)) && line.contains(" / $") { - let (used_amount, limit_amount) = parse_credit_amounts(line); - if credit_reset.is_none() { - credit_reset = line - .split("Resets ") - .nth(1) - .map(str::trim) - .map(str::to_string); - } - credits = Some(ProviderCreditBalance { - used_percent: Some(used_percent), - remaining_percent: Some((100.0 - used_percent).clamp(0.0, 100.0)), - used_amount, - limit_amount, - currency: Some("USD".to_string()), - reset_description: credit_reset.clone(), - }); - pending_percent = None; - } - } - - if !windows.iter().any(|window| window.id == "current") - || !windows.iter().any(|window| window.id == "weekly") - { - return Err( - "Claude Code did not expose complete current and weekly quota windows. Open Claude Code and run /usage." - .to_string(), - ); - } - windows.sort_by_key(|window| match window.id.as_str() { - "current" => 0, - "weekly" => 1, - _ => 2, - }); - Ok(ProviderQuotaStatus { - provider: "claude".to_string(), - status: "ready".to_string(), - source: "Claude Code /usage".to_string(), - checked_at: chrono::Utc::now().to_rfc3339(), - plan, - windows, - credits, - reset_credits: None, - message: None, - }) -} - -fn quota_id_component(value: &str) -> String { - let normalized = value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character.to_ascii_lowercase() - } else { - '_' - } - }) - .collect::(); - normalized.trim_matches('_').to_string() -} - -fn parse_used_percent(line: &str) -> Option { - if !line.ends_with("used") { - return None; - } - line.split_whitespace() - .find_map(|part| part.strip_suffix('%')?.parse::().ok()) - .map(|value| value.clamp(0.0, 100.0)) -} - -fn parse_credit_amounts(line: &str) -> (Option, Option) { - let before_spent = line.split("spent").next().unwrap_or(line); - let mut amounts = before_spent.split('/').filter_map(|part| { - let value = part - .chars() - .filter(|character| character.is_ascii_digit() || *character == '.') - .collect::(); - value.parse::().ok() - }); - (amounts.next(), amounts.next()) -} - -fn upsert_window( - windows: &mut Vec, - id: &str, - label: &str, - used_percent: f64, - reset_description: Option, -) { - let window = ProviderQuotaWindow { - id: id.to_string(), - label: label.to_string(), - used_percent, - remaining_percent: (100.0 - used_percent).clamp(0.0, 100.0), - window_duration_minutes: None, - resets_at_unix: None, - reset_description, - }; - if let Some(existing) = windows.iter_mut().find(|window| window.id == id) { - *existing = window; - } else { - windows.push(window); - } -} - -fn strip_terminal_sequences(input: &str) -> String { - let bytes = input.as_bytes(); - let mut output = String::with_capacity(input.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == 0x1b { - index += 1; - if index >= bytes.len() { - break; - } - match bytes[index] { - b'[' => { - index += 1; - while index < bytes.len() { - let byte = bytes[index]; - index += 1; - if (0x40..=0x7e).contains(&byte) { - break; - } - } - } - b']' => { - index += 1; - while index < bytes.len() { - if bytes[index] == 0x07 { - index += 1; - break; - } - if bytes[index] == 0x1b - && index + 1 < bytes.len() - && bytes[index + 1] == b'\\' - { - index += 2; - break; - } - index += 1; - } - } - _ => index += 1, - } - continue; - } - let byte = bytes[index]; - index += 1; - match byte { - b'\r' => output.push('\n'), - b'\n' | b'\t' => output.push(byte as char), - 0x20..=0x7e => output.push(byte as char), - _ if byte >= 0x80 => { - let start = index - 1; - let width = utf8_char_width(byte); - if start + width <= bytes.len() { - if let Ok(value) = std::str::from_utf8(&bytes[start..start + width]) { - output.push_str(value); - index = start + width; - } - } - } - _ => {} - } - } - output -} - -fn utf8_char_width(byte: u8) -> usize { - match byte { - 0x00..=0x7f => 1, - 0xc0..=0xdf => 2, - 0xe0..=0xef => 3, - _ => 4, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn codex_parser_projects_remaining_windows_without_account_identity() { - let response = json!({ - "id": 1, - "result": { - "rateLimitsByLimitId": { - "codex": { - "limitId": "codex", - "primary": { - "usedPercent": 92, - "windowDurationMins": 10080, - "resetsAt": 1788750854 - }, - "planType": "pro" - }, - "spark": { - "limitName": "Spark", - "primary": { - "usedPercent": 2, - "windowDurationMins": 300, - "resetsAt": 1788470093 - } - } - }, - "rateLimitResetCredits": {"availableCount": 1}, - "accountId": "must-not-be-projected" - } - }); - let quota = parse_codex_rate_limits(&response).expect("Codex quota"); - assert_eq!(quota.plan.as_deref(), Some("pro")); - assert_eq!(quota.reset_credits, Some(1)); - assert_eq!(quota.windows.len(), 1); - assert_eq!(quota.windows[0].id, "codex.primary"); - assert_eq!(quota.windows[0].remaining_percent, 8.0); - assert!(quota - .windows - .iter() - .all(|window| !window.label.contains("Spark"))); - assert!(!serde_json::to_string("a) - .unwrap() - .contains("must-not-be-projected")); - } - - #[test] - fn claude_parser_uses_provider_display_and_never_infers_missing_windows() { - let output = "Claude Code v2.1.236\nOpus 5 · Claude Team · Example\n\ -Current session\n3% 3% used\nResets 2:20am (Asia/Calcutta)\n\ -Current week (all models)\n32% 32% used\nResets Sep 6 at 5:30pm (Asia/Calcutta)\n\ -Current week (Fable)\n4% 4% used\nResets Sep 6 at 5:30pm (Asia/Calcutta)\n\ -Usage credits\n0% 0% used\n$0.00 / $150.00 spent · Resets Oct 1 (Asia/Calcutta)\n"; - let quota = parse_claude_usage_text(output).expect("Claude quota"); - assert_eq!(quota.plan.as_deref(), Some("Claude Team")); - assert_eq!(quota.windows[0].remaining_percent, 97.0); - assert_eq!(quota.windows[1].remaining_percent, 68.0); - assert_eq!(quota.windows[2].label, "Fable weekly window"); - assert_eq!(quota.windows[2].remaining_percent, 96.0); - assert_eq!(quota.credits.as_ref().unwrap().limit_amount, Some(150.0)); - assert!(parse_claude_usage_text("Usage unavailable").is_err()); - } - - #[test] - fn claude_parser_keeps_credit_reset_when_terminal_wraps_it_to_the_next_line() { - let output = "Current session\n12% used\nResets 2:20am (Asia/Calcutta)\n\ -Current week (all models)\n33% used\nResets Sep 6 at 5:30pm (Asia/Calcutta)\n\ -Usage credits\n0% used\n$0.00 / $150.00 spent\nResets Oct 1 (Asia/Calcutta)\n"; - let quota = parse_claude_usage_text(output).expect("Claude quota"); - assert_eq!(quota.windows.len(), 2); - assert_eq!( - quota.credits.as_ref().unwrap().reset_description.as_deref(), - Some("Oct 1 (Asia/Calcutta)") - ); - } - - #[test] - fn claude_parser_does_not_misclassify_in_place_current_reset_as_credit_reset() { - let output = "Current session\n12% used\nResets 2:20am (Asia/Calcutta)\n\ -Current week (all models)\n33% used\nResets Sep 6 at 5:30pm (Asia/Calcutta)\n\ -Usage credits\n0% used\n$0.00 / $150.00 spent · Resets Oct 1 (Asia/Calcutta)\n\ -Resets 2:19am (Asia/Calcutta)\nCurrent week (all models)\n33% used\n"; - let quota = parse_claude_usage_text(output).expect("Claude quota"); - assert_eq!( - quota.credits.as_ref().unwrap().reset_description.as_deref(), - Some("Oct 1 (Asia/Calcutta)") - ); - } - - #[test] - fn claude_parser_handles_bare_carriage_return_redraws() { - let output = "Current session\r12% used\rResets 2:20am (Asia/Calcutta)\r\ -Current week (all models)\r34% used\rResets Sep 6 at 5:30pm (Asia/Calcutta)\r\ -Usage credits\r0% used\r$0.00 / $150.00 spent\rResets Oct 1 (Asia/Calcutta)\r"; - let quota = parse_claude_usage_text(output).expect("Claude quota"); - assert_eq!(quota.windows[0].id, "current"); - assert_eq!(quota.windows[0].remaining_percent, 88.0); - assert_eq!(quota.windows[1].id, "weekly"); - assert_eq!(quota.windows[1].remaining_percent, 66.0); - } - - #[test] - fn claude_capture_can_finish_after_required_windows_settle_without_credits() { - let output = b"Current session\n12% used\nResets 2:20am\n\ -Current week (all models)\n34% used\nResets Sep 6 at 5:30pm\n"; - assert!(claude_required_windows_available(output)); - assert!(!claude_capture_complete(output)); - } - - #[test] - fn terminal_sequence_stripping_preserves_unicode_and_lines() { - let cleaned = strip_terminal_sequences("\u{1b}[2KClaude · 3% used\r\nResets 2:20am\u{7}"); - assert!(cleaned.contains("Claude · 3% used")); - assert!(cleaned.contains("Resets 2:20am")); - assert!(!cleaned.contains('\u{1b}')); - } -} diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 28e0aac5..c8732dcf 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -41,17 +41,10 @@ repairs run as idempotent migrations guarded by feature flags. The groups: ## Persistence invariants -- **Claude/Codex/Grok Usage accounting is externalized.** The bundled, pinned - `ccusage` sidecar reads agent transcripts locally and supplies the Usage - chart's Claude/Codex/Grok period, model, session, token-class, and cost data. The - desktop caches only a short-lived normalized snapshot; SQLite is not a second - canonical usage ledger. -- **Devin remains explicitly separate.** Upstream `ccusage` cannot access - Devin's cloud-side usage, so the chart retains only CodeVetter's existing - Devin rows from SQLite, queried independently so a Devin failure cannot hide - ccusage data. Provider remaining-usage and quota telemetry is a - separate metric family. Claude live quota resolves configured profile files - and Keychain candidates by freshest credential expiry. +- **General agent usage is owned by ContextDaddy.** CodeVetter no longer exposes + its historical usage chart or provider allowance checks. Existing local + session rows remain for backward-compatible evidence/history reads; per-run + token and cost evidence remains attached to verification receipts. - **Legacy usage tables are retained, not maintained as production truth.** Historical Codex observation/coverage/projection tables remain in existing databases for non-destructive compatibility, but startup repair and ledger diff --git a/docs/architecture/how-it-works.md b/docs/architecture/how-it-works.md index 5f37c410..f6367bb6 100644 --- a/docs/architecture/how-it-works.md +++ b/docs/architecture/how-it-works.md @@ -111,7 +111,7 @@ runtime correctness. Review prompts receive this context with revision, freshness, trust, and limitation metadata. The same distinction applies to external tools: Gitleaks, cargo-audit, -cargo-llvm-cov, ccusage, Playwright, Git, and provider CLIs remain separately +cargo-llvm-cov, Playwright, Git, and provider CLIs remain separately identified evidence sources. Tool presence alone is not a passing claim. ## Security and privacy boundaries @@ -129,7 +129,7 @@ identified evidence sources. Tool presence alone is not a passing claim. ## Distribution The production application is a sandboxed, hardened native macOS bundle. It -ships the exact `codevetter`, `codevetter-mcp`, and ccusage companions and +ships the exact `codevetter` and `codevetter-mcp` companions and uses Sparkle for updates. Publication fails closed unless the exact archive passes Developer ID signing, diff --git a/docs/architecture/ipc-and-commands.md b/docs/architecture/ipc-and-commands.md index 25b1c165..513db369 100644 --- a/docs/architecture/ipc-and-commands.md +++ b/docs/architecture/ipc-and-commands.md @@ -33,7 +33,7 @@ Rust command implementations live under | Review and fixes | `review.rs`, `local_qualification.rs` | Review workbench | | Testing and performance | `trex_preview.rs`, `warm_verification*.rs`, `performance_bridge.rs` | Testing and Performance | | Repo Unpack | `unpack*.rs`, `structural_graph/`, `graph_trust.rs` | Repo Unpack | -| Usage and accounts | `local_usage.rs`, `accounts.rs`, `sessions.rs` | Usage and Settings | +| Accounts | `accounts.rs`, `sessions.rs` | Settings | | Settings and rubrics | `preferences.rs`, `rubric_settings.rs`, `setup.rs` | Settings | | MCP access | `mcp_access.rs`, `mcp/` | Settings and `codevetter-mcp` | | History and memories | `history*.rs`, `agent_memories.rs` | Repo Unpack and Settings | diff --git a/docs/architecture/native-migration-matrix.md b/docs/architecture/native-migration-matrix.md index 4e222d13..62e9cc16 100644 --- a/docs/architecture/native-migration-matrix.md +++ b/docs/architecture/native-migration-matrix.md @@ -5,6 +5,10 @@ description: The no-regression contract for transferring every current CodeVette # Native macOS migration matrix +> The September 2026 ownership transfer moved general agent usage and allowance +> to ContextDaddy. CodeVetter retains only per-verification token/cost evidence +> and local History recovery. + The native macOS client is the sole desktop product, not a reduced viewer. The retired Tauri application remains relevant only as the upgrade and rollback fixture. Production publication still fails closed unless output, interaction, @@ -21,7 +25,7 @@ reachable in the current product. | Current surface | Current retained capability | Native destination | Current migration state | Replacement gate | | --- | --- | --- | --- | --- | -| Usage | Local Claude, Codex, Grok, and bundled `ccusage` history; separate Devin and provider quota telemetry; agent/model filters, trends, and adapter health | **Usage** workspace | The canonical Rust `LocalUsageReport` powers `codevetter usage` and the native local-usage desk. Day/week/month granularity; 1w/30d/90d/all-time windows shared by the chart, totals, models, and sessions; Claude/Codex/Grok filters; generated/cache/cost/session totals; model mix; adapter health; refresh; stale/unavailable validation; dark/light evidence; XCUITest; and a 365-period/100-session render gate are implemented. Indexed Devin evidence remains visibly separate and is never folded into local provider totals or described as live quota. Native Usage settings and `codevetter history-roots` share `codevetter.history-roots/v1` for bounded additional Codex homes. Credential-safe live provider telemetry remains separate and fail-closed when unavailable. | Same providers, time ranges, filters, totals, quota boundaries, empty/error states, and refresh behavior; bounded chart rendering and no implied verification claim | +| Usage (retired) | General agent history and provider allowance | ContextDaddy, not CodeVetter | The native Usage workspace, general `codevetter usage` and `codevetter quota` commands, and bundled ccusage helper were removed locally. CodeVetter retains per-verification token/cost receipts and local History recovery. | ContextDaddy owns separate local-history, provider-allowance, Devin, and telemetry evidence with explicit unavailable states. | | Repo Unpack | Repository selection and snapshots; Overview, Handoff, Rules, Analysis, Activity, Inventory, Graph, and Delta; exports and comparisons | **Repo Unpack** workspace with an AppKit source outline and native inspectors | Native and CLI create the same persisted, model-free snapshot through the shared Rust scanner and `codevetter.unpack-scan/v1`, with supervised cancellation in the native client. The client receipt strips the raw file list and exposes separate full-scan and SQLite-persistence profiles. Native inspectors preserve repository and commit identity, history limits, outline bounds, and explicit topology/health non-proof labels. Native and CLI export the same Rust-rendered Markdown, offline HTML, graph JSON, agent-context Markdown, and repository-memory Markdown through `codevetter.unpack-export/v1`. The Graph desk and `codevetter unpack --operation query` consume one `codevetter.repo-query/v2` read-only receipt over the same structural-graph and history services used by MCP; Swift performs no ranking or traversal. Model synthesis remains available through the Rust and agent surfaces without creating a second UI authority. | Same persisted identities and exports; stable graph/history queries; large-repository bounds; no topology-as-runtime-proof regression | | Review | Exact PR/range and task intent; source-qualified findings, coverage gaps, blast radius, memory graph, deterministic manifests, executable evidence links, fix handoff, X-Ray/export, synthetic QA and intent diagnostics | **Review** verification workspace | Exact-change plan/execute/cancel/receipt runs through the Tauri-independent `codevetter.verification-command/v1` Rust application service and supervised CLI worker. One bounded request id now correlates ordered `codevetter.progress/v2` events, process-level cancellation, and the distinct preflight/final canonical receipt; native ignores foreign progress and rejects mismatched terminal receipts. Optional repository-contained Markdown specs are hashed and parsed by the Rust core; native preflight exposes their explicit requirements and requires a fresh exact selection binding before execution. Final receipts render requirement-level review/executable/verified coverage and exact evidence references. Native Review and `codevetter check --agent cross` can request independent sequential Claude and Codex passes over the same immutable target and original context. Rust never sends first-review output to the second reviewer, reconciles only exact source-qualified identities, preserves unique and conflicting findings, and fails closed without a composite claim on missing executors, target drift, incomplete coverage, or interruption. Reviewer manifests, qualified candidates, readiness, duration, and availability limits persist in `codevetter.cross-review/v1`; read-only MCP projects the same receipt without execution authority. Cross-review remains optional until provider-backed caught-bug recall, false-positive, latency, and usage benchmarking passes. The native receipt promotes Rust-qualified findings above raw JSON, preserves severity/confidence/source line/suggestion, opens only repository-contained recorded paths, and exports the unmodified canonical JSON. A separate proof map projects the canonical readiness, deterministic manifest and qualification counts, bounded memory/blast-radius context, revision-qualified structural graph, recorded QA, evidence candidates, and procedure gates while explicitly separating execution proof from navigation leads. The same Rust result now carries `codevetter.review-intent-diagnostic/v1`: native renders the captured goal, deterministic changed-surface classes, source-review and QA signals, gaps, evidence chronology, and a fail-closed closure state that always requires human disposition. Recorded QA artifacts can be explicitly revealed through validated repository-relative or existing absolute paths; a legacy QA pass is never promoted to revision-exact proof. Review now hands the exact repository, range or pull request, and discovery scope to Testing while clearing stale proof and prior execution consent. This enforces the canonical boundary that Testing owns browser execution and Review consumes its evidence instead of recreating two runner authorities. Persisted reviews can build and atomically save the same fail-closed JSON, Markdown, or offline HTML Agent PR X-Ray through native UI and `codevetter xray`; public-source confirmation, current-preview identity, the Rust sanitizer, and optional per-finding suggestion approval remain explicit gates. Selected persisted findings can also become a bounded `codevetter.agent-fix-packet/v1` through native UI and `codevetter fix-packet`, carrying exact task/acceptance context, qualified source locations, recorded evidence to preserve, route advice, limitations, and copyable Markdown without claiming the fix is correct. Native UI, CLI, and local agent invocation now share an explicit-consent `codevetter.fix-attempt/v1`: Rust materializes the recorded head as a detached app-data worktree, supervises one selected agent, bounds the diff, runs `git diff --check`, reruns the recorded correctness target, source-qualifies a `WORKTREE` review, and classifies each selected finding as fixed, reproduced, or unchecked. The worktree remains uncommitted and retained until separately confirmed discard; there is no merge or push action. Typed decoding, exact CLI arguments, focused rendering, and true-black visual evidence are recorded. Native Plan, Execute, and Cancel expose keyboard and accessibility contracts; a Rust `no_confidence` preflight now fails closed, disables execution, names the missing binding, and cannot render an empty finding list as a green pass. A real-agent isolated-fix plus saved-flow post-fix rerun smoke remains pending; saved QA targets, repository spec discovery, and rerun preparation now live in Testing through the shared Rust QA-workspace receipt. | Equivalent Rust receipt semantics, every review state and export, source navigation, keyboard operation, cancellation, and no-confidence behavior | | Testing | Direct preview verification, saved QA workflows and targets, Playwright spec discovery, post-fix rerun setup, changed-capability checks, scenarios, portfolio planning, PR watchers, confirmation, execution progress, failures, receipts, and limitations | **Testing** verification workspace | Direct PR/range preview verification runs through `codevetter trex` with explicit network confirmation, supervised cancellation, schema and verdict/exit validation, preview identity, derived routes, an optional selected saved route and goal, journey/artifact/console evidence, raw JSON, limitations, and a Runs handoff. `codevetter.qa-workspace/v1` is shared by native Testing and `codevetter qa`; the scoped `qa_workspace_inspect` MCP tool exposes the same read-only setup. Rust projects safe legacy fields into a separate native preference, never projects credential-bearing storage-state paths, refuses arbitrary external-command execution, discovers bounded repository Playwright specs without executing them, and prepares the same-flow post-fix rerun without restoring network consent or starting the browser. The shared Rust `evidence-scope` planner is projected through `codevetter scope --consumer testing` and native Testing for deterministic flow, exact-change, and bounded codebase target portfolios. Warm changed proof runs through `codevetter warm`; differential verification runs through `codevetter differential` with a separate preparation gate and the rule that comparison evidence never creates pass evidence. Scenario authoring runs through `codevetter scenario` and the native Scenario Foundry over the incumbent Rust bridge: free/local generation creates expiring candidates, validation and dry-run remain non-persistent, acceptance rechecks the candidate hash, selected destinations, and replacement approval, and rejection writes no project files. Incoming PR watcher configuration, bounded run history, foreground polling, and exact-head recovery now share `codevetter watcher`: native scheduling lasts only for the open app session; consent is not persisted; each automatic poll looks across open PRs, executes only a newly arrived PR or an existing PR with a new head SHA, may contact GitHub, execute isolated project code and the configured agent, post commit statuses, and stays supervised until receipts persist. The Rust boundary validates and fetches the exact immutable `refs/pull//head` object without changing the user's branch, index, worktree, `FETCH_HEAD`, or durable refs; Node projects install with their declared pnpm/npm/Yarn/Bun lockfile contract; and status authentication can reuse existing `gh` authority in memory without persisting or logging the token. Automatic polls skip unchanged PR heads, while a separately confirmed CLI/native Retry action reruns one exact currently open PR and persists a replacement attempt. MCP discovery never starts these runtimes. Exact Rust/CLI/native arguments and schemas, a local bare-remote materialization test, safe non-network configuration smokes, real-repository warm/differential failure-boundary smokes, XCUITest reachability for every Testing workspace, Swift watcher rendering and supervised consent/execution contracts, dark warm/differential/scenario/watcher visual evidence, and bounded direct-preview render gates are recorded. The [shared surface-parity receipt](https://github.com/Codevetter/codevetter/blob/main/evidence/verification/native-surface-parity-2026-09-01.md) proves that one repository-owned fixture preserves the same Rust, CLI, native, and read-only MCP evidence-scope semantics. The [live watcher qualification](https://github.com/Codevetter/codevetter/blob/main/evidence/verification/native-watcher-live-qualification-2026-09-01.md) proves one explicitly approved PR head through exact fetch, pnpm install, repository lint, conservative verdict, persistence, GitHub status posting, and a second same-head explicit recovery attempt. Final owner acceptance remains pending | Same `evidence-scope/v1` resolution and canonical receipts, bounded coverage, confirmation gate, cancellation, watcher lifecycle, and browser evidence | @@ -73,7 +77,7 @@ The visual migration cannot fork product semantics. The Rust capability registry, command contracts, and receipt schemas are projected through: - native UI commands and inspectors; -- `codevetter check`, `fix-packet`, `fix`, `xray`, `scope`, `trex`, `warm`, `differential`, `scenario`, `performance`, `usage`, `history-roots`, `memories`, `ops`, `unpack --operation scan|list|inspect|compare|export|query`, `settings`, `mcp`, `collect`, and `capabilities`, plus the structural +- `codevetter check`, `fix-packet`, `fix`, `xray`, `scope`, `trex`, `warm`, `differential`, `scenario`, `performance`, `history-roots`, `memories`, `ops`, `unpack --operation scan|list|inspect|compare|export|query`, `settings`, `mcp`, `collect`, and `capabilities`, plus the structural graph driver; - repository-scoped MCP graph, history, review-preparation, archaeology, and capability-catalog tools; diff --git a/docs/development/native-macos.md b/docs/development/native-macos.md index 8eb41a58..165685ed 100644 --- a/docs/development/native-macos.md +++ b/docs/development/native-macos.md @@ -64,21 +64,17 @@ visible model/session rows. This measures canonical JSON decoding and native host rendering, not ccusage process startup, filesystem scanning, window-server frame pacing, or interactive scrolling. -The 30 ms decode and 50 ms render limits remain the local/reference Usage -budgets. GitHub's current `xcode-27` 3-core hosted runner applies an explicit -1.5× envelope to those same checks; every benchmark line records the scale and -effective limits. This keeps hosted scheduling and runner-class variance from -silently redefining the workstation target while retaining a deterministic CI -ceiling. +The historical Usage rendering gate was retired with the general agent-usage +dashboard. Current native performance gates cover the remaining verification +workspaces and receipts. Additional Codex history recovery is checked at -`evidence/verification/native-history-roots-2026-09-02.md`. Native Usage +`evidence/verification/native-history-roots-2026-09-02.md`. Native History settings and `codevetter history-roots` share one Rust-owned bounded receipt. Selected `sessions` or `archived_sessions` directories normalize to their canonical Codex home; unrelated directories, malformed paths, duplicates, and more than 16 roots fail closed. Configuration reports availability but never -reads or deletes transcript content, and reconciliation remains a separate -explicit Usage action. +reads or deletes transcript content; general usage now belongs to ContextDaddy. Native memory inspection is checked at `evidence/verification/native-memories-2026-09-02.md`. Native Settings and @@ -151,7 +147,7 @@ Read-only MCP retains no execution authority. `pnpm native:package:qualify` consumes an XcodeBuildMCP-produced Release app; it does not invoke Xcode itself. The qualifier reuses the existing release sidecar builders, stages a new bundle under `artifacts/native-package`, embeds -`codevetter`, `codevetter-mcp`, `ccusage`, the runtime performance capsule, and +`codevetter`, `codevetter-mcp`, the runtime performance capsule, and Sparkle, then verifies hardened signatures and emits ZIP/DMG archives plus a machine-readable receipt. The visible app remains `CodeVetter.app`, while its host executable is `CodeVetterNative` so the lowercase `codevetter` CLI remains @@ -357,11 +353,8 @@ does not write a system or application preference. retains the detached worktree, supervises the agent, reruns the recorded correctness target, re-reviews `WORKTREE`, and requires a separate confirmed discard. No native or CLI merge path exists. -- Local usage invokes `codevetter usage`, which reuses the Tauri Rust service - and opens the existing SQLite database read-only only when it is present. - Ready, stale, and unavailable reports remain inspectable only when exit 0, 1, - or 2 agrees. ccusage accounts for Claude, Codex, and Grok; Devin and live - provider quotas remain explicitly separate and are not inferred by Swift. +- General agent usage and provider allowances are now ContextDaddy's concern. + CodeVetter retains token and cost evidence only within verification receipts. - Repo Unpack history invokes `codevetter unpack`, which opens the existing SQLite database read-only and projects stored snapshot identities plus a Rust-trimmed inventory. Swift does not query SQLite, recompute graph/history diff --git a/docs/development/setup.md b/docs/development/setup.md index a6deee33..998e4052 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -57,7 +57,6 @@ pnpm core:build pnpm core:test pnpm core:prepare-cli pnpm core:prepare-mcp -pnpm core:prepare-ccusage pnpm core:qualify-cli ``` diff --git a/docs/knowledge/tooling-secrets-and-supply-chain.md b/docs/knowledge/tooling-secrets-and-supply-chain.md index 352e08be..6ab434d6 100644 --- a/docs/knowledge/tooling-secrets-and-supply-chain.md +++ b/docs/knowledge/tooling-secrets-and-supply-chain.md @@ -61,7 +61,7 @@ limitations are in the [tracked evidence](https://github.com/Codevetter/codevett **MIT**, and the core scanner's license is unchanged. Fully offline: no DB, no network, rules embedded or from `.gitleaks.toml`. Emits SARIF. Static Go binary, -same integration shape as the existing `ccusage` sidecar. +the same bounded sidecar integration shape used by the native package. One licensing nuance worth stating precisely, because it is widely misreported: the commercial relicensing applies **only to `gitleaks/gitleaks-action` v2.0.0+** diff --git a/docs/operations/ci.md b/docs/operations/ci.md index 397a438d..a70b67b5 100644 --- a/docs/operations/ci.md +++ b/docs/operations/ci.md @@ -19,7 +19,7 @@ The `verify` job installs locked pnpm dependencies and stable Rust, then runs: health gates. 3. Astro public-indexing build and agent-surface verification. 4. Hermetic automation, corpus, retrieval, and core-tool contracts. -5. CLI, MCP, and pinned ccusage companion preparation. +5. CLI and MCP companion preparation. 6. Rust library MCP tests, MCP binary and stdio tests, and CLI tests. The Rust dependency graph contains no Tauri, Wry, WebKit, GTK, or windowing diff --git a/docs/operations/release-pipeline.md b/docs/operations/release-pipeline.md index 805087cb..25355e94 100644 --- a/docs/operations/release-pipeline.md +++ b/docs/operations/release-pipeline.md @@ -17,7 +17,7 @@ apps/macos/Config/Shared.xcconfig version changes on main -> auto-release.yml creates the v tag and a DRAFT release, dispatches release.yml, and waits for that run -> release.yml calls native-production-qualification.yml at that exact tag - -> Xcode Release build + Rust/ccusage companions + -> Xcode Release build + Rust companions -> Developer ID signing + hardened runtime + notarization + stapling -> isolated incumbent-to-native upgrade, relaunch, data, and rollback proof -> Sparkle archive and EdDSA appcast verification diff --git a/docs/operations/runbooks/cut-a-release.md b/docs/operations/runbooks/cut-a-release.md index 591ef48d..9cad37ad 100644 --- a/docs/operations/runbooks/cut-a-release.md +++ b/docs/operations/runbooks/cut-a-release.md @@ -61,7 +61,7 @@ Confirm: - expected short version and build; - Developer ID signature; - notarization staple and Gatekeeper acceptance; -- bundled `codevetter`, `codevetter-mcp`, and ccusage companions; +- bundled `codevetter` and `codevetter-mcp` companions; - existing stable records remain available. Avoid foreground launch automation on the operator's active desktop. Hosted diff --git a/docs/product/capabilities.md b/docs/product/capabilities.md index cde4e5c8..4b060694 100644 --- a/docs/product/capabilities.md +++ b/docs/product/capabilities.md @@ -25,7 +25,6 @@ Every entry declares: | Local verification and review | Read and execute | Read and execute | Persisted receipts read-only; local fix requires explicit CLI consent | | Runtime preview and QA | Read and execute | Read and execute | Scope and saved-workflow inspection only | | Performance verification | Read and execute | Read and execute | Scope inspection only | -| Local Claude, Codex, Grok, and Devin usage evidence | Read | Read | Not exposed | | Additional Codex history roots | Read and configure | Read and configure | Not exposed | | Repository snapshot and exports | Read and execute | Read and execute | Stored-index queries only | | Structural graph and history | Read | Read | Repository-scoped read-only tools | @@ -42,7 +41,6 @@ Every entry declares: | Git | Exact revision, diff, history, and isolated worktrees | Selected repository only | | Tree-sitter | Syntax-aware repository graph | Navigation evidence, not runtime proof | | Claude and Codex CLIs | Independent model review or explicit fix attempt | Optional local provider tools | -| ccusage 20.0.20 | Offline Claude, Codex, and Grok accounting | Bundled and pinned; not cloud quota | | Playwright | Explicit browser journey evidence | Admitted project or bundled runtime | | Gitleaks 8.30.1 | Secret-pattern evidence | Bounded selected change; raw secrets are not retained | | cargo-audit 0.22.2 | Offline Rust advisory evidence | Pinned RustSec snapshot | @@ -58,8 +56,8 @@ decisions, prerequisites, and qualification criteria stay visible. - Hardened execution isolation with Apple container tooling, after read-only mount planning, no-network attestation, cancellation, bounded output, and real-workload regression evidence pass. -- Credential-safe live provider quota telemetry, separate from local token and - cost accounting. +- General agent usage and provider allowance are owned by ContextDaddy. Per-run + token and cost evidence remains part of CodeVetter verification receipts. - Bounded external-collector receipt inspection in native UI and MCP without granting collector execution to agents. - A live Agent Island helper only as a separately scoped side quest; it has no diff --git a/docs/product/surfaces.md b/docs/product/surfaces.md index 0d0bbcc6..34a84cef 100644 --- a/docs/product/surfaces.md +++ b/docs/product/surfaces.md @@ -1,24 +1,24 @@ --- title: Surfaces and navigation -description: The six native macOS sections and their shared CLI and MCP contracts. +description: The native verification workbench and its shared CLI and MCP contracts. sidebar: order: 2 --- # Surfaces and navigation -The native app uses one persistent macOS split-view shell and six sections. +The native app uses one persistent macOS split-view shell. Every page shares the same header, content width, spacing scale, evidence language, loading/empty/error treatment, and keyboard-sized click targets. | Section | Native source | Primary result | |---|---|---| -| Usage | `PremiumUsageView.swift` | Remaining provider allowance first, then bounded historical usage, then Devin's separate indexed history. Unavailable quota or Devin history is labelled unavailable, never zero. | | Explore | `NavigatorWorkspaceView.swift` | Read-only Git source, fuzzy file search, indexed search, declarations, history, and integrated Unpack. | | Review | `NavigatorWorkspaceView.swift`, `PremiumWorkbench.swift` | Pinned GitHub/local diffs and exact source links, alongside the existing executable verification and handoff receipts. | | Testing | `PremiumTestingView.swift` plus focused testing views | Preview, changed verification, scenarios, differential runs, warm verification, and opt-in PR watchers. | | Performance | `PremiumPerformanceView.swift` | Exact local workload, baseline/candidate measurements, limits, cleanup, and optimization verdict. | -| Settings | `PremiumSettingsView.swift` | Accounts, agents, MCP, rubrics, memories, usage roots, updater/about, and other configuration. | +| Runs | `PremiumWorkbench.swift` | Verification receipts, source identities, and recorded limitations. | +| Settings | `PremiumSettingsView.swift` | Accounts, agents, MCP, rubrics, memories, history roots, updater/about, and other configuration. | The app source lives in `apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/`. `ContentView.swift` @@ -41,7 +41,7 @@ codevetter check --range main...HEAD \ ``` Supporting commands cover scope resolution, T-Rex testing, performance, -differential verification, scenario compilation, Repo Unpack, usage, +differential verification, scenario compilation, Repo Unpack, settings, rubrics, memories, MCP readiness, X-Ray export, and isolated fix attempts. Run `codevetter --help` for the exact current contract. @@ -49,54 +49,14 @@ MCP remains read-only: it can inspect evidence and prepare bounded review context, but it cannot start a review, execute tests, approve a fix, alter settings, or publish anything. -## Unified Usage history +## Agent usage ownership -The Usage refinement tracked in [#290](https://github.com/Codevetter/codevetter/issues/290) -keeps the existing provider cards and combines history with model composition. -Model and Project grouping share the same time window, day/week/month buckets, -and Tokens/Cost/Cache reads selector. A neutral stacked timeline and its exact -breakdown use one cached projection; selecting a bar or inspection period scopes -the breakdown. Large histories fold older periods into an explicit Earlier bucket. - -Quota accents describe remaining allowance, while the separate even-use pace -label compares that allowance with time remaining until reset. Saved, expired, -invalid, and stale values remain neutral rather than asserting live status. -Local cost is reported/estimated USD, not subscription spend or provider quota. - -Optional ccusage Claude project records enrich the canonical daily ledger only -when attribution reconciles. Missing or inconsistent attribution stays visible -as Unattributed, including Codex activity; repository identity is never guessed. -The optional project scan has a five-second bound and cannot invalidate an -otherwise usable report. This refinement is implemented locally, not released. - -## Devin on the Usage desk - -Devin is indexed from its own SQLite session history and is never folded into -the ccusage totals, so it renders as its own panel on the page rather than as a -diagnostic. The panel follows the same 1w/30d/90d/all-time window selection as -the ccusage desk. - -`DevinUsageSummary.availability(for:)` separates three states the panel must not -conflate: an unreadable history reads `unavailable`, a readable history with no -sessions in the window reads `empty`, and anything else reads as activity. Stale -counters alongside a failed status still read `unavailable`. - -## Usage revalidation - -Usage keeps itself current while the section is open. `PremiumUsageView` -starts a poll that lives exactly as long as the visible section, so nothing -collects in the background after you navigate away. - -| Surface | Cadence | Why | -|---|---|---| -| Local history (`codevetter usage`) | 60s | Offline `ccusage` scan over agent logs. | -| Provider allowance (`codevetter quota`) | 60s | Spawns supervised `claude` and `codex` sessions that can take twenty seconds and reach the provider. The cadence is measured from the end of the previous collection. | - -Polling suspends while the app is not frontmost and revalidates immediately on -reactivation, so a backgrounded window never spawns provider sessions the -operator cannot see. A repeat collection whose `source_fingerprint` matches the -accepted report keeps that report rather than re-rendering identical data. The -header Refresh button always forces both reads. +The general agent-usage dashboard and provider allowance checks have moved to +ContextDaddy. CodeVetter no longer exposes a Usage workspace or general +`codevetter usage`/`codevetter quota` commands. Token and cost evidence attached +to an individual verification run remains in its receipt and Runs ledger. +CodeVetter's History settings and `history-roots` command are retained for its +existing local evidence archive and are not a general usage dashboard. ## Interaction policy diff --git a/knip.json b/knip.json index a7210b3b..6669d3ac 100644 --- a/knip.json +++ b/knip.json @@ -10,7 +10,6 @@ "project": ["src/**/*.{ts,astro,css}"] } }, - "ignoreDependencies": ["ccusage"], "ignoreBinaries": [ "actionlint", "cargo-deny", diff --git a/package.json b/package.json index 832533e7..81d21cca 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "core:fmt": "cargo fmt --manifest-path crates/codevetter-core/Cargo.toml --check", "core:prepare-cli": "node scripts/core-tools/prepare-cli-sidecar.mjs", "core:prepare-mcp": "node scripts/core-tools/prepare-mcp-sidecar.mjs", - "core:prepare-ccusage": "node scripts/core-tools/prepare-ccusage-sidecar.mjs", "core:prepare-collectors": "node scripts/core-tools/prepare-collector-sidecars.mjs", "core:qualify-cli": "node scripts/core-tools/verify-cli-release.mjs", "core:bench-mcp": "node scripts/core-tools/mcp-benchmark.mjs", @@ -135,7 +134,6 @@ "@biomejs/biome": "^2.5.13", "@friedemannsommer/lcov-parser": "8.0.0", "@size-limit/file": "13.0.3", - "ccusage": "20.0.20", "fast-xml-parser": "5.11.1", "husky": "^9.1.7", "jscpd": "5.0.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b71b177..7d5f9eba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@size-limit/file': specifier: 13.0.3 version: 13.0.3(size-limit@13.0.3) - ccusage: - specifier: 20.0.20 - version: 20.0.20 fast-xml-parser: specifier: 5.11.1 version: 5.11.1 @@ -307,36 +304,6 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} - '@ccusage/ccusage-darwin-arm64@20.0.20': - resolution: {integrity: sha512-TgvU1u9bjgBD+CuQqj55PNAl3LnrW9awCeXDkPS0acntTP72A4gdGjCBZ+LAmR8HECBlvD9lSDqWHhpCX9kZQw==} - cpu: [arm64] - os: [darwin] - - '@ccusage/ccusage-darwin-x64@20.0.20': - resolution: {integrity: sha512-UkanWl+XRiC7rgTG7eZDy4xqleQOZ3xd8i6RBW77p2OhHheBpGznMkqS7/5rPT2JQ10cnkOKqjxFE09CLAmIrQ==} - cpu: [x64] - os: [darwin] - - '@ccusage/ccusage-linux-arm64@20.0.20': - resolution: {integrity: sha512-iiURX4sqr3S7jrDJW6P6F0QtP6lx7lkhqqxBQXzaL/66xjhUkCDHKzRglixPuSBxkNaKGL6RAtKGk/ubFGvdBg==} - cpu: [arm64] - os: [linux] - - '@ccusage/ccusage-linux-x64@20.0.20': - resolution: {integrity: sha512-qG5LcyerzKJVPu3hTRHYaWo6VtKB9uyGbRdviw4imInIWRQdPcrEAIb22oCO7iWEfAEv5ZYmTjYWJLX+dR5HXg==} - cpu: [x64] - os: [linux] - - '@ccusage/ccusage-win32-arm64@20.0.20': - resolution: {integrity: sha512-BUXxVGbZ6WtA8RGqjukzkKFmS2x2cjMTV1kU5weZ4+K/uPHIHJjDIDnSKJtNAllwpwlPKpC+k3UdhkapeVM10w==} - cpu: [arm64] - os: [win32] - - '@ccusage/ccusage-win32-x64@20.0.20': - resolution: {integrity: sha512-6R9cdLuRy529ewdkKbwtjx7boCQNFK8KG1F6d5G/pw2ApKRmucWcwjFo/7sNULQtZMyUxhYa40tUnWy9L/yTMA==} - cpu: [x64] - os: [win32] - '@clack/core@1.5.0': resolution: {integrity: sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==} engines: {node: '>= 20.12.0'} @@ -1521,10 +1488,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - ccusage@20.0.20: - resolution: {integrity: sha512-xctCxhwK4Nqo1i3zSa1+JM3wN/oSdPSglzgxIWZyk9KLsLyQQMfntG3NJHoRH3DJHzkvG1EzVwH/xSI2z6VH3Q==} - hasBin: true - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2973,24 +2936,6 @@ snapshots: dependencies: fontkitten: 1.0.3 - '@ccusage/ccusage-darwin-arm64@20.0.20': - optional: true - - '@ccusage/ccusage-darwin-x64@20.0.20': - optional: true - - '@ccusage/ccusage-linux-arm64@20.0.20': - optional: true - - '@ccusage/ccusage-linux-x64@20.0.20': - optional: true - - '@ccusage/ccusage-win32-arm64@20.0.20': - optional: true - - '@ccusage/ccusage-win32-x64@20.0.20': - optional: true - '@clack/core@1.5.0': dependencies: fast-wrap-ansi: 0.2.2 @@ -3874,15 +3819,6 @@ snapshots: ccount@2.0.1: {} - ccusage@20.0.20: - optionalDependencies: - '@ccusage/ccusage-darwin-arm64': 20.0.20 - '@ccusage/ccusage-darwin-x64': 20.0.20 - '@ccusage/ccusage-linux-arm64': 20.0.20 - '@ccusage/ccusage-linux-x64': 20.0.20 - '@ccusage/ccusage-win32-arm64': 20.0.20 - '@ccusage/ccusage-win32-x64': 20.0.20 - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} diff --git a/scripts/core-tools/prepare-ccusage-sidecar.mjs b/scripts/core-tools/prepare-ccusage-sidecar.mjs deleted file mode 100644 index 3e674301..00000000 --- a/scripts/core-tools/prepare-ccusage-sidecar.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { - chmodSync, - copyFileSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - statSync, -} from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const coreRoot = join(repositoryRoot, 'crates/codevetter-core'); -const rootRequire = createRequire(import.meta.url); - -const TARGET_PACKAGES = new Map([ - ['aarch64-apple-darwin', ['@ccusage/ccusage-darwin-arm64', 'bin/ccusage']], - ['x86_64-apple-darwin', ['@ccusage/ccusage-darwin-x64', 'bin/ccusage']], - ['aarch64-unknown-linux-gnu', ['@ccusage/ccusage-linux-arm64', 'bin/ccusage']], - ['x86_64-unknown-linux-gnu', ['@ccusage/ccusage-linux-x64', 'bin/ccusage']], - ['aarch64-pc-windows-msvc', ['@ccusage/ccusage-win32-arm64', 'bin/ccusage.exe']], - ['x86_64-pc-windows-msvc', ['@ccusage/ccusage-win32-x64', 'bin/ccusage.exe']], -]); - -export function packageForTarget(target) { - const entry = TARGET_PACKAGES.get(target); - if (!entry) throw new Error(`Unsupported ccusage sidecar target: ${target}`); - return { packageName: entry[0], binarySubpath: entry[1] }; -} - -export function assertCcusageMetadata({ expectedVersion, wrapperVersion, nativeVersion, license }) { - if (!/^\d+\.\d+\.\d+$/.test(expectedVersion)) { - throw new Error(`ccusage must use an exact version, received: ${expectedVersion}`); - } - if (wrapperVersion !== expectedVersion || nativeVersion !== expectedVersion) { - throw new Error( - `ccusage version mismatch: expected ${expectedVersion}, wrapper ${wrapperVersion}, native ${nativeVersion}` - ); - } - if (license !== 'MIT') throw new Error(`Unexpected ccusage license: ${license}`); -} - -export function resolveInstalledCcusage(target) { - const { packageName, binarySubpath } = packageForTarget(target); - let cliPath; - try { - cliPath = rootRequire.resolve('ccusage/src/cli.js'); - } catch { - throw new Error('ccusage is not installed. Run pnpm install from the repository root.'); - } - const wrapperPackage = readJson(join(dirname(cliPath), '..', 'package.json')); - const nativeRequire = createRequire(cliPath); - let binaryPath; - let nativePackagePath; - try { - binaryPath = nativeRequire.resolve(`${packageName}/${binarySubpath}`); - nativePackagePath = nativeRequire.resolve(`${packageName}/package.json`); - } catch { - throw new Error( - `The optional native package ${packageName} is missing for ${target}. Reinstall dependencies on that target.` - ); - } - const nativePackage = readJson(nativePackagePath); - return { - binaryPath, - wrapperVersion: wrapperPackage.version, - nativeVersion: nativePackage.version, - license: nativePackage.license, - }; -} - -export function prepareCcusageSidecar({ - target = process.env.CODEVETTER_TARGET_TRIPLE ?? rustHostTarget(), - destinationRoot = join(coreRoot, 'binaries'), - installed = resolveInstalledCcusage(target), - expectedVersion = pinnedVersion(), -} = {}) { - assertCcusageMetadata({ expectedVersion, ...installed }); - assertNonEmpty(installed.binaryPath, 'installed ccusage binary'); - - const suffix = target.includes('windows') ? '.exe' : ''; - const destination = join(destinationRoot, `ccusage-${target}${suffix}`); - const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`; - mkdirSync(dirname(destination), { recursive: true }); - try { - copyFileSync(installed.binaryPath, temporary); - if (!suffix) chmodSync(temporary, 0o755); - assertNonEmpty(temporary, 'prepared ccusage sidecar'); - renameSync(temporary, destination); - } finally { - rmSync(temporary, { force: true }); - } - - const output = execFileSync(destination, ['--version'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - if (!output.includes(expectedVersion)) { - throw new Error( - `Prepared ccusage version mismatch: expected ${expectedVersion}, received ${output}` - ); - } - console.log(`Prepared ${destination} (${output}, MIT)`); - return { destination, version: expectedVersion, license: installed.license }; -} - -function pinnedVersion() { - const packageJson = readJson(join(repositoryRoot, 'package.json')); - return packageJson.devDependencies?.ccusage ?? ''; -} - -export function rustHostTarget() { - const target = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }) - .split('\n') - .find((line) => line.startsWith('host: ')) - ?.slice('host: '.length); - if (!target) throw new Error('Could not determine the Rust target triple for ccusage'); - return target; -} - -function assertNonEmpty(path, label) { - const stats = statSync(path); - if (!stats.isFile() || stats.size === 0) throw new Error(`${label} is missing or empty: ${path}`); -} - -function readJson(path) { - return JSON.parse(readFileSync(path, 'utf8')); -} - -const isMain = - process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); -if (isMain) prepareCcusageSidecar(); diff --git a/scripts/core-tools/prepare-ccusage-sidecar.test.mjs b/scripts/core-tools/prepare-ccusage-sidecar.test.mjs deleted file mode 100644 index 979f3e02..00000000 --- a/scripts/core-tools/prepare-ccusage-sidecar.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import assert from 'node:assert/strict'; -import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import test from 'node:test'; - -import { - assertCcusageMetadata, - packageForTarget, - prepareCcusageSidecar, - resolveInstalledCcusage, - rustHostTarget, -} from './prepare-ccusage-sidecar.mjs'; - -test('maps every published ccusage target package', () => { - assert.deepEqual(packageForTarget('aarch64-apple-darwin'), { - packageName: '@ccusage/ccusage-darwin-arm64', - binarySubpath: 'bin/ccusage', - }); - assert.deepEqual(packageForTarget('x86_64-unknown-linux-gnu'), { - packageName: '@ccusage/ccusage-linux-x64', - binarySubpath: 'bin/ccusage', - }); - assert.deepEqual(packageForTarget('aarch64-pc-windows-msvc'), { - packageName: '@ccusage/ccusage-win32-arm64', - binarySubpath: 'bin/ccusage.exe', - }); - assert.throws(() => packageForTarget('wasm32-unknown-unknown'), /Unsupported/); -}); - -test('rejects non-exact, mismatched, and non-MIT packages', () => { - const valid = { - expectedVersion: '20.0.20', - wrapperVersion: '20.0.20', - nativeVersion: '20.0.20', - license: 'MIT', - }; - assert.doesNotThrow(() => assertCcusageMetadata(valid)); - assert.throws(() => assertCcusageMetadata({ ...valid, expectedVersion: '^20.0.20' }), /exact/); - assert.throws(() => assertCcusageMetadata({ ...valid, nativeVersion: '20.0.19' }), /mismatch/); - assert.throws(() => assertCcusageMetadata({ ...valid, license: 'UNKNOWN' }), /license/); -}); - -test('resolves and executes the real installed native package', () => { - const target = rustHostTarget(); - const installed = resolveInstalledCcusage(target); - const destinationRoot = mkdtempSync(join(tmpdir(), 'codevetter-ccusage-')); - const result = prepareCcusageSidecar({ - target, - destinationRoot, - installed, - expectedVersion: '20.0.20', - }); - assert.equal(result.version, '20.0.20'); - assert.equal(result.license, 'MIT'); -}); - -test('rejects a missing or empty binary', () => { - const directory = mkdtempSync(join(tmpdir(), 'codevetter-ccusage-empty-')); - const empty = join(directory, 'ccusage'); - writeFileSync(empty, ''); - chmodSync(empty, 0o755); - assert.throws( - () => - prepareCcusageSidecar({ - target: 'aarch64-apple-darwin', - destinationRoot: directory, - installed: { - binaryPath: empty, - wrapperVersion: '20.0.20', - nativeVersion: '20.0.20', - license: 'MIT', - }, - expectedVersion: '20.0.20', - }), - /missing or empty/ - ); -}); diff --git a/scripts/inspect-native-release-readiness.mjs b/scripts/inspect-native-release-readiness.mjs index edd40f8a..f29ce5f6 100644 --- a/scripts/inspect-native-release-readiness.mjs +++ b/scripts/inspect-native-release-readiness.mjs @@ -43,7 +43,6 @@ export function evaluateNativeReleaseReadiness(input) { const requiredCompanions = [ 'cargo-audit:Contents/Resources/collectors/cargo-audit', 'cargo-llvm-cov:Contents/Resources/collectors/cargo-llvm-cov', - 'ccusage:Contents/MacOS/ccusage', 'codevetter:Contents/MacOS/codevetter', 'codevetter-mcp:Contents/MacOS/codevetter-mcp', 'gitleaks:Contents/Resources/collectors/gitleaks', diff --git a/scripts/inspect-native-release-readiness.test.mjs b/scripts/inspect-native-release-readiness.test.mjs index 97e56b73..572c348e 100644 --- a/scripts/inspect-native-release-readiness.test.mjs +++ b/scripts/inspect-native-release-readiness.test.mjs @@ -17,7 +17,6 @@ function input(overrides = {}) { sidecars: [ { name: 'codevetter', relative_path: 'Contents/MacOS/codevetter' }, { name: 'codevetter-mcp', relative_path: 'Contents/MacOS/codevetter-mcp' }, - { name: 'ccusage', relative_path: 'Contents/MacOS/ccusage' }, { name: 'cargo-audit', relative_path: 'Contents/Resources/collectors/cargo-audit', diff --git a/scripts/qualify-native-package.mjs b/scripts/qualify-native-package.mjs index 92a67d76..acacfe22 100644 --- a/scripts/qualify-native-package.mjs +++ b/scripts/qualify-native-package.mjs @@ -180,7 +180,6 @@ export function qualifyNativePackage(options = parseArguments(process.argv.slice const executableSidecars = [ ['codevetter', join('binaries', `codevetter-${target}`)], ['codevetter-mcp', join('binaries', `codevetter-mcp-${target}`)], - ['ccusage', join('binaries', `ccusage-${target}`)], ].map(([destinationName, preparedPath]) => { const source = join(repositoryRoot, 'crates/codevetter-core', preparedPath); const destination = join(executableDirectory, destinationName); @@ -243,15 +242,11 @@ export function qualifyNativePackage(options = parseArguments(process.argv.slice const smoke = { cli: { exit_code: 0, output: cliHelp.trim().slice(0, 500) }, mcp: capture(sidecars[1], ['--help']), - ccusage: capture(sidecars[2], ['--version']), - gitleaks: capture(sidecars[3], ['version']), - cargo_audit: capture(sidecars[4], ['--version']), - cargo_llvm_cov: capture(sidecars[5], ['llvm-cov', '--version']), + gitleaks: capture(sidecars[2], ['version']), + cargo_audit: capture(sidecars[3], ['--version']), + cargo_llvm_cov: capture(sidecars[4], ['llvm-cov', '--version']), runtime: capture(process.execPath, [join(runtimeDestination, 'cli.mjs'), '--help']), }; - if (!smoke.ccusage.output.includes('20.0.20')) { - throw new Error(`Unexpected bundled ccusage version: ${smoke.ccusage.output}`); - } if (!smoke.gitleaks.output.includes('8.30.1')) { throw new Error(`Unexpected bundled gitleaks version: ${smoke.gitleaks.output}`); } @@ -365,7 +360,6 @@ function prepareSidecars(target) { for (const [script, args] of [ ['scripts/core-tools/prepare-cli-sidecar.mjs', ['--release']], ['scripts/core-tools/prepare-mcp-sidecar.mjs', ['--release']], - ['scripts/core-tools/prepare-ccusage-sidecar.mjs', []], ['scripts/core-tools/prepare-collector-sidecars.mjs', []], ]) { run(process.execPath, [join(repositoryRoot, script), ...args], {