wip(394): steps 6+7 — backend path and instruction surfaces

This commit is contained in:
2026-09-11 15:15:33 -04:00
parent 690ca0306e
commit c149ef31a3
28 changed files with 260 additions and 738 deletions
+37 -110
View File
@@ -8,7 +8,7 @@ Design note — altitude: we inject rule *titles* grouped by topic (a compact
index), NOT every rule's full statement. The 48 always-on statements run well
past the 10k-char `additionalContext` cap, and the push channel's job is to make
Claude *aware* the rules exist and *reach* for them — not to dump them. Full
text stays one `get_rule(id)` / `list_always_on_rules()` call away. Titles are
text stays one `get_rule(id)` / `search(content_type="rule")` call away. Titles are
mostly self-describing ("`dev` is home", "No GitHub — Fabled-Git only"), so the
index alone already steers behavior.
"""
@@ -1400,7 +1400,6 @@ async def build_write_path_hint(
repo_key: str = "",
exclude_derive: list[str] | None = None,
exclude_rule_ids: list[int] | None = None,
rules_etag: str = "",
) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -1686,60 +1685,22 @@ async def build_write_path_hint(
staleness: list[str] = []
# ── Have the rules moved under this session? (milestone 323) ───────
#
# THE CARRIER IS THE POINT. This hook already fires before a write — the
# moment acting on a stale rule actually costs something — and the check
# is one comparison against a marker the session already holds. No
# payload, no extra round trip, and nothing said when nothing moved.
# THE RULES-ETAG STALENESS ARM IS GONE (milestone 394).
#
# WHAT THIS CANNOT SEE, and a reader who finds an etag here will assume
# otherwise:
# It took a marker the session had been given at SessionStart, compared it
# against the resident set as it stood now, and said which rules had moved
# or fallen out of force. That was worth doing while a session held a
# fixed set of rules from turn zero and could be holding a stale copy of
# it hours later.
#
# what goes wrong | caught?
# ---------------------------------------------------|--------
# another session edits a rule mid-flight | yes
# the session is misremembering a rule read hours ago | yes
# compaction summarised the rules out of context | NO
# Nothing is resident now. A rule is retrieved at the moment it applies,
# so a session cannot be holding an out-of-date one — the next act that
# needs it fetches it again. The staleness this arm reported was an
# artifact of the delivery model rather than a fact about the corpus, and
# it goes with the model.
#
# The third is the most common and this 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.
#
# Fails open, like every other arm here: a staleness hint must never
# break a write.
if rules_etag:
try:
current = await rulebooks_svc.list_always_on_rules(
user_id, project_id=project_id or 0,
)
if rulebooks_svc.rules_etag(current) != rules_etag:
moved = rulebooks_svc.rules_moved_since(current, rules_etag)
held = rulebooks_svc.etag_count(rules_etag)
bits = []
if moved:
named = ", ".join(
f"#{r.id} \u201c{r.title}\u201d" for r in moved[:3]
)
more = len(moved) - 3
bits.append(
f"{named}" + (f", and {more} more" if more > 0 else "")
)
# A DELETED rule moves no timestamp and leaves no row to name,
# so the count is the only thing that can report the one change
# that takes an instruction OUT of force.
if held is not None and held != len(current):
delta = len(current) - held
bits.append(
f"{abs(delta)} rule(s) {'added' if delta > 0 else 'no longer in force'}"
)
if bits:
staleness.append(
"Your loaded rules have changed since this session "
"started — " + "; ".join(bits) + ". Re-read them with "
"list_always_on_rules() before relying on the set you "
"are holding."
)
except Exception:
logger.debug("write-path rules-etag arm failed", exc_info=True)
# `staleness` survives as the list the arms below still append to.
# The guard sits BELOW the staleness arm on purpose. A rules change is
# unconditional news — it does not become less true because this
@@ -2208,67 +2169,37 @@ async def build_session_context(
its normalized key — triggers a one-line "bind this repo" hint so
the binding is self-healing.
Returns {"context": str, "rule_count": int, "project": dict | None,
"rules_etag": str}. The etag is for the HOOK, not for the model — the
hook stores it and hands it back on each write so the server can say
whether these rules have moved since the session loaded them.
Returns {"context": str, "project": dict | None}.
It carried `rule_count` and `rules_etag` until milestone 394, when the
preload it described was removed. The etag let the hook hand a marker back
on each write so the server could say whether the resident rules had
moved; nothing is resident now, so nothing can have moved, and a rule is
re-retrieved at the moment it applies rather than held and aged.
`context` is markdown ready to drop into `additionalContext`; it is capped
at _MAX_CHARS with an explicit truncation note so the hook can pass it
through verbatim.
"""
# Inside a project, the always-on set is the project's: an inception
# exclusion (milestone 297) takes a rulebook out of this block, and is
# named below so the departure is visible rather than silent.
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
# AMBIENT source, and the one that matters most: this is the preload — the
# block every session opens with, chosen by nobody, paid for every turn.
#
# It emitted nothing until 2026-09-03, which made the resident set's cost
# certain and its usefulness unfalsifiable at the same time (#3473). Note
# #3089 is the argument this measurement finally lets someone test: that a
# rule arriving with thirty others, none of them relevant, is read as
# preamble rather than as a claim — so presence is not surfacing, and a
# tier-1 set can grow without anybody noticing it stopped working.
#
# Recorded even when the hook truncates the block below: the rules WERE
# delivered, and counting only the untruncated ones would quietly shrink
# the denominator exactly where the set is too big to read.
record_rule_surfaced(
user_id=user_id,
rule_ids=[r.id for r in rules],
source="session_start",
)
excluded = (
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
if project_id else []
)
topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id})
lines: list[str] = [
"# Scribe — standing session context (auto-injected by the Scribe plugin)",
"",
"You are working with Scribe, the operator's self-hosted second brain. "
"The always-on rules below are BINDING this session. Titles only — full "
"text via `list_always_on_rules()` or `get_rule(id)`.",
"You are working with Scribe, the operator's self-hosted second brain.",
"",
"## Always-on rules (by topic)",
"## You are not holding the operator's rules",
"",
"No rule has been loaded into this session, and that is deliberate. "
"Rules arrive when something you are about to do makes one relevant — "
"a command you are about to run, code you are writing, or what the "
"operator just asked for. On most turns none will, and that is the "
"surface working rather than failing.",
"",
"**\"No rule arrived\" means \"nothing matched\" — never \"there is no "
"rule.\"** Before a consequential act, one that is hard to reverse or "
"outward-facing, `search(content_type=\"rule\")` is how you ask. "
"Retrieval runs on its own and is a convenience; asking is what you do "
"when it matters and nothing has spoken.",
]
# rules already arrive ordered by rulebook/topic/order, so grouping by
# consecutive topic_id preserves the intended sequence.
current_topic: int | None = object() # sentinel distinct from any id/None
for r in rules:
if r.topic_id != current_topic:
current_topic = r.topic_id
heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped"
lines.append(f"### {heading}")
lines.append(f"- [{r.id}] {r.title}")
if excluded:
names = ", ".join(f"{e['title']} (#{e['id']})" for e in excluded)
lines += [
"",
f"Excluded for this project by its inception decision (not binding here): {names}.",
]
project_dict: dict | None = None
if project_id:
@@ -2336,14 +2267,10 @@ async def build_session_context(
context = "\n".join(line for line in lines if line is not None)
if len(context) > _MAX_CHARS:
context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated — call list_always_on_rules())"
context = context[:_MAX_CHARS].rstrip() + \
"\n\n…(truncated — ask with search(content_type=\"rule\"))"
return {
"context": context,
"rule_count": len(rules),
"project": project_dict,
# Computed from the rules THIS payload was built from, not re-queried:
# the marker has to describe the set the session is actually holding,
# and a second query could disagree with the first.
"rules_etag": rulebooks_svc.rules_etag(rules),
}