Packs & configuration

Use the packs you have, ship your own in a Claude plugin, and tune hook settings.

Packs bundle hooks you install once and get everywhere. Exactly two providers exist — the capt-hook wheel’s builtins and Claude Code plugins — with zero per-repo config: no manifest, no enable list, no .claude/capt-hook.toml. This page covers using the packs you have, shipping your own in a plugin, and tuning hook settings.

Builtin packs activate themselves

capt-hook ships seven builtin packs. Five are always active in every repo:

  • general — language-agnostic guards: git/jj command safety, a session guard that denies pkill/killall, a kill or renice of a pid unless this session can prove it started the process, since ancestry alone does not establish ownership, the harness’s TaskStop tool from a subagent or Orca lane, since a bare task id does not tell a disposable shell task from a workflow, agent, or teammate session (the root session stops any of its tasks unchecked), orca terminal close/worktree rm/worker-stop (the root coordinating a dispatch’s Orca Run may stop that dispatch with one literal worker-stop), launchctl teardown, session-ending osascript/softwareupdate/pmset/tmux, and shutdown/reboot, in Bash and in MCP payloads, doc and prompt nudges, task and plan discipline, model-routing nudges, a code-review gate, a docs-freshness gate, a detour check-in nudge, a tooling nudge that asks for a tool-fix lane after a first-party tool refuses, a manual workaround next to a tool, or a command repeated by hand, verbose-comment enforcement (a new over-budget comment is denied; an edit to a comment that was already over budget only warns), rm safety, and a plain-English reply rewrite. An owner ruling that names its target lifts a terminal close, launchd service stop, a lane’s TaskStop, or a kill through tickets for as long as the ruling stands, and each close, service-stop, or kill ticket covers an identical retry for two minutes. A lifted Bash call must run once at top level, first in the payload, with only pipe successors. A session may also close an idle Orca terminal it proves it created itself. The rewrite sends each assistant reply through Cerebras (qwen-3.8-27b) and does nothing until CEREBRAS_API_KEY is set. With the key set, it hides the reply’s streamed chunks and shows the rewrite in their place once the last chunk arrives. Code blocks, names, numbers, and commands carry over unchanged. A reply with under 200 characters of prose outside code blocks shows as written, and a failed or empty rewrite shows the original reply and records a fault.
  • fixes — workarounds for upstream Claude Code issues: auto-approving a teammate’s permission dialogs and scratch-file writes when the session ran with --dangerously-skip-permissions.
  • steering — judgment nudges: prefer first-principles fixes over band-aids, don’t dismiss a real pre-existing issue, don’t chase trivial type noise, don’t swap the requested fix for docs.
  • performance — wall-clock pipelining nudges that speculate a held fix while verification runs, plus a subprocess resource monitor. One host ticker in capt-hookd samples the children of each registered session. When a child runs past min_runtime_seconds at sustained CPU or disk pressure, the monitor warns the session through its next hook event and a desktop notification, then waits out the grace period. It then asks TypeSafe Jev whether the child is disposable. When Jev says yes with at least 0.9 probability, the monitor sends one targeted SIGTERM after it revalidates the pid, parent, process group, start time, command, working directory, and session ownership. Deploys, releases, publishes, database clients and migrators, and anything protected are never signaled, and neither are their ancestors. Protected means agent sessions, terminal hosts, PTY daemons, Orca, launchd, and the Captain Hook host. Jev cannot override those exclusions. See Tune the subprocess resource monitor to change the thresholds or turn the monitor off. The pack also refuses a new local Go or Cargo build or test while the host is saturated; see Defer local builds on a saturated host.
  • graphite — Graphite (gt) workflow guards that stay inert outside a gt repo init’d repo. Every hook checks for .git/.graphite_repo_config across worktrees and honors ccx.nogt. That git-config key opts a repo out of the gt lane, so a stale marker cannot activate the pack. Neither can a marker on a repo Graphite has no access to: with ccx on PATH the pack reads ccx vcs lane --json and stands down wherever ccx reports a lane other than gt, five-minute cached per repository so a linked worktree and its main checkout read one verdict. One hook refuses a command; the rest note or rewrite. A jj write gets a note saying the repository’s stack lives in Graphite; read-only jj commands (log, status, diff, bookmark list, …) get none when every jj call on the line is a read. Raw git commit/git push/branch writes get a note naming ccx vcs ship/ccx vcs stack new. Its presubmit review reminder quiets once a cc-review pass has run or the session’s dispatch prompt contains a whole ccx: role=fix line. Any role value counts because the parent owns the lane’s review pass. Rebase, merge, and pull notes name ccx vcs stack rebase (ccx 0.65.0 or later) or ccx vcs stack submit. When ccx is on PATH, a stack write with a ccx twin is rewritten to it: gt submit, gt s, and gt ss become ccx vcs stack submit (-d/--draft carries over as --draft), and a bare gt restack becomes ccx vcs stack restack. A git push --force-with-lease of the checked-out branch to origin, with no lease value of its own, becomes ccx vcs push, which force-pushes under a lease only when the branch was rewritten and refuses a remote head the branch never held. The rewrite applies only when the ccx verb covers every flag on the call and no wrapper, env prefix, or global option would be dropped. Everything else it routes, from gt sync, gt create, gt modify, a scoped gt restack, and git rebase to a plain git push --force, runs as written with a note naming the ccx command. A rebase control (--continue, --abort, --skip) inside a ccx conflict workspace runs as written too, with a note naming ccx vcs stack continue / abort (ccx 0.65.0 or later), since it is the workaround when that verb fails. End a command with a real shell comment, # ccx:raw, or set CAPT_HOOK_CCX_RAW=1 for the session, to run it as written and silence the note. Quoted text and heredoc bodies do not count as comments. Only 1, true, or yes enable the environment escape. Matching ignores case and surrounding whitespace; 0 does not count. With ccx on PATH, a gh pr view --json query for landing fields warns and names ccx vcs pr status <n> (ccx 0.64.1 or later) or ccx vcs status; the warning quiets after either command runs this session. The one refusal is a push to a PR the Graphite merge queue holds. With ccx on PATH, git push, ccx vcs push, ccx vcs ship, ccx vcs stack submit, and gt submit look up each pushed branch’s open PR and read their queue state in one ccx vcs pr status --json call. A queued PR that receives any head but the commit the queue admitted is refused. The queue lands that commit and drops the rest, so the refusal names ccx vcs stack new <name> and ccx vcs ship to put the change in a stacked PR instead. When the queued PR is only a parent that a ccx vcs ship would push along with its downstack, the refusal names ccx vcs ship --tip-only instead, which pushes only the shipped branch. # ccx:raw or CAPT_HOOK_CCX_RAW=1 pushes anyway, and a lookup that fails, times out, or answers from a stale cache allows the push.

