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
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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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).",
|
"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": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
|
|||||||
# Nothing to retrieve against.
|
# Nothing to retrieve against.
|
||||||
[ -n "$prompt" ] || exit 0
|
[ -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).
|
# Unconfigured install → silent (auto-inject is pure enrichment).
|
||||||
scribe_config || exit 0
|
scribe_config || exit 0
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
# same three things, kept here so they cannot drift apart:
|
# same three things, kept here so they cannot drift apart:
|
||||||
#
|
#
|
||||||
# scribe_skip_path PATH formats that hold prose or data, not shapes
|
# 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_defs stdin code → "kind<TAB>name" per definition
|
||||||
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
|
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
|
||||||
# local-duplicate lines (ARM 1, #2280)
|
# local-duplicate lines (ARM 1, #2280)
|
||||||
@@ -43,6 +45,47 @@ scribe_skip_path() {
|
|||||||
return 1
|
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).
|
# JSON AND URL-ENCODING, WITHOUT jq (#4107).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""The prompt boundary retrieves against PROMPTS, not against plumbing (#4200).
|
||||||
|
|
||||||
|
Claude Code submits more than typed words through `UserPromptSubmit`. A task
|
||||||
|
notification, the echo of a slash command and the caveat banner a local command
|
||||||
|
prints all arrive as user turns, and all reach `.prompt` looking exactly like
|
||||||
|
something the operator wrote. `scribe_autoinject.sh` believed them.
|
||||||
|
|
||||||
|
WHY THIS IS TESTED AT ALL, given that retrieving against a notification almost
|
||||||
|
never returns anything. The cost that matters is not the wasted embedding, it
|
||||||
|
is the LOG ROW. Every one of those calls counts in the denominator of every
|
||||||
|
prompt-boundary surface, so delivery rate reads low for a reason unrelated to
|
||||||
|
retrieval quality; and each refusal lands in `near_misses`, where a later
|
||||||
|
tuning decision reads it as 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 have injected that record into every notification — an
|
||||||
|
instrument arguing for the wrong fix, which is exactly #379.
|
||||||
|
|
||||||
|
So the assertions split in two, and the SECOND half is the one guarding
|
||||||
|
against the obvious wrong implementation:
|
||||||
|
|
||||||
|
* the client's envelopes are skipped;
|
||||||
|
* a real prompt that merely CONTAINS one of those tags is NOT — because the
|
||||||
|
cheap version of this filter is a substring search, and a substring search
|
||||||
|
silences an operator who pastes a transcript or asks a question about a
|
||||||
|
tag by name. The filter reads the FIRST token only.
|
||||||
|
|
||||||
|
The corpus below is drawn from real transcript shapes rather than invented,
|
||||||
|
which is the reason the whitespace and trailing-`<system-reminder>` cases are
|
||||||
|
here: both occur.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
|
||||||
|
HOOK = ROOT / "plugin" / "hooks" / "scribe_autoinject.sh"
|
||||||
|
|
||||||
|
# Written by the client. None of these is a question anyone asked.
|
||||||
|
SYNTHETIC = [
|
||||||
|
"<task-notification>\n<task-id>b1oojuqu3</task-id>\n<output-file>/tmp/x</output-file>",
|
||||||
|
"<command-name>/compact</command-name>",
|
||||||
|
"<command-message>compact</command-message>",
|
||||||
|
"<command-args>focus on the hooks</command-args>",
|
||||||
|
"<local-command-stdout>Reconnected to plugin:scribe</local-command-stdout>",
|
||||||
|
"<local-command-stderr>Failed to reconnect</local-command-stderr>",
|
||||||
|
"<local-command-caveat>Caveat: The messages below were generated…</local-command-caveat>",
|
||||||
|
" \n<task-notification>\n<task-id>abc</task-id>", # whitespace must not walk the filter
|
||||||
|
]
|
||||||
|
|
||||||
|
# Typed by a person. Every one of these must still be retrieved against.
|
||||||
|
REAL = [
|
||||||
|
"please merge to main",
|
||||||
|
"awesome go for 3898",
|
||||||
|
"why does <task-notification> show up in the telemetry?", # names the tag
|
||||||
|
"fix the hook<system-reminder>injected</system-reminder>", # tag, but trailing
|
||||||
|
"This session is being continued from a previous conversation.", # see module docstring
|
||||||
|
"<taskbar> is rendering wrong", # a tag, but not one of the client's
|
||||||
|
"here is the transcript you asked for:\n<local-command-stdout>hi</local-command-stdout>",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(prompt: str) -> bool:
|
||||||
|
"""Run the real predicate in the real shell — not a reimplementation."""
|
||||||
|
if shutil.which("bash") is None: # pragma: no cover
|
||||||
|
pytest.skip("hook runtime tool 'bash' not installed")
|
||||||
|
script = f'set -uo pipefail\n. "{DEFS}"\nscribe_skip_prompt "$1" && echo SKIP || echo KEEP\n'
|
||||||
|
out = subprocess.run(
|
||||||
|
["bash", "-c", script, "_", prompt],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
env={"PATH": os.environ["PATH"], "HOME": os.environ.get("HOME", "/tmp")},
|
||||||
|
)
|
||||||
|
assert out.returncode == 0, out.stderr
|
||||||
|
verdict = out.stdout.strip()
|
||||||
|
assert verdict in {"SKIP", "KEEP"}, out.stdout
|
||||||
|
return verdict == "SKIP"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", SYNTHETIC, ids=lambda p: p[:28])
|
||||||
|
def test_client_written_turns_are_skipped(prompt: str) -> None:
|
||||||
|
assert _skip(prompt), f"retrieval would fire on a machine-written turn: {prompt[:60]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", REAL, ids=lambda p: p[:28])
|
||||||
|
def test_operator_prompts_survive(prompt: str) -> None:
|
||||||
|
assert not _skip(prompt), f"a real prompt was silenced: {prompt[:60]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_filter_is_a_prefix_test_not_a_substring_test() -> None:
|
||||||
|
"""The distinction the whole design rests on, asserted as one fact.
|
||||||
|
|
||||||
|
Stated separately from the parametrised cases because a future rewrite
|
||||||
|
that reaches for `grep -q` or `case *"<tag>"*` passes nothing here, and
|
||||||
|
the parametrised failure would read as "one odd input" rather than "the
|
||||||
|
implementation changed shape".
|
||||||
|
"""
|
||||||
|
tag = "<task-notification>"
|
||||||
|
assert _skip(tag + " trailing")
|
||||||
|
assert not _skip("what is " + tag + "?")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hook_consults_the_filter_before_spending_a_request() -> None:
|
||||||
|
"""Placement, not merely presence.
|
||||||
|
|
||||||
|
The guard is worthless below the `curl`: the point is that no row is
|
||||||
|
logged, so it has to sit ahead of the request that would log one.
|
||||||
|
"""
|
||||||
|
body = HOOK.read_text()
|
||||||
|
assert "scribe_skip_prompt" in body, "the hook does not consult the filter"
|
||||||
|
assert body.index("scribe_skip_prompt") < body.index("curl -fsS"), \
|
||||||
|
"the filter runs after the request it exists to prevent"
|
||||||
Reference in New Issue
Block a user