feat(rules): a session is told when its rules move under it (#3244, milestone 323 step 5)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 1m0s
CI & Build / Build & push image (push) Skipped

The rules payload carries a marker; the write-path hook hands it back; the
server says which rules moved. Nothing is said when nothing moved.

THE COUNT IS NOT DECORATION. max(updated_at) alone cannot see a DELETED rule
— it moves no timestamp — and that is the single change that takes an
instruction OUT of force, which is the one a session most needs to hear
about. The marker is `<max updated_at>|<count>`, and a deletion is reported
through the count because there is no row left to name.

THE HOOK IS THE CARRIER because it already fires before a write, which is the
moment acting on a stale rule costs something. One comparison, no payload,
no extra round trip.

WHERE THE MARKER IS CAPTURED, and it could not be anywhere else: the
SessionStart hook, from /api/plugin/context. The model also receives one from
list_always_on_rules, but a hook cannot see an MCP tool's result — so the
value the write path compares has to be stored where a shell script can
reach it. Keyed by session id in the state dir the prior-art hook already
uses, so "changed since" means since THIS session loaded its rules.

NOT ON rules_payload, against the task's letter. Those are applicable_rules —
a different, subscription-derived set. One key name over two sets is how a
comparison starts reporting phantom changes, and the write path compares
against the always-on set.

WHAT IT CANNOT SEE is stated in both the service and the write-path arm as a
table, because a reader who finds an etag will assume it covers staleness
generally:

  another session edits a rule mid-flight             | caught
  the session is misremembering a rule read hours ago | caught
  compaction summarised the rules out of context      | NOT caught

The third is the most common, and the marker is blind to it — the etag was in
context too and went with the rules. The SessionStart nudge is that case's
only mechanism and must not be softened because this shipped. A test asserts
both modules still explain that.

Instance-agnostic (rule 115): an install with no rules produces a stable
marker rather than an error, and "no rules" reads as a state rather than as a
change. An unreadable or absent marker reports nothing — a signal that cries
wolf is worse than none, because it trains a reader to skip the line that
will one day be true. The arm fails open like every other arm on this hook.

The delivery is tested through the real build_write_path_hint rather than the
helper alone: the feature IS a line arriving in a session, and the arithmetic
being right proves nothing about that.

Live acceptance is deploy-gated and not yet recorded on the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:02:00 -04:00
co-authored by Claude Opus 5
parent a8b2040216
commit 5c9bb40777
7 changed files with 431 additions and 4 deletions
+11 -1
View File
@@ -175,6 +175,16 @@ while IFS= read -r rel_path; do
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}"
fi
# The rules marker the SessionStart hook stored, handed back so the server
# can say whether those rules moved since (milestone 323). Nothing stored
# means nothing sent, which the server reads as silence rather than as a
# mismatch — an install that never reached /api/plugin/context must not
# start claiming its rules changed.
etag_q=""
if [ -f "$state_dir/${safe_sid}.rules_etag" ]; then
held=$(jq -sRr '@uri' < "$state_dir/${safe_sid}.rules_etag" 2>/dev/null) || held=""
[ -n "$held" ] && etag_q="&rules_etag=${held}"
fi
if [ -n "$path_enc" ]; then
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
# gates nothing the session is waiting on, and the first prior-art call
@@ -184,7 +194,7 @@ while IFS= read -r rel_path; do
reached=1
body=$(curl -fsS --max-time 8 \
-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}${shapes_q}${etag_q}" 2>/dev/null) || { body=""; reached=0; }
# A call that was owed and didn't come back is said, once per outage
# (#2932) — shared marker with the pre-write hook, so one outage is one
# line however the code was written.
+22
View File
@@ -109,6 +109,28 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
# Stash the rules marker for the write-path hook (milestone 323). THIS is
# where it has to be captured: the model receives one from
# list_always_on_rules too, but a hook cannot see an MCP tool's result. Stored
# under the same state dir the prior-art hook already uses, keyed by session,
# so "changed since" means since THIS session loaded its rules.
#
# Written on `compact` as well as `startup`, and that is correct rather than
# convenient: a compact tells the session to re-pull its rules, so the marker
# should describe the set it is about to hold. It is also why this cannot
# cover the compaction case — see the table in services/plugin_context.py.
if [ -n "$body" ]; then
etag=$(printf '%s' "$body" | jq -r '.rules_etag // empty' 2>/dev/null) || etag=""
if [ -n "$etag" ]; then
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
safe_sid=$(printf '%s' "${sid:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
etag_dir="${TMPDIR:-/tmp}/scribe-priorart"
# Best-effort throughout: a marker that cannot be stored costs a hint,
# never the session.
mkdir -p "$etag_dir" 2>/dev/null \
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
fi
fi
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
elif [ -n "$url" ] && [ -z "$token" ]; then
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."