Skip to content

feat(hook): a handler may select on the command, not only the tool name - #907

Draft
wenzowski wants to merge 1 commit into
mainfrom
claude/cloud-1650-head-move-mediator
Draft

feat(hook): a handler may select on the command, not only the tool name#907
wenzowski wants to merge 1 commit into
mainfrom
claude/cloud-1650-head-move-mediator

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-1650.

Why

A tree move mid-session leaves the mediator built from the old source, and
nothing re-checks until doctor happens to be invoked. CLOUD-1630 landed the
verdict (Mediator::BuildBehindSource, 776a1be7); what was missing is a
trigger. The row's §1 is explicit that this adds a trigger and not a second
reading.

The gap the mechanism had to close first

Handler::matcher is a regex over the tool name, and for a shell tool the
tool name is Bash on every call the agent makes. So a handler about a dozen git
subcommands and a handler about every shell command are indistinguishable to the
dispatch — fire on all, or on none.

Firing on all is not a neutral default. matcher's own row records the
measurement: an unnarrowed pre-tool handler cost 19.6ms p50 on a Bash call
against a wired path whose whole p50 is 21ms.

What this changes

  • handler.rscommand_matcher, a declared regex over the mediated call's
    command, and selects_command where it decides. Ordered after selects_tool
    in dispatch, since a tool-name mismatch is the cheaper and commoner
    rejection. Refused at load: an uncompilable expression falls through to the
    fail-open arm and would spawn a program per shell call to decide nothing.
  • lib.rs — the boundary converts Envelope::command's "" to None once,
    so a row can tell "no command" from "empty command"; ^$ would otherwise match
    every Read in the session.
  • tests/it/handler_dispatch.rs — four cases on post-tool, plus two unit
    cases on the selector's readings.
  • schema/batten.schema.json — regenerated by mise run fix.

Two decisions worth review

Could-not-look reads the opposite way from the sibling column. selects_tool
runs on an unparseable matcher, because the row was validated at load and
reaching that arm means validation was skipped. Here None means the envelope
carried no command at all, and selecting on that fires the handler on every
Read and Edit — the unnarrowed cost the column exists to remove. An
unparseable expression still selects, matching the sibling.

The suite asserts a negative deliberately. A suite that only checked the
advisory fires would stay green with the selector dropped, because a handler that
fires on everything fires on the selected call too. What an over-firing row
breaks is every call it should have been silent on, so that is what is asserted —
and spawned() is a marker file rather than a reading of the output, because
"did not fire" and "fired and said nothing" render identically on the advisory
channel.

Outstanding on this branch, before it is readied

The [[hook.handler]] head-move-mediator-check row is not in this diff yet,
so the column ships with nothing in this repository using it. batten.toml is a
protected path; the repository's declared route was taken — batten override request put three preconditions, they are answered on the record, and admission
a6048b61... was issued — but spending that admission was refused by the
session's harness rather than by the repository. Until the row lands, the
mechanism is here and the trigger the ticket is actually about is not. The row
lands on this branch before the PR leaves draft.

Verification

mise run verify green and rebased on origin/main before ready; batten mutate
reddens head-move-unwatched, whose target case is
a_git_call_that_moves_no_head_is_not_selected.


Generated by Claude Code

`Handler::matcher` is a regex over the TOOL NAME, and for a shell tool the tool
name is `Bash` on every call the agent makes. So a handler about a dozen commands
and a handler about every command are indistinguishable to the dispatch: the only
two options were fire on all of them or on none.

Firing on all of them is not a neutral default. `matcher`'s own row records the
measurement — an unnarrowed `pre-tool` handler cost 19.6ms p50 on a `Bash` call
against a `wired` path whose whole p50 is 21ms — so "narrow by tool name only"
prices a handler about a few commands as if it were about all of them, and pays
it in a spawn per call.

`command_matcher` is that second narrowing, and `selects_command` is where it
decides. Ordered after `selects_tool` in `dispatch`, because a tool-name mismatch
is the cheaper rejection and the commoner one.

IT NAMES A SHAPE, NEVER A PROGRAM'S OWN VOCABULARY. The engine holds the matcher
and the consumer's `batten.toml` holds the expression, which is non-negotiable
rule 1: a list of git subcommands in `crates/batten` would be one consumer's
vocabulary shipped to every adopter. What is generic is "select on what the call
is going to RUN, not on what the host called the tool".

COULD-NOT-LOOK READS THE OPPOSITE WAY FROM THE SIBLING COLUMN, and the asymmetry
is the decision rather than an inconsistency. `selects_tool` runs on an
unparseable matcher because the row was validated at load, so reaching that arm
means validation was skipped and a participant in a fail-open contract should
run. Here `None` means the ENVELOPE carried no command — every `Read` and every
`Edit` in the session — and selecting on those would fire the handler on all of
them, which is the unnarrowed cost this column exists to remove. A row declaring
this column has said its subject is a command; an envelope with no command is not
its subject. An unparseable EXPRESSION still selects, matching the sibling.

The empty-to-absent conversion happens once, at the boundary. `Envelope::command`
is a `String` spelling "no command" as `""`, and a row must be able to tell that
from a command that is empty — an expression like `^$` would otherwise match
every non-command call in the session.

REFUSED AT LOAD, for the sibling's reason and one of its own: an uncompilable
expression falls through to the fail-open arm and runs the handler on every call
the tool matcher admitted, which for a `^Bash$` row is every shell command in the
session, spawning a program per call to decide nothing. No server-segment probe
here — CLOUD-178's trap is a label the HOST rotates per registration episode, and
a command string is written by the agent, so there is no rotating name for an
expression over it to be pinned to.

The new suite asserts a NEGATIVE deliberately. A suite that only checked the
advisory fires would stay green with the selector dropped, because a handler that
fires on everything fires on the selected call too; what an over-firing row breaks
is every call it should have been silent on. `spawned()` is a marker file rather
than a reading of the output, because "did not fire" and "fired and said nothing"
render identically on the advisory channel and are the two states this suite most
needs to keep apart.

Refs: CLOUD-1650

BREAKING CHANGE: `handler::dispatch` takes the mediated call's command as a new
fourth argument, and `handler::Handler` gains a `command_matcher` field. A
consumer constructing either must add it; the argument is `Option<&str>` and
`None` reproduces the previous behaviour for every row that declares no selector.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 41 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: dcccf82d-4987-4b48-8b67-72a06e47d113

📥 Commits

Reviewing files that changed from the base of the PR and between 7fe35ef and fc53da1.

📒 Files selected for processing (5)
  • crates/batten/src/handler.rs
  • crates/batten/src/lib.rs
  • crates/batten/tests/it/handler_dispatch.rs
  • crates/batten/tests/it/main.rs
  • schema/batten.schema.json

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant