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 exactlywindowHoursis 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'scanevents within the last 60 minutes (needs >=2 points and >0.05h span); clamped to >=0. - Budget ratchet:
--record-limit-hitsetsbudgetWeighted = 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.1ConvertTo-Json -Compressoutput the production hooks (hooks/governor-prompt-hook.ps1,hooks/governor-pretool-hook.ps1) produce, since Claude Code invokes them viapowershell -NoProfile -ExecutionPolicy Bypass -File ...(Windows PowerShell 5.1, notpwsh).HookHandlerbuilds 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.