feat(rules): the write path can notice a standing rule it was never given (#3031, milestone 307 step 5, hook arm)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s

A conditional rule is not resident, so a session can be about to violate one it
was never handed. This arm notices: when what is being written resembles a
rule's trigger, the hint names it and says to read it before deciding it does
not apply.

A SUGGESTION, and the plan was wrong about why it could be more. It claimed the
hook "already resolves a path to an area" — it does not, and nothing in Scribe
maps a path to a System or a canonical area (build_write_path_hint resolves
paths against snippet LOCATIONS, a different index; the learned-alias idea
belongs to another project). Correction logged on the task. Rather than invent
path→area inference to make a stale claim true, the arm does what D7 already
decided and what this surface already IS: tags bind at enter_project, meaning
suggests here. The header of the hook says NEVER BLOCKS; dressing a hint up as
binding would have been the actual mistake.

CONDITIONAL RULES ONLY. An always-on rule is already in the session, so
re-offering it is noise — and noise on a hint that fires on every write is how
a hint gets ignored.

Telemetry goes to retrieval_logs, NOT note_usage_events, and that is a
correctness call rather than a preference: note_usage ids are REMAPPED on a
backup restore, so a rule id written there would come back attached to whatever
note took that number — silently corrupting the evidence the next true-up is
supposed to read. retrieval_logs is never restored and `source` already
separates surfaces. record_retrieval's `results` type widened to match what it
actually needs (an `.id`), instead of passing a Rule to something annotated Note.

The rule dedup gets its OWN state file and query parameter, like the three
channels before it — #2708's lesson was that one shared channel lets a hint of
one class silence a different class that had never been shown. Plugin version
bumped: a hook change clients cannot see did not ship (#1040).

The stub is autouse in conftest rather than added to forty-odd call sites: the
arm loads an embedding model, and every existing test that stubs the NOTES
search would otherwise pull a real model in through the one arm it had no way
to know about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 16:35:36 -04:00
co-authored by Claude Opus 5
parent 4585cda3ff
commit 02c1e37620
8 changed files with 134 additions and 8 deletions
+8 -4
View File
@@ -1,13 +1,17 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.46", "version": "0.1.47",
"author": { "name": "Bryan Van Deusen" }, "author": {
"name": "Bryan Van Deusen"
},
"mcpServers": { "mcpServers": {
"scribe": { "scribe": {
"type": "http", "type": "http",
"url": "${user_config.api_endpoint}/mcp", "url": "${user_config.api_endpoint}/mcp",
"headers": { "Authorization": "Bearer ${user_config.api_token}" } "headers": {
"Authorization": "Bearer ${user_config.api_token}"
}
} }
}, },
"userConfig": { "userConfig": {
@@ -19,7 +23,7 @@
"api_token": { "api_token": {
"type": "string", "type": "string",
"title": "Scribe API key", "title": "Scribe API key",
"description": "An fmcp_ API key from Settings API Keys (read scope is enough for the session-start hook; write scope to use the tools)", "description": "An fmcp_ API key from Settings \u2192 API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
"sensitive": true "sensitive": true
} }
} }
+15 -1
View File
@@ -174,17 +174,24 @@ mkdir -p "$state_dir" 2>/dev/null || true
# (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the # (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
# shapes being written. Keyed by that token, not a note id, so it dedups on # shapes being written. Keyed by that token, not a note id, so it dedups on
# its own file and a family is named once per session, not at every edit. # its own file and a family is named once per session, not at every edit.
#
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
# file for the same reason as the others — a rule named once should not be
# re-offered on every subsequent write in the session.
idfile="" idfile=""
syncfile="" syncfile=""
derivefile="" derivefile=""
rulefile=""
exclude_q="" exclude_q=""
sync_exclude_q="" sync_exclude_q=""
derive_exclude_q="" derive_exclude_q=""
rule_exclude_q=""
if [ -n "$session_id" ]; then if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
idfile="$state_dir/${safe_sid}.ids" idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids" syncfile="$state_dir/${safe_sid}.sync.ids"
derivefile="$state_dir/${safe_sid}.derive.ids" derivefile="$state_dir/${safe_sid}.derive.ids"
rulefile="$state_dir/${safe_sid}.rules.ids"
if [ -f "$idfile" ]; then if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//') seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}" [ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
@@ -197,6 +204,10 @@ if [ -n "$session_id" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen="" derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}" [ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi fi
if [ -f "$rulefile" ]; then
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
fi fi
# Not `|| exit 0`: an unreachable instance must not discard a local finding # Not `|| exit 0`: an unreachable instance must not discard a local finding
@@ -206,7 +217,7 @@ fi
reached=1 reached=1
body=$(curl -fsS --max-time 5 \ body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \ -H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; } "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
unreached_context="" unreached_context=""
if [ "$reached" = 1 ]; then if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "${safe_sid:-nosession}" scribe_reached "$state_dir" "${safe_sid:-nosession}"
@@ -227,6 +238,9 @@ if [ -n "$body" ]; then
if [ -n "$syncfile" ]; then if [ -n "$syncfile" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi fi
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
fi
if [ -n "$derivefile" ]; then if [ -n "$derivefile" ]; then
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
fi fi
+7
View File
@@ -129,6 +129,11 @@ async def write_path_prior_art():
surfaced. A separate channel on purpose: a reuse surfaced. A separate channel on purpose: a reuse
hint shown early must not suppress the record-sync hint shown early must not suppress the record-sync
nudge when the recorded file is edited later. nudge when the recorded file is edited later.
exclude_rule_ids (opt) — comma-separated RULE ids already surfaced
this session. Its own channel like the three
above, and for the same reason: a rule named
twenty turns ago should not be re-offered on
every subsequent write.
exclude_derive (opt) — comma-separated derive keys (a derive group id exclude_derive (opt) — comma-separated derive keys (a derive group id
or `canon:<snippet_id>`) already named this or `canon:<snippet_id>`) already named this
session by the ledger arm (#2900); its own session by the ledger arm (#2900); its own
@@ -151,6 +156,7 @@ async def write_path_prior_art():
exclude_derive = [ exclude_derive = [
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip() p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
] ]
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
shapes = _parse_shapes(request.args.get("shapes") or "") shapes = _parse_shapes(request.args.get("shapes") or "")
api_key = getattr(g, "api_key", None) api_key = getattr(g, "api_key", None)
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write" may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
@@ -161,6 +167,7 @@ async def write_path_prior_art():
stamp_shapes=shapes if may_stamp else None, stamp_shapes=shapes if may_stamp else None,
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "", repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
exclude_derive=exclude_derive, exclude_derive=exclude_derive,
exclude_rule_ids=exclude_rule_ids,
) )
return jsonify(result) return jsonify(result)
+7
View File
@@ -711,6 +711,7 @@ async def semantic_search_rules(
query: str, query: str,
limit: int = 5, limit: int = 5,
threshold: float = _SIMILARITY_THRESHOLD, threshold: float = _SIMILARITY_THRESHOLD,
tier: str | None = None,
) -> list[tuple[float, "Rule"]]: ) -> list[tuple[float, "Rule"]]:
"""Return up to *limit* (score, rule) pairs most relevant to *query*. """Return up to *limit* (score, rule) pairs most relevant to *query*.
@@ -721,6 +722,11 @@ async def semantic_search_rules(
is the surfacing question, and it has its own machinery is the surfacing question, and it has its own machinery
(get_applicable_rules) rather than a second, subtly different copy here. (get_applicable_rules) rather than a second, subtly different copy here.
`tier` narrows to one tier. The write-path hint passes "conditional",
because an always-on rule is ALREADY in the session — surfacing it again as
a suggestion is pure noise, and noise on a hint that fires on every write
is how a hint gets ignored.
Collapses to best-chunk-per-rule like the note search, so a long rule split Collapses to best-chunk-per-rule like the note search, so a long rule split
across chunks competes once rather than crowding the results with itself. across chunks competes once rather than crowding the results with itself.
@@ -757,6 +763,7 @@ async def semantic_search_rules(
Rulebook.owner_user_id == user_id, Rulebook.owner_user_id == user_id,
Project.user_id == user_id, Project.user_id == user_id,
), ),
*( [Rule.tier == tier] if tier else [] ),
) )
# Overfetch so collapsing chunks to their best row still fills # Overfetch so collapsing chunks to their best row still fills
# the page — the same reason the note search overfetches. # the page — the same reason the note search overfetches.
+49 -2
View File
@@ -30,7 +30,7 @@ from scribe.services import rulebooks as rulebooks_svc
from scribe.services import shape_ledger as shape_ledger_svc from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc from scribe.services import snippets as snippets_svc
from scribe.services.access import label_shared_items, owner_names_for from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import semantic_search_notes from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
from scribe.services.note_usage import record_surfaced from scribe.services.note_usage import record_surfaced
from scribe.services.supersession import superseded_ids from scribe.services.supersession import superseded_ids
from scribe.services.retrieval_telemetry import record_retrieval from scribe.services.retrieval_telemetry import record_retrieval
@@ -707,6 +707,7 @@ async def build_write_path_hint(
stamp_shapes: list[tuple[str, str]] | None = None, stamp_shapes: list[tuple[str, str]] | None = None,
repo_key: str = "", repo_key: str = "",
exclude_derive: list[str] | None = None, exclude_derive: list[str] | None = None,
exclude_rule_ids: list[int] | None = None,
) -> dict: ) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit. """Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -766,7 +767,8 @@ async def build_write_path_hint(
""" """
cfg = await get_writepath_config(user_id) cfg = await get_writepath_config(user_id)
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg, empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
"stamped": [], "divergence": [], "derive": [], "derive_keys": []} "stamped": [], "divergence": [], "derive": [], "derive_keys": [],
"rule_ids": []}
path = (path or "").strip() path = (path or "").strip()
if not cfg["enabled"] or not path: if not cfg["enabled"] or not path:
return empty return empty
@@ -1036,6 +1038,50 @@ async def build_write_path_hint(
for arm, ids in by_arm.items(): for arm, ids in by_arm.items():
record_surfaced(user_id=user_id, note_ids=ids, source=arm) record_surfaced(user_id=user_id, note_ids=ids, source=arm)
# ── Standing rules that may apply here (milestone 307) ──────────────
#
# A SUGGESTION, not a binding surface, and the distinction is the design
# (D7): a rule BINDS by being tagged to an area the project works in,
# resolved deterministically at enter_project. This arm reaches for
# something weaker and still useful — a conditional rule whose trigger
# resembles what is being written, noticed at the moment it is relevant
# rather than by being resident in every session.
#
# CONDITIONAL ONLY. An always-on rule is already in the session; repeating
# it here would be noise, and noise on a hint that fires on every write is
# how a hint gets ignored.
#
# Fails open like every other arm: a rule hint must never break a write.
rule_ids: list[int] = []
try:
already = set(exclude_rule_ids or [])
hits = await semantic_search_rules(
user_id, code or path, limit=2,
threshold=cfg["threshold"], tier="conditional",
)
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
for _score, rule in fresh:
trigger = (rule.when_to_apply or "").strip()
lines.append(
f"Standing rule that may apply here — \u201c{rule.title}\u201d"
+ (f" ({trigger})" if trigger else "")
+ f". Read it with get_rule({rule.id}) before deciding it "
"does not apply; it is not in this session's loaded set."
)
rule_ids.append(rule.id)
if fresh:
# retrieval_logs, NOT note_usage_events: that table's ids are
# remapped on a backup restore, so a rule id there would return
# attached to whatever note took that number. This one is never
# restored, and `source` already separates the surfaces.
record_retrieval(
user_id=user_id, source="write_path_rule", query=code or path,
threshold=cfg["threshold"], limit=2, project_id=project_id,
is_task=None, results=fresh,
)
except Exception:
logger.debug("write-path rule arm failed", exc_info=True)
return { return {
"context": "\n".join(lines), "context": "\n".join(lines),
"note_ids": note_ids, "note_ids": note_ids,
@@ -1045,6 +1091,7 @@ async def build_write_path_hint(
"divergence": divergence, "divergence": divergence,
"derive": derive, "derive": derive,
"derive_keys": [d["key"] for d in derive], "derive_keys": [d["key"] for d in derive],
"rule_ids": rule_ids,
} }
+10 -1
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import Any
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -108,11 +109,19 @@ def record_retrieval(
limit: int | None, limit: int | None,
project_id: int | None, project_id: int | None,
is_task: bool | None, is_task: bool | None,
results: list[tuple[float, Note]], results: list[tuple[float, Any]],
duration_ms: float | None = None, duration_ms: float | None = None,
) -> None: ) -> None:
"""Fire-and-forget: record one retrieval call. """Fire-and-forget: record one retrieval call.
`results` needs only `.id` on each record, which is why it is not typed to
Note: rules are retrieved too (milestone 307) and land here rather than in
note_usage_events. That table's ids are REMAPPED on a backup restore, so a
rule id written into it would come back attached to whatever note happened
to take that number — silent corruption of the very evidence this exists to
provide. retrieval_logs is not restored at all, so it has no such hazard,
and `source` already distinguishes the surfaces.
Builds the payload inline (synchronously) then schedules the insert so the Builds the payload inline (synchronously) then schedules the insert so the
caller returns immediately. Never raises — telemetry must not affect search. caller returns immediately. Never raises — telemetry must not affect search.
""" """
+21
View File
@@ -82,3 +82,24 @@ def _no_supersession():
with patch("scribe.services.plugin_context.superseded_ids", with patch("scribe.services.plugin_context.superseded_ids",
AsyncMock(return_value=set())): AsyncMock(return_value=set())):
yield yield
@pytest.fixture(autouse=True)
def _no_rule_arm():
"""Stub the write-path hint's standing-RULES arm (milestone 307).
Autouse, and deliberately so. The arm calls semantic_search_rules, which
loads the embedding model — so every unrelated plugin-context test that
already stubs the NOTES search would otherwise pull a real model into a
unit test through the one arm it forgot to stub. The forty-odd existing
call sites should not each have to learn about a new arm.
The arm's own behaviour is covered where it belongs: the document shape in
tests/test_services_rule_embeddings.py, the surfacing rules against real
Postgres in tests/test_integration_rule_surfacing.py, and the hook's dedup
channel in tests/test_write_path_trigger.py. A test that wants the arm
live can re-patch it.
"""
with patch("scribe.services.plugin_context.semantic_search_rules",
AsyncMock(return_value=[])):
yield
+17
View File
@@ -1435,3 +1435,20 @@ async def test_the_write_time_divergence_check_is_named_in_band():
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")]) out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")])
check.assert_not_awaited() check.assert_not_awaited()
assert out["divergence"] == [] assert out["divergence"] == []
def test_hook_keeps_the_rule_channel_apart_from_the_other_three():
"""Milestone 307's arm, pinned the way #2708's was.
Standing rules dedup on their OWN file and their OWN query parameter. One
shared channel is the bug #2708 already fixed once: a hint of one class
silencing a different class that had never been shown. A rule named early
must not be re-offered on every later write, and must not silence — or be
silenced by — a snippet suggestion.
"""
src = HOOK.read_text()
assert ".rules.ids" in src # its own state file
assert "exclude_rule_ids=" in src # its own query channel
assert "(.rule_ids // [])[]?" in src # its own write-back
# And it rides the same request as the rest, not a second round trip.
assert "${rule_exclude_q}" in src