Archon was burning quota. The fix was a hook, not a scheduler.

The Archon dashboard showed “running” on three simultaneous worktrees. I hadn’t consciously approved two of them. Not a bug in the dispatcher. I’d typed both commands, nothing had blocked them, and each Claude Code session had independently concluded it was at a valid dispatch point. The quota counter was ticking on all three. The fix I reached for wasn’t a smarter scheduler. It was a speed-bump.

The setup

The editorial pipeline runs a pipeline dispatcher that converts GitHub issues into Archon workflow runs. The dispatcher lives in brain_server.py and manages concurrency caps and cooldowns. Since Sprint 2.5 it holds every automated run in pending_approval until I tap approve in Telegram. That approval gate covers the automated path entirely.

The manual path was ungated. Any Claude Code session that decided it was time to dispatch could type archon workflow run (or fire the wrapped osascript form) and the run would start. No pause, no check. The session had already decided; the command was the decision.

Claude Code’s quota runs on a rolling five-hour window, not a monthly cap. Three concurrent Archon runs on three different repos is three instances of Claude burning that window in parallel. On a sprint day with several independent tasks queued, the window closed faster than I was tracking.

What I tried first

The instinct was instrumentation. Sprint 1.5.1 added a proactive usage monitor that scans ~/.claude/projects/**/*.jsonl, estimates current token consumption against the observed cap, and auto-pauses the dispatcher at 90% headroom. It self-calibrates against past quota failures. That piece works and is still running.

What it doesn’t cover is the manual path. A session that bypasses the dispatcher entirely is invisible to the usage monitor. Typing archon workflow run directly goes around the decision layer the monitor guards. The monitor guards automated decisions. Manual ones were still zero-friction.

The hook

Sprint 2.2 added ~/.claude/hooks/archon-preflight.py. It registers as a global PreToolUse hook in ~/.claude/settings.json under the Bash matcher.

The matching rule: any Bash command containing archon workflow run or archon workflow resume. The wrapped form is also caught. osascript -e '...do script "archon workflow run..."' triggers the hook as written, without needing to parse the AppleScript.

The behaviour is one thing: block with a message. Review session availability before spawning a run.

That’s the entire logic. No token count check, no quota read, no concurrency query. The hook fires on the command shape and stops there. Bypass is BBBRAIN_PREFLIGHT_BYPASS=1 as either an inline literal in the command string or a real env var. The hook fails open on any Python exception. A bug in the hook cannot block unrelated Bash work.

Why a speed-bump beats a smarter check

A smarter check needs accurate state. The proactive monitor calibrates against the observed cap, but it observes the cap from past failures. The first failure in a fresh window teaches the monitor something the second run could have used. There’s a lag window. During that lag, concurrent runs slip through.

A speed-bump needs nothing. It matches the command shape, fires synchronously, and forces a pause. The check I actually needed was not “how much quota is left?” It was “am I already running something, and did I mean to fire another one?” That check doesn’t need token counts. It needs a moment of attention.

The real problem wasn’t threshold miscalibration. It was that the manual path from decision to dispatch was instant. The hook inserts a single deliberate step between the two. On a routine dispatch where I’ve already decided, bypass with the env var and carry on. On a session where the dispatch was reflexive rather than considered, the block message lands, I check what’s running, I proceed or don’t.

One pause per dispatch. That’s the cost. The quota savings aren’t measurable in any precise way, because the scenario being prevented is a waste-of-attention error, not a deterministic failure. What is measurable: no concurrent triple-run since the hook shipped.

Out of scope: auto-mode with threshold-based silent approval. That’s an explicit later iteration. The hook is a speed-bump by design, not a gatekeeper that can be reasoned around.

Sprint 2.11: the visibility problem

Parallel to the quota issue was a legibility one. The Archon dashboard ran rows that read archon-fix-github-issue / fix issue #9 with no indication of which issue was actually in flight. The auto-generated worktree branch was archon/task-archon-fix-github-issue-{epoch_ms}, an opaque epoch-suffixed string that matched nothing in the GitHub issue list. Identifying a stuck run meant cross-referencing timestamps across two UIs.

Sprint 2.11 enriched the dispatch message and added an explicit --branch argument. The dispatcher reads the issue title via gh issue list, truncates to 80 characters, escapes " and \ against AppleScript string injection, and passes it through. Dashboard rows now read fix issue #N — <real title>. Worktree branches follow archon/issue-{N}-{epoch_ms}.

The branch prefix change required widening branch_prefix_for(repo) from matching the exact archon/task-{workflow}- shape to matching archon/ as a prefix. That covers both the legacy branch shape and the new one, so is_busy() and the completion sweep continue working without a backfill. No schema migration, no downtime.

The pipeline_runs table gained an issue_title TEXT column. Captured at dispatch time on both the auto-poll path (from gh issue list output) and the manual fire path (from the Sprint 2.8 issues cache). Rows predating the column have NULL there; the dispatcher handles the NULL case by falling back to fix issue #N as the message argument.

The two sprints are solving different problems in the same region. The hook reduces the chance of an unintentional dispatch. The job naming makes the running state legible after a dispatch happens. Both reduce the cost of a mistake. Neither prevents one entirely.

What this isn’t

It isn’t a complete quota solution. The hook intercepts the command; it doesn’t intercept the intent. A session that knows the bypass token can skip it.

The approval gate (Sprint 2.5) operates at a different layer, inside the dispatcher, after the decision to fire but before the osascript spawn. The hook operates before the dispatcher sees the command at all. Both layers exist. They’re not redundant; they cover different paths.

What a complete solution would look like: a rate-limiter at the Anthropic API layer, or a centralised spawn registry that any dispatch path must pass through before touching osascript. Both are out of reach from inside a hook file. The hook is the lightest intervention that changes the behaviour.

Lightest is appropriate here. The failure mode was reflexive dispatch, not broken logic. A speed-bump fixes reflexive dispatch. A smarter system would have been solving a harder problem than the one I had.

All writing