@addforce/governor
v0.4.17
Published
Local, deterministic MCP server that scores a coding task against your repo — likely files, difficulty, criticality, model recommendation, verification level — plus an optional Claude Code hook channel. No LLM calls, no network, WASM-only parsing.
Maintainers
Readme
Governor
Governor is a local MCP server for a coding agent (or the human driving one): before a task starts, it says which files it'll touch and which model to use for it; after, it catches an agent's "done" claim that doesn't actually hold up.
- Local engine, no network. File-inference, difficulty, and criticality scoring run as pure
rules-based logic (
packages/core) — no LLM call, no network request, no API key required. - Stops and asks instead of guessing. When a task description is too vague to place confidently, it asks a clarifying question rather than silently picking files.
- Catches a "done" claim that didn't hold up.
verify_taskdetects and runs the project's own test/build/lint commands and diffs files actually changed against what was predicted, so a task claimed done with failing tests or an untouched prediction doesn't slip through.
Docs in /docs; this file is the quickstart.
Shipped-version history: CHANGELOG.md.
Install (npm)
Language coverage: full support, import-graph included, for TypeScript/JavaScript/Python. Every other real-source-code file gets text-tier support — indexed, embedded, and keyword-matched, without the dependency-graph signal a parser would add. (Full breakdown below the install commands.)
Registering the MCP server alone does not make it active. The server only runs when a tool is
called by name — nothing calls assess_task on its own. Install the hook below to get a brief
injected automatically on every prompt instead of relying on an agent to remember to ask.
The product packs as two artifacts: @addforce/governor (the MCP server,
the governor bin, and the Claude Code hook channel with its
governor-hook installer bin, all in one package) and
@addforce/governor-core (the engine it depends on, WASM grammars and the
vendored embedding model included — nothing downloads at install or run
time). Install:
npm i -g @addforce/governor # pulls @addforce/governor-core automatically
governor-hook /path/to/your/project # registers BOTH the MCP server and the hook — one command
governor-hook /path/to/your/project --uninstall # removes both any timeThat's the whole install — two commands. governor-hook writes to THREE places, every one of them
named in the confirmation prompt before anything happens: it merges a "governor" entry into
<project>/.mcp.json (creating the file if it doesn't exist, leaving any other server entries
there untouched), merges UserPromptSubmit/Stop entries into <project>/.claude/settings.json,
and appends a .governor/ line to <project>/.gitignore (creating it if absent) so the record
store never shows up as untracked files in your repo. Every one of the three is named again in the
success message once the install finishes, and in the refusal if it can't ask you. --uninstall
reverses the first two exactly — a file it CREATED is removed entirely, along with the .claude/
directory if creating that file is the only reason it exists; a file that already existed keeps its
other content minus our entries — but does not touch the .gitignore line, which is left in place
as a harmless, permanent convenience (the uninstall message says so rather than leaving you to find
it). If .mcp.json already has a "governor" entry pointing somewhere else, the
installer refuses and says so rather than overwriting it — remove or rename that entry first.
governor-hook always asks for confirmation unless you pass --yes — this holds whether or
not you gave it an explicit target directory; earlier versions skipped the prompt for an explicit
path, which is not what "asks for confirmation first" should mean, and no longer does. The install
itself also prints this same command right after it finishes (with --foreground-scripts, since
npm hides plain postinstall output by default — see below) — see it there if you skip straight to
the code. governor-hook works on any real project directory, git or not (it refuses only a
directory that looks empty or unrelated — no manifest file and no source code found, one that
already has the hook installed, or a conflicting .mcp.json entry as above; --help for the full
option list). Without it, the server still works once you register it yourself (below) — an agent
can call assess_task explicitly — it just won't happen on its own, and you're back to hand-editing
.mcp.json.
Running governor with no arguments prints getting-started instructions rather than hanging as an
MCP server nobody is talking to. governor-hook with no arguments does the same when you are not
inside a project directory; run it from inside one and it treats that as the target and asks to
install, which is the other thing you might have meant. Either way you do not need to remember the
two commands above from memory.
governor is the stdio server itself — an MCP client spawns it, you don't run it
directly. If you'd rather register it by hand (a non-Claude-Code client, or you don't want the
hook), or want to see what the installer writes: Claude Code's .mcp.json at your project root,
or claude mcp add; other clients use their own MCP server config, same shape:
{
"mcpServers": {
"governor": {
"command": "governor",
"args": []
}
}
}or, without the -g flag (a local install into one project instead):
{
"mcpServers": {
"governor": {
"command": "node_modules/.bin/governor",
"args": []
}
}
}(and node_modules/.bin/governor-hook for the hook command above, in that case)
Both packages are licensed under PolyForm Shield 1.0.0 (see Licence below) and publish
under the @addforce scope. pnpm gate4 packs both tarballs, installs them with plain npm into a
fresh directory outside this repo, and drives a full MCP handshake, cold index, and hook
install/uninstall round-trip against a never-seen clone with the network blocked
(gate-harness/reports/gate4-packaging-2026-08-13.md).
Language coverage, in full: full support, import-graph included, for TypeScript/JavaScript/ Python; every other real-source-code file (Dart, Elixir, Lua, Zig, Haskell, R, Julia, Clojure, F#, C#, Java, Go, Rust, C/C++, Ruby, PHP, Kotlin, Swift, Scala, Objective-C, shell, SQL, Vue, Svelte, template languages like EJS/Jade/Handlebars, and anything else that isn't positively identified as data/markup/config, documentation, an image/media/binary, or generated/minified output) gets text-tier support — indexed, embedded, and keyword-matched, just without the dependency-graph signal a parser would add. This is a denylist, not a hardcoded list of languages: a language nobody thought to name still gets indexed, rather than silently contributing nothing.
Check it's working
After registering the server (above), confirm two things: your MCP client sees it, and a real call comes back with a real answer.
The client sees it connected. In Claude Code, run /mcp — governor should show as
connected, and its tool list should include assess_task, assess_plan, get_file_risk,
report_outcome, verify_task, reindex. Other clients have their own way to list connected
MCP servers and tools; the tool names are the same everywhere.
A real call comes back with a real answer. Ask your agent to call assess_task with a short
description of something you'd actually work on next in this repo (in Claude Code, just describe
a task normally — it calls the tool on its own once registered). A working response looks like
this shape, always:
<one plain-language summary line — the recommended model and why, or a clarifying question>
**Record ID:** `<timestamp>-<random>`
_Closing the loop: call `report_outcome` with this record id once the task is done...If the repository you registered against isn't a git repo yet (or has no commits), the response
starts with one extra line — NOTE: no git history available here... — and everything else
still works; that's expected, not a problem to fix.
If you get anything else — an error message, a stack trace, or no response at all — something is
actually wrong: check that the command in your .mcp.json resolves at all (command -v
governor, or command -v node_modules/.bin/governor for a local install, from
the same shell your client uses — it's a stdio server, so running it directly just hangs waiting
for input, which is itself a sign it resolved correctly), and see Development
below for pnpm gate4, which reproduces this exact check from a clean install outside the
monorepo and is the fastest way to tell "my setup" from "a real bug" apart.
Using Governor outside Claude Code
Governor indexes the directory it's launched from — its cwd — unless told otherwise. Claude
Code launches every MCP server it registers from the project directory itself, so this needs no
attention there. Other clients don't all do that: some launch a global MCP server from wherever the
IDE's own install lives, which is a different directory from any project you're actually working
in. Point Governor at the right one with the GOVERNOR_REPO environment variable — it overrides
cwd when set, and takes precedence over it.
{
"mcpServers": {
"governor": {
"command": "npx",
"args": ["-y", "@addforce/governor"],
"env": {
"GOVERNOR_REPO": "/absolute/path/to/your/project"
}
}
}
}GOVERNOR_REPO must resolve to a real, existing directory that looks like an actual project (a
package.json/pyproject.toml/.git/etc. at its root, or a source file within two directory
levels — the same check the installer already uses, so if governor-hook would accept a directory
as a target, this accepts it as GOVERNOR_REPO too). Point it at the wrong path, or at a directory
that isn't a real project, and every repo-reading tool (assess_task, assess_plan, reindex,
get_file_risk, verify_task) refuses outright and says exactly why — naming the resolved path —
instead of silently falling back to cwd and indexing whatever happens to be there. That refusal
never writes anything: no .governor/ directory, no files, until it's pointed at somewhere real.
Which models Governor will recommend
Governor only names a model it believes your environment can actually run. It used to score the
same four-model Claude set everywhere, which meant that inside a non-Claude client it would happily
recommend claude-opus-5 — a model that client cannot run. Availability now resolves from three
layers, each overriding the one before:
- CLI scan. Which vendor agent CLIs (
claude,gemini,codex, …) are on yourPATH. This is the most direct evidence there is, because every model call Governor makes runs through your own CLI. Scanned once and cached in.governor/cli-scan.json; runreindexafter installing a new CLI to refresh it. - Host identity. The
clientInfoyour MCP client sends during the handshake, matched against a short table of known clients. A client not in that table resolves to unknown — never a guess. - Your own declaration.
availableModelsviamanage_settings, which overrides both:
manage_settings action=set key=availableModels value="claude-sonnet-5, gemini-3.7-flash"When Governor can't name a model, it says so instead of guessing. If nothing in its scored catalog is available in your environment, the response names the tier the task needs and stops there — no model id. This is deliberate: an agent handles missing information far better than wrong information, so a tier phrase beats a confident recommendation you can't act on.
Making other models recommendable by name. Governor's scored catalog is the four Claude tiers.
It also ships a 36-model research catalog (RESEARCH_CATALOG) with real prices and benchmark
numbers for models from Google, OpenAI, xAI, DeepSeek and others — but deliberately without
tiers or task-type fit, because assigning those is a judgment call this project won't make on your
behalf. To have a non-Claude model recommended by name, add it to .governor/registry.json with a
tier you choose:
[
{
"id": "gemini-3.7-flash",
"displayName": "Gemini 3.7 Flash",
"tier": "balanced",
"inputCostPerMTok": 0.3,
"outputCostPerMTok": 2.5,
"seedScores": {},
"seedConfidence": "seed"
}
]That entry is scored like any other from then on. The tier is your declaration about your own setup, which is exactly the boundary: Governor won't invent one, and it won't stop you from setting one.
Quickstart
pnpm install
pnpm buildRegister the server (already checked in at the repo root as .mcp.json — most MCP clients,
including Claude Code and VS Code, pick this up automatically once you cd into the repo):
{
"mcpServers": {
"governor": {
"command": "node",
"args": ["packages/adapter-mcp/dist/index.js"]
}
}
}First call is slower than the rest. The first assess_task/assess_plan/reindex call
against a repo builds the full index — file scan, git history, dependency graph, criticality
scores — and warms the semantic embedding model (all in-process, no download at request time; the
model ships vendored in the package). Expect this to take several seconds on a mid-sized repo.
Every call after that reuses the cached index and only rebuilds when HEAD moves. Call reindex
explicitly to force a full rebuild and see the cost broken out:
Reindexed `/path/to/repo`.
- Files: 214
- Commits: 812
- HEAD: `a1b2c3d4e5`
- Semantic: 214 files, 1,043 chunks, 0 cache hits / 1,043 misses, 15,821ms index time
- Watcher: 1 records attributed, 5 memory entries created, 42 commits processed
- Total: 16,234.1msReduced vs. full mode
The phase-gate docs (docs/phase-gates.md) describe a future "full mode" — a real
usage-measurement gate that could eventually justify a richer, LLM-in-the-loop enrichment layer for
file inference. That layer doesn't exist yet: as shipped, every response comes out of the
deterministic reduced-mode engine, whether or not you have an API key configured anywhere else in
your toolchain. There is no mode switch to flip today — "reduced mode" isn't a fallback, it's the
whole product right now. Every registry entry is correspondingly marked seedConfidence: "seed",
and file-inference confidence never claims more certainty than the rules-based signals actually
support.
What leaves your machine
Not one blanket claim — the layers differ in what they actually send, so each gets its own line:
- The engine (
packages/core: ranking, difficulty, criticality, model recommendation) makes no LLM call and no network request, unconditionally. This is the claim above, and it does not weaken as other layers are added on top of it. - The intake layer's CALLER path (
packages/adapter-mcp/src/tools.ts'sassessTask, on the MCP channel or any live turn-taking caller) introduces no new privacy surface: it asks the CALLING AGENT — which is already inside a session the description already went to — to compute a clarity score, a specification-vocabulary rewrite, and a model recommendation itself, and pass the answer back on the next call. Nothing new leaves the machine that the session's own model calls did not already receive. On by default for exactly that reason. - The intake layer's SPAWN path (
packages/adapter-hooks/src/intake-spawn.ts) would be a genuinely NEW outbound call the user did not otherwise initiate — a headlessclaudeCLI invocation carrying the task description — if it were enabled. It is DISABLED. The real implementation is preserved and tested (unsafeSpawnIntakeModelCaller), but the path everything actually calls (createSpawnIntakeModelCaller) always rejects immediately without spawning anything, because the call measures ~10s against the hook channel's own 800ms IPC budget — 12x over, and awaiting it inline would reopen the exact fail-open regressiongate-harness/reports/hook-fail-open-2026-08-18.mdfixed..governor/hooks-config.json'sintakeSpawnEnabledflag still exists and is still read, but has no effect. As a result, the hook channel makes zero outbound model calls today, full stop — seedocs/intake-layer-spec.md's "Why the SPAWN path is disabled" section for the exact condition under which that would change (a genuine non-blocking dispatch, or a materially faster spawn). - "auto" automation mode's dispatch (
packages/adapter-mcp/src/auto-dispatch.ts) is a genuinely NEW outbound call too — the router runs the task itself, spawning a headlessclaudeCLI call carrying the (rewritten) task description. It ships off by default:.governor/intake-config.json'sautomationis"recommend"unless explicitly set to"auto". Unlike the SPAWN path above, this one has no budget conflict (it only ever runs from a live MCP/no-channel caller, never the hook channel), so it isn't disabled — but it is exactly as opt-in. Seedocs/intake-layer-spec.md's "The automation switch" section. - The done-verification layer (
packages/core/src/verify/,verify_task) makes NO outbound call of any kind — no model call, no network request. It only runs commands the project ALREADY defines (its own test/build/lint scripts) via a local subprocess spawn (packages/adapter-mcp/src/command-runner.ts), on your own machine, and never modifies the repo, commits, or installs anything.
Don't take our word for it
The automated test: scripts/gate4-pack-test.mjs (run via pnpm gate4) launches the packed server
with poisoned proxy env vars and samples its live TCP connections every 5s while it handles a real
assess_task call — see sampleOutbound() in that file.
Watch it yourself: start the server, find its PID, then watch its connections while you send it a
request — Get-NetTCPConnection -OwningProcess <PID> (PowerShell) or lsof -i -p <PID> /
netstat -p tcp <PID> (macOS/Linux). You should see nothing but loopback/local, if anything at all.
The only path that ever reaches the network at runtime: "auto" automation mode's dispatch
(packages/adapter-mcp/src/auto-dispatch.ts), which spawns a real claude CLI call. It is
opt-in and off by default — see What leaves your machine above.
The tools
Registered by packages/adapter-mcp/src/index.ts. Every assess_task/assess_plan/reindex
response carries structured.serverBuild: {startedAt, buildHash, stale} — a cheap mtime check
against the server's own compiled entry and @addforce/governor-core's, so a rebuild-without-restart
surfaces immediately instead of silently serving stale formulas; when stale is true the markdown
opens with a one-line restart reminder.
assess_task(description, files?)
The main entry point. Scores every registry model against the task, infers which files it likely
touches (unless you already know and pass files), and — when confidence is high enough — renders
an opening brief: a ready-to-paste starting-file list with the signal that justified each entry,
capped extended-checklist candidates below it, and the verification level required on completion.
## Task Assessment
**Recommended model:** claude-opus-5
| Model | Score | Est. cost | Breakdown |
|---|---|---|---|
| **claude-opus-5** | 0.85 | $0.3149 | taskTypeFit 0.86, difficultyFit 1.00, costEfficiency 0.56 |
| claude-sonnet-5 | 0.77 | $0.1889 | taskTypeFit 0.85, difficultyFit 0.67, costEfficiency 0.78 |
**Verification:** affected-tests — touches a medium-criticality file.
**Record ID:** `1786617849385-7a337663`
```
OPENING BRIEF (from Governor, confidence: medium)
Likely starting files — verify before editing, do not limit yourself to these:
- packages/adapter-mcp/src/tools.ts — name match, semantic, co-change
- packages/core/src/measurement/records.ts — name match, semantic, co-change, prompt memory
Extended checklist — lower-ranked candidates worth a quick look before broad exploration:
- gate-harness/src/report.ts — name match, semantic, co-change, prompt memory
Verification required on completion: affected-tests — touches a medium-criticality file.
If these files look wrong, say so and explore normally.
```Below confidence "medium with basename evidence," a tentative brief renders instead (hedged
wording, 3-file cap) whenever the top candidate still scores >= 0.6; below that, nothing renders and
a clarifying question takes its place. A fourth tier, docs-hedged, replaces whatever would have
rendered when the description explicitly names a real, indexed non-code file (a README, a config
file) — near-certain evidence the task can't be seen by a ranker that only ranks code — OR when the
task merely classifies as docs/config by keyword AND no code-file candidate clears the tentative
floor either. A confident code-file candidate is trusted over a casual docs-sounding description:
sampling every unit whose text merely read as documentation work found 15 of 15 touched real
source in the actual commit (gate-harness/reports/task-kind-map-2026-08-17.md), so keyword
classification alone no longer overrides a ranker that has a real lead. structured.briefTier
(full / tentative / docs-hedged / none) and structured.timingMs
({total, contextReady, inference, semantic, decide}) are on every response for whoever wants to
measure this without scraping markdown — see Honest numbers.
Caller-rewrite: recovering recall on unconfident descriptions
Recall falls as a task description drifts from commit-message vocabulary toward casual or vague
phrasing (gate-harness/reports/ai-ground-baseline-2026-08-17.md). Rewriting the description into
commit-message register — before scoring it — measurably recovers most of that gap
(gate-harness/reports/query-rewrite-2026-08-17.md: casual R@5 51.5% → 64%+ after rewriting; a
follow-up round confirmed the rewrite itself does the work, not an incidental directory listing the
first measurement's prompt also carried —
gate-harness/reports/query-rewrite-followup-2026-08-18.md).
Two ways to get that rewrite; this project ships the cheaper one as the default.
Caller-rewrite (default on this MCP channel). When a description scores an unconfident
combination of signals, assess_task's response gains a callerRewriteHint: an instruction asking
you, the calling agent, to rewrite the description into commit-message vocabulary and call
assess_task again — no subprocess, no separate auth, one extra turn in the session you're already
running. Validated with a REAL separate caller (a fresh headless session per unit, not the
same session doing the validating) on the two repos where recall actually collapses: 30 units, 15
each of scrapy and nest, casual + vague text only — see
gate-harness/reports/caller-rewrite-validation-2026-08-18.md for the per-repo numbers against the
spawned-subprocess alternative below.
The hint doesn't fire on every unconfident call — that was measured and rejected. Gating on
confidence !== "high" alone fires on effectively every call ("high" never occurs on the
holdout used to measure this), so a real gate was calibrated instead, entirely on the four
tuning repos (zod/fastify/flask/express) — never on the holdout data used to validate it: fires
when at least 3 of 4 structural signals are weak (a low top score, a small gap to the second
candidate, a terse description, no basename evidence on the top candidate). Read once against
already-scored holdout data (no new generation, no threshold changes after looking): calls the gate
fires on gained 19.9% mean R@5 on average; calls it suppressed gained 7.0% — it fires more on the
descriptions that actually benefit, and it correctly never fires on the one repo where rewriting
doesn't move the needle at all (gate-harness/reports/caller-rewrite-gate-2026-08-18.md).
Default is channel-dependent: on for this (MCP) channel and any caller with no channel at all,
off on the Claude Code hook channel (its injection is a one-shot prompt prepend with no
multi-turn "read the hint, rewrite, call again" loop to run in). Either default can be overridden
per call with the callerRewrite boolean.
Spawned subprocess (measured, not shipped as a live feature). The alternative — the server
spawns a headless claude -p process to do the rewrite itself — was measured, not built into the
product: a real call costs ~40,757 tokens (median, dominated by the spawned CLI's own
system-prompt/tool-definition injection — this is real per-call weight a fresh process always pays,
regardless of the actual prompt) and ~14s wall-clock when run in a real project directory
(~10.6s in a bare one), with no known quota price — haiku has no empirical entry in
scripts/quota-weights.json yet. Caller-rewrite's own real cost, measured live in the session that
validated it: a single extra turn adds roughly 1,100–2,100 tokens of genuinely NEW content
(cache-write + output) — but the FULL cost of any turn in a long-running session also re-reads its
accumulated context (cache-read pricing, ~10x cheaper per token than fresh input, but non-zero): in
an ~800K-token session that re-read alone outweighs the spawned call's fixed cost, while in a short,
fresh session caller-rewrite is unambiguously far cheaper. Which one is actually cheaper depends
on how much context the calling session already carries — there is no single answer, which is
exactly why the default routes through the caller (cheap in the common case, and the only path with
zero fixed per-call token floor) while the spawn path stays available as a measured, documented
alternative rather than a shipped one.
If the response contains a Questions for the USER section, that's the clarifying-question path:
the calling agent is expected to relay it to the human verbatim and stop, not answer it internally
(the control contract lives in CLAUDE.md).
assess_plan(steps[])
Same scoring, one step at a time, for a planning agent that already knows which files each step of a multi-step plan touches:
## Plan Assessment
| Step | Recommended model | Difficulty | Band | Verification |
|---|---|---|---|---|
| 1 | claude-sonnet-5 | 0.42 | medium | affected-tests |
| 2 | claude-opus-5 | 0.71 | high | full-suite |reindex()
Forces a full rebuild regardless of whether HEAD has moved — see the Quickstart output above.
Normally unnecessary; every other tool call already rebuilds on a cheap HEAD mismatch.
Two smaller tools round out the API: get_file_risk(path) looks up one file's criticality score
and components without running a full assessment, and report_outcome(recordId, chosenModelId?,
note?) manually records which model was actually used for a prior decision (automated attribution
from git history runs separately and does not need this call).
verify_task(recordId, claimedDone)
The done-verification layer (docs/intake-layer-spec.md's Step 4). Call it after finishing the task
named by recordId, reporting whether your own final message claims it's done. Detects and runs the
project's own test/build/typecheck/lint commands, diffs files actually changed against what was
predicted, and checks any done conditions intake captured — then renders one line, in the same
spirit as the intake message:
Verification: LOW — tests did not run, 1 of 3 predicted files touchedThis is a confidence level, never a correctness verdict — it catches BROKEN work (tests fail, nothing runs, files diverge from the prediction), not WRONG-but-passing work (code that runs clean but doesn't do what was actually asked). Runs entirely locally: no model call, and it never modifies the repo, commits, or installs dependencies — see "What leaves your machine" above.
The clarifying-question dial
docs/intake-layer-spec.md's Step 5, shipped as MEASURABLE rather than proven — the case for
asking rests on one directionally-positive small-sample measurement, not a settled result.
.governor/intake-config.json's interventionLevel (0-100, default 20) asks a clarifying
question on the N% least-clear of THIS project's own prompts, read as a percentile of the project's
own clarity-score history (never an absolute score — scores aren't comparable across models). 0
never asks, 100 always asks. The question itself comes from the SAME intake call that already
returns the score, rewrite, and recommendation — the model is required to return null rather than
invent one when nothing is genuinely missing.
When the dial fires, assess_task returns early with just the question:
Clarifying question: <question>
**Record ID:** `1786617849385-7a337663`Relay it to the user, then call assess_task again with the same description, intakeRecordId, and
either intakeQuestionAnswer or intakeQuestionSkipped: true — capped at exactly one question
round per task. In "auto" automation mode a question still stops for the user first: automation
governs dispatch, not consent to be asked.
The Claude Code hook channel
The second channel over the same engine: instead of an agent calling a tool, a UserPromptSubmit
hook asks a warm background daemon for the assessment and injects context directly into the
prompt — before the session reads your prompt. A Stop hook closes the loop: it reads which model
actually ran from the session transcript, stamps it onto the session's decision records, and
triggers a watcher sweep so commits you just made attribute automatically.
pnpm build
pnpm install-hook # merges hook entries into .claude/settings.json, verifies the daemon starts
pnpm uninstall-hook # removes exactly our entries, stops the daemonInjection policy: what actually gets injected, and for whom
What the hook prints is no longer the same for every session — it is gated by tier AND by an injection profile, on measured evidence rather than assumption:
- Tier
noneinjects nothing. It used to print a one-line "low confidence" notice; the variance-floor round's Part 0 measured that the existing brief already costs strong executors tokens for no completion benefit on this population, and a consolation line when the render gate itself withheld a brief is the same shape of cost for less reason to pay it. - Cheap executors (sonnet, haiku) get the full brief — file list, extended checklist, the advisory model line — unchanged from before this policy existed.
- Strong executors (opus, fable) get one line only:
Governor: task assessed (confidence <level>); full brief via assess_task— no file list, no checklist. The variance-floor round measured that the file-list brief does not stabilize (and slightly destabilizes) these two executors; the AI layer's prompt-rewrite round separately measured that a wrong specific guess is worse than an honest absence of one. A strong executor is pointed atassess_taskfor the full brief on demand rather than handed one it does not need and may act on unnecessarily. - Executor detection reads the last assistant turn's model from the session transcript
(
transcript_path, the same field theStophook already reads) — best-effort, not live: a session's first prompt has no prior turn to read, so it falls through to config. Set.governor/hooks-config.json'sinjectionProfile("cheap"/"strong"/"auto", default"auto") to override detection when it has nothing to say, or to force a profile regardless of what's detected — detection always wins over config when it fires. assess_taskcalled explicitly always returns the full brief, on every channel, regardless of profile — a human (or agent) that asked the question directly gets the full answer; the profile only shapes what the hook chooses to push unprompted.- Every hook-channel record gains
injectionProfileandinjectedBytes— which profile actually rendered and how many bytes actually went into the session's context — so this policy's real effect is measurable from the records alone, not just assumed from the tier.
The safety rail is absolute: every hook path fails open. On daemon absence, any error, or a hard
800ms budget, the hook prints nothing and exits 0 — your prompt proceeds untouched, and the failure
goes to .governor/hook.log, never to your face. The first prompt after a cold start usually
gets no brief (the daemon is still warming); the next one does.
Warm latency scales with repository size, and not gently. Measured directly (warm
assess_task, p50/p95 over 20 calls, real repo content, no synthetic fixtures):
| Repo | Tracked files | p50 | p95 | |---|---|---|---| | json-server | 33 | 18.5ms | 20.3ms | | flask | 236 | 66.7ms | 70.7ms | | this monorepo | 963 | ~3.0s | ~3.1s |
A 4x file-count jump (flask → this monorepo) costs a 45x latency jump, not the ~4x a linear
relationship would predict — file-inference scoring is effectively all of it (2,994ms of the
monorepo's 3,010ms total; 50ms of flask's 64ms), which is worth flagging as a real, unoptimized
scaling issue rather than a rounding error. On a repository this monorepo's size, warm latency
exceeds the hook channel's own 800ms hard budget — a prompt here will typically fail open (no
brief, silent, by design) rather than deliver one. Below a few hundred tracked files, in practice,
warm latency is a non-issue. The assess_task MCP tool has no hard timeout of its own and always
returns the real answer, however long it takes; it is only the hook channel's fail-open budget that
this can blow through on a large repo.
Measure delivery, not installs. A working install still fails open sometimes — a slow daemon
under real load, a machine mid-something-else — and a failed-open call produces no DecisionRecord
at all, so records.jsonl alone cannot tell you how often the hook actually delivered a brief. It
only tells you how often it succeeded, silently dropping every failure from the denominator. Every
hook invocation, delivered or failed-open, is logged with its outcome and latency to
.governor/hook-delivery.jsonl (pnpm dashboard, monorepo-only — see above, reports the
delivered share over the last 7 days) — any field evaluation of the hook channel should read from
there, not from install counts or
from records.jsonl alone. This was a real gap, not a hypothetical one: a controlled measurement
(docs/dev-log.md, "greenfield: four arms under hidden tests") found the hook delivering nothing in
up to 8 of 18 sessions of a real multi-arm run, invisible until the invocation itself was logged.
The honest limitation: a hook cannot change a running session's model — nothing it injects can
flip the picker for you. The product recommends and asks; switching is the user's action. Row 141
made the recommendation itself actionable instead of purely informational, but did not (could not)
close that gap: when the RUNNING model's tier differs from the RECOMMENDED model's tier, the
injected line instructs the agent to stop before starting work and ask you whether to switch,
naming both models and the reason — the same "relay verbatim and stop" shape this project's own
CLAUDE.md already uses for clarifying questions. A same-tier difference (two models in the same
tier) never triggers this — only a tier change does — and it fires at most once per session per
tier-mismatch pairing, not on every prompt. Whether the agent actually obeys that instruction is a
separate, measured question, not an assumption: .governor/tier-offers.jsonl records every
offer and, once the transcript shows what the next turn did, whether it was obeyed — see
docs/dev-log.md row 141 for the mechanism and its own "no obedience-rate claim without data"
caveat. Recommendation-vs-reality lands in the records regardless (recommendedModelId from the
assessment, modelActuallyUsed from the transcript), which is the comparison the product's story
actually needs.
Development
pnpm build
pnpm test # FAST tier: pure unit tests, ~8s total — run this constantly
pnpm test:slow # SLOW tier: real daemon + real IPC latency-budget tests, ~94s
pnpm test:all # both, in order
pnpm lintAny change touching a package runs that package's SLOW tests before commit; pnpm test:all
in full before any publish and at the end of a working session. pnpm test alone cannot catch a
latency regression in the hook or MCP channel — the two tests that would are excluded from it by
design. Full rationale, the per-file timing measurement behind the split, and why the daemon
budget test specifically runs serially: docs/testing.md.
Honest numbers
Full validation detail: docs/current-state.md (current formulas, gate
results, known limitations) and docs/experiment-log.md (every measured
round, chronologically, with cited sources). Summarized plainly, no rounding in the flattering
direction:
- The opening brief's file list — what the calling agent actually receives — recalls roughly 64%
of the right files on casually-worded English task descriptions, at the shipped confidence
gating. Extending that same ranking down through the extended checklist to rank ~20 recovers
recall to roughly 90% on the same population — most of the gap the top-of-list brief misses is
still there, just further down, not gone (
gate-harness/reports/recall-curve-2026-08-13.md). - Zero silent failures on every measured variant: the "high confidence" bucket has never once
been wrong-and-certain across any validation round to date (
docs/current-state.md§5). - The no-AI ceiling is formally closed. Four full measurement rounds after the current
formula shipped — a corrected re-ranking hypothesis, three stronger-embedding-model swaps, and an
exhaustive per-prompt error clinic sweeping 167 configurations — moved casual-English recall by at
most +0.21pp against a pre-registered +3pp bar, or won on the tuning set and lost on the frozen
holdout. Deterministic lexical/graph/co-change/embedding re-ranking has reached its ceiling on
this problem; the verdict and full evidence chain are in
docs/current-state.md§5's "Post-v19: the error clinic and the no-AI ceiling." - Gate 1's own recall/precision thresholds still fail outright against their original targets (recall 74.4% vs. >= 80%, precision 25.6% vs. >= 50%) — mitigated, not closed, by confidence gating and clarifying questions.
None of this is presented as a finished product. It's the actual state of the numbers, and the numbers are the pitch, not a summary written to sound better than they are.
This project measures itself
Every round of formula work runs against a frozen test set (real OSS commit history, sampled
once and never re-touched during tuning) plus a calibration set the tuning is allowed to see,
disjoint by construction — and, past that, an express holdout repo never opened for tuning at
all, to catch a formula that wins on what it was fitted to and loses on what it wasn't.
Pre-registered success/failure criteria are written down before a round runs, not after — see
docs/experiments/opening-brief.md for the currently-running
protocol (opening-brief token savings, BRIEF vs. CONTROL, alternating assignment).
docs/dev-log.md records every development task with the model that ran it, why that model, an
API-equivalent token/cost estimate computed from local session transcripts
(scripts/log-usage.mjs), and — where the routing engine's own recommendation diverged from what
actually ran — that divergence, noted rather than smoothed over. The project is, deliberately, a
pilot of itself.
📊 Cost dashboard — the project's own spend in both
currencies (API-equivalent dollars and subscription-quota points), per-model
breakdown, brief-tier distribution, timing percentiles. Static HTML, no
dependencies, no network; regenerate with pnpm dashboard. Monorepo-only:
it summarizes THIS repository's own docs/dev-log.md and .governor/
history — a project-self-measurement tool, not a feature the installed
@addforce/governor package ships or runs against your repo. There is
currently no equivalent command available from a plain npm i install.
Licence
You may use this software, including for commercial work, and modify it for your own use. You may
not redistribute it, sell it, or use it to build a competing product — see LICENSE
(PolyForm Shield 1.0.0) for the exact terms.