Two more activate by language, detected from the repo’s build manifests:

  • go — a narrowest-test-run nudge and a go.mod nudge — active when a recursive, non-ignored go.mod or go.work exists anywhere in the repo. The test nudge stands down while the host is saturated, so it never asks for a test the performance pack refuses to start.
  • python — a narrowest-test-run nudge, AST style rules, and a uv missing-dependency nudge — active when a recursive, non-ignored pyproject.toml exists.

Detection walks the repo once, pruning VCS metadata and .gitignored directories, so a vendored go.mod under .venv or a testdata pyproject.toml you ignore never counts. There is no opt-out for a builtin: to change what fires, fix the policy in capt-hook itself rather than adding a repo exception. To turn every capt-hook behavior off, disable the captain-hook plugin — its wiring dispatches every event, so without it no hook runs, builtin or plugin.

List the active packs

pack list shows the active builtins plus any pack your enabled plugins ship — read-only, no config to edit:

uvx capt-hook pack list
  builtin:fixes                      builtin  2 hooks
  builtin:general                    builtin  13 hooks
  builtin:graphite                   builtin  1 hooks
  builtin:performance                builtin  3 hooks
  builtin:python                     builtin  3 hooks
  builtin:steering                   builtin  3 hooks
  plugin:cc-context@cc-context       plugin   32 hooks
  # ...plus a row per pack your other enabled plugins ship

A pack’s runtime id is builtin:<name> for a wheel builtin and plugin:<plugin-id> for a plugin pack. A plugin pack’s version, description, and repository all derive from its plugin.json — nothing about a pack is separately authored.

Test your own hooks

capt-hook test runs the inline tests on your repo’s own .claude/hooks/ modules:

uvx capt-hook test

The builtins are tested by captain-hook itself, and a plugin’s pack is tested from its own repo with pack test — so capt-hook test covers only what your repo owns.

How packs combine with your own hooks

Your .claude/hooks/ modules import first, so they register ahead of every pack. A block from any hook wins the event outright; among approvals the first registered wins, so a local approval outranks a pack’s on the same event. Packs are the shared baseline; your local hooks stay authoritative.

Author a pack

