Skip to content

Usage Governor (C#) — Architecture

C# port of the PowerShell usage-governor scripts (scan-usage.ps1, hooks/governor-*-hook.ps1), preserving behavior byte-for-byte where it matters (hook JSON payloads) and algorithmically everywhere else (weighting, windowing, tiering, ratcheting).

Components

flowchart TB
    subgraph CLI["UsageGovernor.Cli (thin dispatch)"]
        Program["Program.cs\nargs -> command"]
    end

    subgraph Core["UsageGovernor.Core"]
        Paths["GovernorPaths\n(state/config/cursors/history/override)"]
        Config["GovernorConfig\n(load-or-create defaults)"]
        Scanner["TranscriptScanner\n(incremental byte-offset *.jsonl scan)"]
        Weights["ModelWeights\n(haiku/sonnet/opus-fable-mythos)"]
        Window["WindowTracker\n(fixed 5h window reset logic)"]
        History["BurnHistoryStore\n(burn-history.jsonl append + rate)"]
        Codex["CodexLimitReader\n(.codex session rate_limits)"]
        Gemini["GeminiLedger\n(gemini-ledger.jsonl)"]
        ProviderTier["ProviderTierPolicy\n(Codex/Gemini tiers)"]
        Tier["TierPolicy\n(GREEN/YELLOW/RED/BLACKOUT)"]
        Override["OverrideStore\n(escape hatch, expiring)"]
        Cursor["CursorStore\n(cursors.json)"]
        State["StateStore\n(atomic state.json write)"]
        Scan["ScanService\n(orchestrates one full scan pass)"]
        Hook["HookHandler\n(prompt + pretool message logic)"]
    end

    Program -->|scan| Scan
    Program -->|hook-prompt| Hook
    Program -->|hook-pretool| Hook
    Program -->|status/override| Config
    Program -->|status/override| Override

    Scan --> Paths
    Scan --> Config
    Scan --> Scanner
    Scan --> Window
    Scan --> History
    Scan --> Codex
    Scan --> Gemini
    Scan --> ProviderTier
    Scan --> Tier
    Scan --> Override
    Scan --> Cursor
    Scan --> State
    Scanner --> Weights
    Scanner --> Window

    Hook --> State

Sequence: scheduled scan

sequenceDiagram
    participant Task as Scheduled Task (every 5 min)
    participant Cli as UsageGovernor.exe scan
    participant Svc as ScanService
    participant Scanner as TranscriptScanner
    participant FS as ~/.claude/projects/**/*.jsonl
    participant Hist as burn-history.jsonl
    participant State as state.json (atomic)

    Task->>Cli: scan [--record-limit-hit]
    Cli->>Svc: Run(recordLimitHit, now)
    Svc->>Svc: load config.json / cursors.json / prior state.json
    Svc->>Svc: WindowTracker.ResetIfExpired (fixed window gap check)
    Svc->>Scanner: Scan(projectsDir, cursors, windowHours, windowStart, burn, now)
    Scanner->>FS: enumerate *.jsonl (Length>0, mtime > now-6h)
    loop each eligible file
        Scanner->>FS: seek to cursor, read new lines
        Scanner->>Scanner: regex extract tokens/model/timestamp
        Scanner->>Scanner: WindowTracker.AssignLine (per-line reset)
        Scanner->>Scanner: burn += ModelWeights.WeightedCost(...)
    end
    Scanner-->>Svc: ScanResult (burn, windowStart, activeSessions15m, cursors)
    alt --record-limit-hit
        Svc->>Svc: newBudget = floor(burn * 0.8), only if lower
        Svc->>Hist: append {event:"limit-hit", ...}
    end
    Svc->>Hist: append {event:"scan", at, burn}
    Svc->>Hist: ComputeRate (last 40 lines, scans within 60 min)
    Svc->>Svc: projected = burn + rate * remainingWindowHours
    Svc->>Svc: TierPolicy.Compute(pct, proj, activeSessions15m, config)
    Svc->>Svc: OverrideStore.ApplyOverride (escape hatch)
    Svc->>State: SaveAtomic (write .tmp, move over target)
    Svc->>Cli: cursors.json persisted, prune deleted files
    Cli-->>Task: stdout "tier=X pct=Y proj=Z burn=N budget=B sessions15m=S"

Sequence: hooks

sequenceDiagram
    participant CC as Claude Code
    participant Prompt as UsageGovernor.exe hook-prompt
    participant PreTool as UsageGovernor.exe hook-pretool
    participant State as state.json

    CC->>Prompt: UserPromptSubmit
    Prompt->>State: load (missing/corrupt -> silent exit 0)
    alt tier is YELLOW/RED/BLACKOUT
        Prompt-->>CC: {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"<byte-stable message>"}}
    else GREEN or unknown
        Prompt-->>CC: (nothing)
    end

    CC->>PreTool: PreToolUse (tool_name, tool_input on stdin)
    PreTool->>State: load (missing/corrupt/GREEN -> exit 0)
    PreTool->>PreTool: only gates tool_name Agent | Workflow
    alt RED or BLACKOUT
        PreTool-->>CC: deny (verbatim reason)
    else YELLOW and tool_name == Workflow
        PreTool-->>CC: deny (verbatim reason)
    else YELLOW and subagent_type starts with caveman:/cavecrew
        PreTool-->>CC: (silent allow)
    else YELLOW and model in {haiku, sonnet}
        PreTool-->>CC: (silent allow)
    else YELLOW otherwise
        PreTool-->>CC: deny (verbatim reason, interpolated pct/proj)
    end

Key parity details

  • Weighting: weighted = modelWeight * (input*1 + cacheWrite*1.25 + cacheRead*0.1 + output*5). modelWeight: haiku=0.25, sonnet=1.0, opus/fable/mythos=5.0, else 1.0 (case-insensitive substring match).
  • Fixed window: window resets (both at scan-start against the prior persisted state, and per-line against each transcript line's own timestamp) whenever the gap exceeds windowHours (default 5h). Strict > — a gap of exactly windowHours is not a reset.
  • Cursor seeding: an untracked file starts at byte 0 if it was written in the last 90 minutes, otherwise at EOF (so old files don't get replayed on first sight).
  • Projection: burn-rate/hour derived from burn-history.jsonl's last 40 lines' scan events within the last 60 minutes (needs >=2 points and >0.05h span); clamped to >=0.
  • Budget ratchet: --record-limit-hit sets budgetWeighted = floor(burn * 0.8) only if that's lower than the current budget and greater than 0 — it never moves the budget up.
  • Hook JSON byte-stability: hook output field order and escaping (', <, >, & force-escaped to '/</>/&; other ASCII like + left literal) were verified against the actual PowerShell 5.1 ConvertTo-Json -Compress output the production hooks (hooks/governor-prompt-hook.ps1, hooks/governor-pretool-hook.ps1) produce, since Claude Code invokes them via powershell -NoProfile -ExecutionPolicy Bypass -File ... (Windows PowerShell 5.1, not pwsh). HookHandler builds these payloads by hand rather than via generic serialization to pin that exact field order and escape set.

Testing

xunit + coverlet + Bogus in tests/UsageGovernor.Tests. Core classes take injected paths (GovernorPaths) rather than reading environment variables directly, so tests run against real temp directories with no environment mutation. UsageGovernor.Cli is the only place UG_BASE_DIR / UG_PROJECTS_DIR are read.