fix(plugin): the prompt boundary retrieves against prompts, not plumbing (#4200)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 17s

Claude Code submits more than typed words through UserPromptSubmit. A task
notification, a slash-command echo and the caveat banner a local command
prints all arrive as user turns, reaching `.prompt` indistinguishable from
something the operator wrote. scribe_autoinject.sh believed all of them.

The cost that matters is not the wasted embedding — it is the log row. Every
such call counts in the denominator of every prompt-boundary surface, so
delivery rate reads low for a reason unrelated to retrieval; and each refusal
lands in `near_misses`, where a later tuning decision reads it as demand.

Measured while taking milestone 399's acceptance (#3898): 15 of the top 20
`preference_slot` near-misses were `<task-notification>` blocks, all matching
ONE record — #140 "Let each action land before starting the next" — all within
thousandths of the 0.70 floor. A notification that an action finished really
does resemble a preference about letting actions land. Lowering the floor to
serve that apparent demand would have injected that record into every
notification: the instrument arguing for the wrong fix, which is #379 again.

scribe_skip_prompt is a PREFIX test, not a substring one, and that is the
whole safety argument. A real prompt may contain one of these tags — an
operator pasting a transcript, or a `<system-reminder>` after typed words —
and must still be retrieved against. Nothing an operator types begins with a
client envelope. The compaction-resume injection is deliberately NOT filtered:
it is machine-written, but it summarises real work, and a resumed session is
where recalling a rule earns its keep.

Client-side only, so no server contract moves and lagging plugin caches keep
working. The tags are Claude Code protocol constructs, identical on every
install — instance-agnostic under rule 115.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-20 18:45:13 -04:00
co-authored by Claude Opus 5
parent 7e653e16dc
commit 1f7ff7b215
4 changed files with 169 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.20.1623",
"version": "2026.09.20.2244",
"author": {
"name": "Bryan Van Deusen"
},
+8
View File
@@ -53,6 +53,14 @@ event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
# Nothing to retrieve against.
[ -n "$prompt" ] || exit 0
# A turn CLAUDE CODE wrote, not the operator (#4200) — a task notification, a
# slash-command echo, a local command's caveat banner. Leaving before the
# request matters more for the LOG than for the noise: a retrieval call
# recorded against a notification inflates this surface's denominator and
# seeds `near_misses` with demand nobody expressed. scribe_skip_prompt carries
# the measurement.
scribe_skip_prompt "$prompt" && exit 0
# Unconfigured install → silent (auto-inject is pure enrichment).
scribe_config || exit 0
+43
View File
@@ -8,6 +8,8 @@
# same three things, kept here so they cannot drift apart:
#
# scribe_skip_path PATH formats that hold prose or data, not shapes
# scribe_skip_prompt TEXT the turn was written by the CLIENT, not the
# operator — skip it entirely (#4200)
# scribe_defs stdin code → "kind<TAB>name" per definition
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
# local-duplicate lines (ARM 1, #2280)
@@ -43,6 +45,47 @@ scribe_skip_path() {
return 1
}
# Skip a turn the CLIENT wrote rather than the operator (#4200). Claude Code
# submits several kinds of machine-written text through UserPromptSubmit — a
# task notification, the echo of a slash command, the caveat banner a local
# command prints — and they reach `.prompt` indistinguishable from typed words
# unless something looks.
#
# THIS IS A TELEMETRY FIX FIRST AND A NOISE FIX SECOND, which is the part
# worth keeping straight: retrieving against a notification usually returns
# nothing, so the visible cost looks like one wasted embedding. The real cost
# is that the call is LOGGED. It inflates the denominator of every
# prompt-boundary surface, so delivery rate reads low for a reason that has
# nothing to do with retrieval; and the refusal lands in `near_misses`, where
# a later tuning decision reads it as unmet demand. Measured on #3898: 15 of
# the top 20 preference near-misses were `<task-notification>` blocks, ALL
# matching one record, ALL within thousandths of the floor. A floor lowered to
# serve that apparent demand would inject that record into every notification
# — the instrument arguing for the wrong fix, which is #379's lesson again.
#
# A PREFIX TEST, NOT A SUBSTRING ONE, and that distinction is the whole safety
# argument. A real prompt may well CONTAIN one of these tags — an operator
# pasting a transcript, or a `<system-reminder>` appended after typed words —
# and must still be retrieved against. Nothing an operator types BEGINS with a
# client envelope.
#
# Leading whitespace goes first: the tag is what identifies the turn, and one
# stray newline ahead of it would otherwise walk past the entire filter.
#
# NOT LISTED, ON PURPOSE: the compaction-resume injection. It is machine
# written too, but it is a summary of real work rather than plumbing, and a
# resumed session is exactly where recalling a rule earns its keep.
scribe_skip_prompt() {
_scribe_prompt=${1#"${1%%[![:space:]]*}"}
case "$_scribe_prompt" in
"<task-notification>"*|\
"<command-name>"*|"<command-message>"*|"<command-args>"*|\
"<local-command-stdout>"*|"<local-command-stderr>"*|"<local-command-caveat>"*)
return 0 ;;
esac
return 1
}
# ---------------------------------------------------------------------------
# JSON AND URL-ENCODING, WITHOUT jq (#4107).
#