A pack is a directory of hook .py files plus an optional pack.toml descriptor. Each hook file is a flat module that imports from captain_hook and registers with the same primitives you use locally:

from captain_hook import Allow, Block, Input, block_command

block_command(
    ["git", "push", "--force"],
    reason="Force-push rewrites shared history",
    hint="Push a new commit instead",
    tests={
        Input(command="git push --force"): Block(),
        Input(command="git push"): Allow(),
    },
)

You ship that pack to other repos by putting it inside a Claude plugin at the fixed path capt-hook/{pack.toml, hooks/}. Ship a pack in a plugin covers the layout, the descriptor, and the pack test authoring lane end to end.

Ship a pack in a plugin

A Claude plugin can carry one capt-hook pack, and dispatch discovers it: every event, the dispatcher enumerates the project’s enabled plugins and loads the pack any of them ships at the fixed path capt-hook/{pack.toml, hooks/} under the plugin root. The pack applies to whatever project the plugin is enabled in, with zero repo edits and zero per-session wiring — the plugin runs nothing itself.

There is no other way to distribute a pack: no GitHub sources, no tarballs, no pins, no per-repo enable list. A standalone pack becomes a minimal plugin.

The layout

A pack plugin ships its pack at one fixed path:

your-plugin/
├── .claude-plugin/
│   └── plugin.json
└── capt-hook/
    ├── pack.toml
    └── hooks/
        ├── guard.py
        └── ...

The capt-hook/ directory is the pack. Present, it must carry both a pack.toml and a hooks/ directory; a capt-hook/ missing either, or with a malformed pack.toml, is a fatal load error at every dispatch in every repo that enables the plugin — never a silent skip, because a dropped pack silently drops guards. A plugin with no capt-hook/ directory merely consumes packs and is skipped. Wire pack test (below) into the plugin’s CI so a malformed pack is caught before release.

pack.toml is a tiny descriptor

The descriptor holds only what can’t be derived from the plugin — its identity, version, description, and repository all come from plugin.json. So pack.toml carries just two things, both optional:

# Resources the pack's hooks need provisioned before they run.
resources = ["spacy:en_core_web_sm", "wordnet:oewn:2025"]

# MCP tool gate semantics: how a tool lowers to a built-in gate.
[tools.ccx_code_edit]
behaves_like = "Edit"
span_edit = { path = "path", content = "content", delete = "delete" }

[tools.BashFormat]
behaves_like = "Bash"

resources names NLP or tool resources the pack’s hooks need eagerly provisioned; capt-hook provisions the union of every active pack’s resources once at session start. A [tools.<name>] table tells capt-hook an MCP tool behaves like a built-in gate — its key is the bare tool segment (ccx_code_edit, not mcp__server__ccx_code_edit), which matches the tool under any server or mount prefix. A tool name may be claimed by only one pack; a collision across packs is fatal. A pack that needs neither still ships a pack.toml (an empty resources = [] is fine) — the file is what declares capt-hook/ an intentional pack.

The captain-hook dependency

plugin.json must declare the captain-hook dependency with a version floor:

{
  "dependencies": [
    { "name": "captain-hook", "marketplace": "captain-hook", "version": ">=11.0.0" }
  ]
}

The object form is required: name, marketplace, and a >=X.Y.Z floor. This dependency is what pulls the dispatcher onto a machine that installs only your plugin; without it the pack has no runtime.

Test the pack

pack test validates and runs a working-tree pack, so CI needs one line:

uvx capt-hook pack test .

Pass the plugin root — the directory holding .claude-plugin/plugin.json and capt-hook/. It requires all three artifacts, validates the captain-hook dependency floor, validates the pack.toml resources and tool specs, loads only this working-tree pack, and runs its inline tests. It exits non-zero on a missing layout, a bad dependency, an unknown resource, a load error, zero hooks, an async_=True decision hook, or a failing test.

Tell users how to install

Two lines — your marketplace and your plugin:

claude plugin marketplace add your-org/your-repo
claude plugin install your-plugin@your-marketplace

On a machine that has never seen captain-hook, installing just your plugin surfaces Claude Code’s dependency error: the plugin.json dependency points at a marketplace the machine doesn’t know, so the plugin lands disabled with dependency-unsatisfied. The one-time fix is registering the marketplace — claude plugin marketplace add yasyf/captain-hook, or capt-hook init in any repo, which registers it as a side effect. From then on the dependency resolves and Claude Code installs the dispatcher itself.

How discovery works

Dispatch reads the project’s plugin roster from Claude Code’s own files on every discovery, with no subprocess. ~/.claude/plugins/installed_plugins.json lists each install’s path, scope, and, for a project or local install, the project it belongs to. The enabledPlugins maps in the user, project, local, and managed settings files decide which plugins are on, with the higher-precedence file winning. In a git worktree the local settings file is the one at the main checkout’s root, as in Claude Code. Discovery then checks each enabled install’s root for the fixed capt-hook/ path. Installing, enabling, or disabling a plugin takes effect on the next event, and so does editing a hook file inside a plugin.

A plugin can be installed at more than one scope. Discovery ignores installs that belong to another project, skips install paths that no longer exist, and follows Claude Code’s precedence among the rest: local over project over user. Two different install paths at the same scope of one project mean a corrupt roster, and so does an installed_plugins.json that isn’t a roster object. Either way discovery loads zero plugin packs and records the error where capt-hook status and the next session start show it.

Policy that arrives outside these files, such as an MDM plist, a remote managed policy, or per-session flags like --plugin-dir and --settings, is not visible to discovery.

Discovered packs are first-class: they show in uvx capt-hook pack list as kind plugin. Their inline tests run from the plugin’s own repo with pack test, not from a consuming project’s capt-hook test.

Route a misfire home

When the session reviewer mines a misfiring hook, it routes the fix PR to the pack’s home repo: a builtin pack’s fix goes to captain-hook, and a plugin pack’s fix goes to the repository in its plugin.json. A plugin that declares no repository has no route, so its misfires are dropped rather than misfiled.

Configure hook settings

You define a HooksSettings subclass in .claude/hooks/conf.py, override its fields from code or environment variables, and read the values inside a handler through evt.ctx.c.

Define a settings class

Create .claude/hooks/conf.py and subclass HooksSettings. Add your own fields with type annotations and defaults:

from captain_hook import HooksSettings


class ProjectSettings(HooksSettings):
    test_command: str = "pytest"
    require_tests_after_edit: bool = True

The loader discovers conf.py when it imports your hooks package, finds the HooksSettings subclass, and instantiates it for every event. You do not call any builder yourself. Put exactly one subclass in conf.py so the loader has a single class to pick.

Read settings in a handler

Every event carries the active settings on evt.ctx.c. Cast it to your subclass to get typed field access:

from typing import cast

from captain_hook import Event, Tool, on

from .conf import ProjectSettings


@on(Event.PreToolUse, only_if=[Tool("Bash")])
def enforce_test_command(evt):
    settings = cast(ProjectSettings, evt.ctx.c)
    if (cmd := evt.command).raw and cmd.q.runs("pytest") and settings.test_command != "pytest":
        return evt.block(f"Run the project test command: {settings.test_command}")
    return None

Override fields from the environment

HooksSettings reads environment variables with the HOOKS_ prefix, so each field maps to one variable. Set HOOKS_TEST_COMMAND=mtest to override test_command without touching conf.py. List and tuple fields take JSON:

HOOKS_PLANNING_AGENTS='["Explore","Plan","research"]'

To namespace project settings under a different prefix, set model_config on your subclass:

from pydantic_settings import SettingsConfigDict

from captain_hook import HooksSettings


class ProjectSettings(HooksSettings):
    model_config = SettingsConfigDict(env_prefix="MYAPP_")
    require_tests_after_edit: bool = True

Now MYAPP_REQUIRE_TESTS_AFTER_EDIT=false flips the field, and every inherited field moves under the new prefix too. Pick the prefix once at the project root and keep it stable.

Tune the built-in fields

HooksSettings ships built-in fields you can override the same way.

planning_agents

A list of subagent names that the framework treats as scratch work, not real edits. skip_planning_agents is on by default for every hook, so hooks ignore Event.SubagentStart and Event.SubagentStop for any agent on this list and planning runs do not trip your gates. The defaults cover Claude Code’s built-in planning agents, such as Explore and Plan. Add your own when your team uses custom planning agent types:

class ProjectSettings(HooksSettings):
    planning_agents: list[str] = [
        *HooksSettings.model_fields["planning_agents"].default_factory(),
        "research",
    ]

waiting_tools

A list of tool names that mean the agent is waiting on something external, not working. The Waiting() condition reads this list to suppress nudges and gates while a background tool is still running. The defaults cover Claude Code’s long-running tools, such as Monitor and TeamCreate. Append your custom async tools to the defaults:

class ProjectSettings(HooksSettings):
    waiting_tools: list[str] = [
        *HooksSettings.model_fields["waiting_tools"].default_factory(),
        "MyAsyncTask",
    ]

state_dir

Where @session_state and @workflow_state models persist their JSON. The field reads CAPTAIN_HOOK_STATE_DIR, falling back to ~/.claude/state. Set CAPTAIN_HOOK_STATE_DIR to relocate state, which helps in CI sandboxes that block writes outside the workspace. See State and sessions for how state directories are laid out.

log_dir

Where the framework writes its own session logs. The field reads CAPTAIN_HOOK_LOG_DIR and otherwise falls back to ~/.cache/captain-hook/logs, or to $XDG_CACHE_HOME/captain-hook/logs when you set XDG_CACHE_HOME.

Tune the subprocess resource monitor

The performance pack’s resource monitor reads PerformanceSettings, which uses the HOOKS_PERFORMANCE_ prefix. The defaults live in internal/wireproto/resource.json, so the Go host and the Python worker read one set of numbers.

The host reads the first ten keys below and the worker reads all fourteen. Where each value comes from differs by key:

  • Per session: the host takes the thresholds, the grace period, the escalation delay, and the per-session cap from the environment of the request that registers the session. Set those in the shell that launches Claude Code.
  • Per daemon: the sample interval and the registry cap come from the environment capt-hookd started with. The interval also sets how many samples fill the sustain window. A session’s own HOOKS_PERFORMANCE_SAMPLE_INTERVAL_SECONDS does not change either one.
  • Worker: the worker reads its keys from the environment of each hook run, so they follow the session.
Key Default Valid range Read by Meaning
HOOKS_PERFORMANCE_ENABLED true true or false host and worker Turns the monitor on or off
HOOKS_PERFORMANCE_SAMPLE_INTERVAL_SECONDS 15 1 to 86400 host, daemon Seconds between ticks of the shared sampler
HOOKS_PERFORMANCE_MIN_RUNTIME_SECONDS 120 1 to 86400 host A child must have run this long before it can raise pressure
HOOKS_PERFORMANCE_CPU_FRACTION 0.5 above 0, up to 1 host CPU use that counts as heavy, as a fraction of one core
HOOKS_PERFORMANCE_SUSTAIN_SECONDS 60 1 to 86400 host How long CPU or disk use must stay heavy before the warning
HOOKS_PERFORMANCE_DISK_BYTES_PER_SECOND 52428800 0 or more host Disk throughput that counts as heavy, 50 MiB per second
HOOKS_PERFORMANCE_GRACE_SECONDS 60 1 to 86400 host Wait between the warning and the judge
HOOKS_PERFORMANCE_ESCALATE_AFTER_SECONDS 30 0 to 86400 host Wait after SIGTERM before the escalation stage; 0 disables escalation
HOOKS_PERFORMANCE_MAX_TRACKED_PER_SESSION 32 1 or more host Children tracked at once per session
HOOKS_PERFORMANCE_REGISTRY_CAP 512 1 or more host, daemon Sessions the host monitors at once
HOOKS_PERFORMANCE_TERMINATE true true or false worker Whether a disposable verdict ends in a signal
HOOKS_PERFORMANCE_JUDGE_TIMEOUT_SECONDS 3 1 to 300 worker Time limit for one Jev call
HOOKS_PERFORMANCE_MAX_JUDGE_CALLS_PER_SESSION 10 1 or more worker Judge calls allowed per session

An invalid value does not stop the session. If the daemon’s own environment holds one, the daemon logs the error and runs with the defaults. If a session’s environment holds one, the host logs the error and does not monitor that session. In the worker, an invalid value makes the stage refuse.

To keep the warnings and never stop a process, set termination off:

export HOOKS_PERFORMANCE_TERMINATE=false

The warning then says termination is off, and the judge stage leaves the child running. To switch the monitor off entirely, set HOOKS_PERFORMANCE_ENABLED=false.

What stops a signal

The monitor signals a child only when every check passes. Each of these conditions leaves the process running instead:

  • The stage has no current qualifying reading, or the process table, the child’s row, or the child’s working directory is unreadable. A child with no readable working directory is never signaled, and a child whose current readings are all unknown is not signaled at any stage, escalation included.
  • A sample that cannot be read empties the window for that metric. Pressure must then be proven again with fresh readable samples before the child advances.
  • The command line, or an ancestor’s, matches an exclusion, or the child or one of its ancestors is protected.
  • Jev times out, rejects the request, has no key, declines the question, or rates the child disposable below 0.9, or the session runs out of judge calls.
  • The event payload is malformed, or the session state on disk is corrupt.
  • The host cannot dispatch the stage: it is shed, it expired or was abandoned past its deadline, or the host is closing.
  • The registry is full, or the session’s settings are invalid, so the session is never monitored.

Only the warn stage waits for an acknowledgment. The warn hook answers proceed, and the host stops tracking the child when the answer is anything else. The judge and escalate stages are fire and forget from the host’s side: the host does not read the reply. Every refusal in the worker records a notice that the next hook event delivers to the session instead.

The host also refuses a ResourcePressure event sent by a client, and capt-hook run ResourcePressure refuses it on the cold path, so only the host raises the event. The host derives the session’s anchor from the authenticated hook process rather than from the parent pid the request claims.

Escalation sends SIGKILL only after the worker recorded its own SIGTERM for the same process identity. It then repeats the full ownership and exclusion checks and revalidates the identity before it signals. The host runs the escalation stage once per child.

Limits

  • The judge sees the daemon’s environment. The model command-line client inherits the daemon worker’s environment, minus the keys the hooks whitelist. A secret exported into the daemon’s own environment is visible to it. The judge prompt carries a redacted command line, and the redaction is a best effort.
  • macOS validation and signal are separate calls. The monitor checks the process, then sends the signal with kill(2). In the milliseconds between them the process can exit and its pid be reused, exec a different program, or change directory. The monitor reads the working directory first and the identity last to narrow that window, but it cannot close it.
  • Linux uses pidfd. Signaling through a pidfd rules out pid reuse. An exec or a directory change after validation can still happen.
  • The worker’s last identity check is command text, not the argv vector. The host tracks a child by its pid and sub-second start time at every tick and reads the argv vector it sends fresh at each stage dispatch. Right before a signal the worker checks only what ps exposes, which is the parent and group ids, the whole-second start time, the working directory, and the command text with whitespace collapsed. Two argument vectors that join to the same text pass that check, and a process that runs exec with such arguments during the judge passes with a verdict about its earlier invocation.
  • Other users’ processes are not signaled. The sampler rows carry no user id, and Linux can expose a process’s CPU use across users. The worker refuses any target whose user id differs from the session’s.
  • Disk pressure can be unknown. On Linux, a process whose /proc/<pid>/io the monitor cannot read has no disk reading, so only its CPU counts.
  • Children that predate registration are never tracked. A session registers on its first hook event. Anything already running then stays out of scope. Only processes that first appear afterward and descend from the session’s claude process are tracked.
  • Reparented children can drop out. A process is identified by its pid and start time, so reparenting does not make a new identity. A descendant that leaves the session’s process subtree is dropped. The own children of a child the monitor stopped are tracked only if they first appeared after registration and still descend from the session.
  • A calm child keeps its warning. A child that drops below the thresholds is not warned a second time for the same run.

Defer local builds on a saturated host

The host counts as saturated when its 1-minute load average is at or above HOOKS_ADMISSION_LOAD_PER_CORE times its core count. The default is 1, so a 32-core host is saturated at a load of 32. The worker reads the key from each hook run’s environment.

While the host is saturated, the performance pack refuses a Bash call that starts any of these on the local machine:

  • go build, go test, go vet, including go -C <dir> <verb>
  • go run golang.org/x/tools/cmd/deadcode
  • cargo build, cargo check, cargo test, including cargo +<toolchain> <verb>

The check walks the parsed command line. It finds these calls by an absolute executable path, behind env, timeout, nice, time, command, exec, or xargs, inside a sh -c or eval payload, and in a command substitution. A command ssh sends to another host is not parsed as a call, so ssh forge go build ./... and remote-builder exec --lane go -- go test ./... both run. ssh forge true && go build ./... is still refused, because its go build runs locally. The refusal names the load and core count and tells the agent to let CI grade the commit. Remote work the task already has approval for stays possible when written explicitly.

The go pack’s test nudge skips under the same condition.

Limits

  • It sees only the command line. A build started by make, a script, mise exec, uv run, gtimeout, or docker run is not recognized. Neither is one whose executable name comes from a variable.
  • A routing shim reads as local. A plain go test or cargo build that a wrapper on PATH forwards to a remote builder is refused like any local one. Write the remote form explicitly instead.
  • It is a threshold, not a lease. Several calls admitted in the same moment can all start, and a build already running is not stopped.
  • Cargo global options hide the verb. cargo --locked check is not recognized, because only a +<toolchain> argument may come before the verb.

See also