A rule must say when it applies; the ledger says what was read, not what was shown #162

Merged
bvandeusen merged 6 commits from dev into main 2026-09-16 17:50:08 -04:00
29 changed files with 766 additions and 48 deletions
+34
View File
@@ -96,6 +96,7 @@ const kbToolRuleThreshold = ref("0.68");
// And the prompt boundary is a third query shape again — the operator's own // And the prompt boundary is a third query shape again — the operator's own
// prose rather than anything a tool produced (#3852). // prose rather than anything a tool produced (#3852).
const kbPromptRuleThreshold = ref("0.72"); const kbPromptRuleThreshold = ref("0.72");
const kbReportPrefThreshold = ref("0.72");
// Near-duplicate report floors, one per record kind (services/dedup.py). // Near-duplicate report floors, one per record kind (services/dedup.py).
// Snippets are single-chunk, so their floor sits below the 0.90 write-time // Snippets are single-chunk, so their floor sits below the 0.90 write-time
// gate and catches what it lets through. Notes/tasks are scored at chunk // gate and catches what it lets through. Notes/tasks are scored at chunk
@@ -171,6 +172,7 @@ async function saveKbInject() {
// Bash call, so a fallback of 0 would put a rule in front of every command. // Bash call, so a fallback of 0 would put a rule in front of every command.
const trT = Math.min(1, Math.max(0, Number(kbToolRuleThreshold.value) || 0.68)); const trT = Math.min(1, Math.max(0, Number(kbToolRuleThreshold.value) || 0.68));
const prT = Math.min(1, Math.max(0, Number(kbPromptRuleThreshold.value) || 0.72)); const prT = Math.min(1, Math.max(0, Number(kbPromptRuleThreshold.value) || 0.72));
const rpT = Math.min(1, Math.max(0, Number(kbReportPrefThreshold.value) || 0.72));
kbInjectThreshold.value = String(t); kbInjectThreshold.value = String(t);
kbInjectTopK.value = String(k); kbInjectTopK.value = String(k);
kbDupThresholdSnippet.value = String(dupSnip); kbDupThresholdSnippet.value = String(dupSnip);
@@ -181,6 +183,7 @@ async function saveKbInject() {
kbRuleHintThreshold.value = String(rhT); kbRuleHintThreshold.value = String(rhT);
kbToolRuleThreshold.value = String(trT); kbToolRuleThreshold.value = String(trT);
kbPromptRuleThreshold.value = String(prT); kbPromptRuleThreshold.value = String(prT);
kbReportPrefThreshold.value = String(rpT);
savingKbInject.value = true; savingKbInject.value = true;
kbInjectSaved.value = false; kbInjectSaved.value = false;
try { try {
@@ -202,6 +205,12 @@ async function saveKbInject() {
// queries are different shapes. Moving one must not move the others. // queries are different shapes. Moving one must not move the others.
kb_toolrule_threshold: String(trT), kb_toolrule_threshold: String(trT),
kb_promptrule_threshold: String(prT), kb_promptrule_threshold: String(prT),
// A SIXTH, and the one that most needed its own key: this arm's query
// is a fixed string, so its score is a constant for a given corpus.
// While it borrowed the prompt bar, tuning prose silently retuned it —
// and a constant that lands under the bar is a dead arm, not a quiet
// one (#3860).
kb_reportpref_threshold: String(rpT),
kb_duplicate_threshold_snippet: String(dupSnip), kb_duplicate_threshold_snippet: String(dupSnip),
kb_duplicate_threshold_note: String(dupNote), kb_duplicate_threshold_note: String(dupNote),
kb_duplicate_threshold_task: String(dupTask), kb_duplicate_threshold_task: String(dupTask),
@@ -657,6 +666,9 @@ onMounted(async () => {
if (allSettings.kb_promptrule_threshold !== undefined) { if (allSettings.kb_promptrule_threshold !== undefined) {
kbPromptRuleThreshold.value = allSettings.kb_promptrule_threshold; kbPromptRuleThreshold.value = allSettings.kb_promptrule_threshold;
} }
if (allSettings.kb_reportpref_threshold !== undefined) {
kbReportPrefThreshold.value = allSettings.kb_reportpref_threshold;
}
if (allSettings.kb_writepath_threshold !== undefined) { if (allSettings.kb_writepath_threshold !== undefined) {
kbWritePathThreshold.value = allSettings.kb_writepath_threshold; kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
} }
@@ -1568,6 +1580,28 @@ async function deleteUser(userId: number) {
not a command or a file — which is why it carries its own number. not a command or a file — which is why it carries its own number.
</p> </p>
</div> </div>
<div class="field">
<label for="kb-reportpref-threshold">Completion-report confidence threshold (01)</label>
<input
id="kb-reportpref-threshold"
v-model="kbReportPrefThreshold"
type="number"
min="0"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
The bar for a <em>preference about how a completion report should be
written</em>, looked up when a task closes. Unlike every other bar
here, the question this arm asks never changes — so its score is
fixed by your preferences alone, and it will either always find one
or never find one. If you have written a preference for report shape
and it is not arriving, lower this; there is no run of calls that
will reveal the problem on its own.
</p>
</div>
<!-- A design system belongs to a PROJECT, and the picker for it lives on <!-- A design system belongs to a PROJECT, and the picker for it lives on
the project. There was a setting here that designated the system the project. There was a setting here that designated the system
this install's own interface was built from; it only ever described this install's own interface was built from; it only ever described
+1 -1
View File
@@ -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.16.1232", "version": "2026.09.16.2102",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+9
View File
@@ -53,6 +53,15 @@
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\"" "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
} }
] ]
},
{
"matcher": "mcp__.*__get_rule",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_opened.sh\""
}
]
} }
], ],
"PreCompact": [ "PreCompact": [
+2
View File
@@ -100,6 +100,8 @@ if [ -n "$session_id" ]; then
# stops holding what it was told. scribe_rules_live carries the reasoning. # stops holding what it was told. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}" [ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown (#4100).
exclude_q="${exclude_q}$(scribe_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
fi fi
body=$(curl -fsS --max-time 5 \ body=$(curl -fsS --max-time 5 \
+19
View File
@@ -289,6 +289,25 @@ scribe_rules_append() {
awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true
} }
# The OPENED ledger's contribution to a rule arm's query string (#4100).
#
# TWO LEDGERS, BECAUSE THEY RECORD TWO DIFFERENT FACTS. `.rules.ids` holds
# every id an arm has NAMED; `.opened.ids` holds the ids the session actually
# read, written by scribe_record_opened.sh from the `get_rule` call itself.
# Named is not read: the injected line is a teaser, and one skimmed past
# leaves nothing behind — least of all across a compaction. Sending both lets
# the server tell a reader who opened a rule from one who was only shown it,
# instead of telling them both the same untrue thing.
#
# Same reader as the naming ledger on purpose, so ageing, the last-entry-wins
# rule and the bare-id format are defined once and cannot drift apart.
scribe_held_query() {
local ids
ids=$(scribe_rules_live "$1")
[ -n "$ids" ] && printf '&held_rule_ids=%s' "$ids"
return 0
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# WHICH PROJECT IS THIS DIRECTORY'S? (#4085) # WHICH PROJECT IS THIS DIRECTORY'S? (#4085)
# #
+2
View File
@@ -207,6 +207,8 @@ if [ -n "$session_id" ]; then
# this arm's sibling hook reads the same rule file through the same helper. # this arm's sibling hook reads the same rule file through the same helper.
rule_seen=$(scribe_rules_live "$rulefile") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}" [ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown (#4100).
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
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
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Scribe — record that this session OPENED a rule, not merely saw it named (#4100).
#
# WHAT THIS CLOSES
#
# The rule arms keep a ledger of every id they have NAMED, and the injected
# line used to tell the reader "You saw it earlier this session". That claim
# was never checked. The line those arms emit is a TEASER — title, trigger,
# `get_rule(N)` — so a session can be named a rule twenty times and never read
# one word of it, and after a compaction the teaser is summarised away leaving
# nothing at all. The server was asserting something about the reader's
# context that it had no way to know.
#
# This is the observable half. PostToolUse fires for MCP tools (the event's own
# output schema carries `updatedMCPToolOutput`, which would be meaningless
# otherwise), so the `get_rule` CALL can be watched directly.
#
# WHY THIS IS NOT THE SELF-REPORT MILESTONE 386 REJECTED
#
# 386 ruled out asking the session whether it holds a rule, because a model
# asked "do you still hold rule 156?" will say yes and the answer is
# unverifiable self-report. That objection is about ASKING. This asks nobody:
# a tool call happened or it did not, and the harness reports it either way.
# Recording what a session DID is a different kind of evidence from believing
# what it says about itself.
#
# WHAT IT DELIBERATELY DOES NOT DO
#
# It does not prove the rule is still in context — nothing can, and a
# compaction can drop it moments later. That is why `.opened.ids` ages exactly
# like `.rules.ids` and is cleared on the same events (#3749): both ledgers
# describe a context that no longer exists once the context is destroyed. The
# claim it supports is only ever "you opened this, pull it again if you no
# longer hold it", which stays true in every case and carries its own remedy.
#
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
# turn a successful tool call into a hook error. Worst case the id is missed
# and the reader is offered a rule it already read — the cost of a wrong guess
# here is one extra line, in the direction that shows more rather than less.
set -uo pipefail
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
# No jq, no ledger — and no complaint. Every other hook degrades the same way
# rather than printing a tooling error in front of the operator's work (#4107
# tracks making that dependency honest; this is not the place to diverge).
command -v jq >/dev/null 2>&1 || exit 0
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
[ -n "$session_id" ] || exit 0
# The matcher in hooks.json already narrows to the get_rule tools, but the
# server segment of an MCP tool name varies with how the plugin was installed,
# so the id is read from whichever field is actually present rather than from
# an assumed tool name. An event that carries none simply records nothing.
rule_id=$(printf '%s' "$event" \
| jq -r '(.tool_input.rule_id // empty) | tostring' 2>/dev/null) || rule_id=""
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
[ -n "$rule_id" ] || exit 0
# The same directory the naming ledger uses. One session keeps its state in one
# place, and the prior-art name is kept for the reason scribe_tool_rules.sh
# gives: renaming it would orphan every live session's state for a cosmetic
# gain.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# Stamped and append-only, exactly like the naming ledger — so the same reader
# (`scribe_rules_live`) ages both, and the last entry for an id wins.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids"
exit 0
+7
View File
@@ -105,6 +105,13 @@ case "$source" in
# Best-effort, like every other filesystem touch in these hooks: a ledger # Best-effort, like every other filesystem touch in these hooks: a ledger
# that cannot be removed costs a repeated exclusion, never a session. # that cannot be removed costs a repeated exclusion, never a session.
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.rules.ids" 2>/dev/null || true rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.rules.ids" 2>/dev/null || true
# BOTH ledgers, for one reason (#4100). `.opened.ids` records what the
# session read; a compaction is exactly the event that takes it away
# again. Clearing the naming ledger while keeping this one would leave
# the surfacing arms telling a freshly-summarised session "you opened
# it earlier" about a rule that is no longer anywhere in its context —
# a more confident version of the claim this milestone removed.
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.opened.ids" 2>/dev/null || true
fi fi
;; ;;
esac esac
+2
View File
@@ -87,6 +87,8 @@ if [ -n "$session_id" ]; then
# session is still holding. scribe_rules_live carries the reasoning. # session is still holding. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile") rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}" [ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
# What the session actually OPENED, as against what it was shown (#4100).
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
fi fi
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose # `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
+37 -8
View File
@@ -345,13 +345,43 @@ SMOKE_EVENTS: dict[str, str] = {
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl", {"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
"cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False} "cwd": ".", "hook_event_name": "Stop", "stop_hook_active": False}
), ),
# The PreCompact preserver (#3680). Its whole contract is the inverse of
# every other hook's: it must exit 0 AND print, because stdout is what
# becomes the summarizer's custom instructions. A silent success here is
# the failure mode, and it would look like every other hook's success.
"scribe_precompact_preserve.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "PreCompact",
"trigger": "manual", "custom_instructions": None}
),
# The opened-ledger recorder (#4100). It writes to TMPDIR and prints
# nothing — a PostToolUse hook that emitted output would put a line in
# front of every get_rule call, which is the opposite of its purpose. The
# smoke case is a well-formed open: it must exit 0 and stay silent.
"scribe_record_opened.sh": json.dumps(
{"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__get_rule", "tool_input": {"rule_id": 1},
"tool_response": {}}
),
# The shared library is sourced, never run; executed bare it defines # The shared library is sourced, never run; executed bare it defines
# functions and exits — silent by construction. # functions and exits — silent by construction.
"scribe_defs.sh": "", "scribe_defs.sh": "",
} }
# The one hook that legitimately produces output with no credentials. # The hooks that legitimately produce output with NO credentials, each for its
STATIC_FLOOR = "scribe_session_context.sh" # own reason — named per hook rather than shared, because "this one is allowed
# to speak" is exactly the kind of exemption that quietly grows to cover a hook
# that is merely leaking.
STATIC_EMITTERS = {
# The two-tier SessionStart design: the bundled static tier ships whatever
# the instance does, and that floor is the whole point of the split.
"scribe_session_context.sh": "static floor present",
# PreCompact's contract is INVERTED (#3680). Its stdout becomes the
# summarizer's custom instructions, and what must survive a summary is
# known without asking anything — so it needs no instance, and silence is
# the failure mode rather than the success one. Read as a generic hook it
# would look like a leak; it is the opposite.
"scribe_precompact_preserve.sh": "preservation instructions present",
}
# The hooks that say so when a configured instance does not answer (#2932). # The hooks that say so when a configured instance does not answer (#2932).
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"} OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
OUTAGE_LINE = "> Scribe did not answer the prior-art check" OUTAGE_LINE = "> Scribe did not answer the prior-art check"
@@ -398,14 +428,13 @@ def check_fail_open() -> None:
f"a recall aid may never fail the operator's action") f"a recall aid may never fail the operator's action")
continue continue
out = proc.stdout.strip() out = proc.stdout.strip()
if script.name == STATIC_FLOOR: if script.name in STATIC_EMITTERS:
# Emits its bundled static tier regardless; that floor is the what = STATIC_EMITTERS[script.name]
# whole point of the two-tier design.
if not out: if not out:
fail(f"{rel} [{label}]: emitted nothing — the static " fail(f"{rel} [{label}]: emitted nothing — this hook's "
f"behavioural floor must survive having no credentials") f"output must survive having no credentials ({what})")
else: else:
ok(f"{rel} [{label}]: exit 0, static floor present") ok(f"{rel} [{label}]: exit 0, {what}")
elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS: elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
# The only thing allowed here is the outage line itself. # The only thing allowed here is the outage line itself.
try: try:
+10 -6
View File
@@ -246,7 +246,7 @@ async def get_rule(rule_id: int) -> dict:
async def create_rule( async def create_rule(
topic_id: int, title: str, statement: str, when_to_apply: str = "", topic_id: int, title: str, statement: str, when_to_apply: str,
why: str = "", how_to_apply: str = "", order_index: int = 0, why: str = "", how_to_apply: str = "", order_index: int = 0,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "", arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
system_ids: list[int] | None = None, force: bool = False, system_ids: list[int] | None = None, force: bool = False,
@@ -344,10 +344,14 @@ async def create_rule(
when_to_apply: WHEN this rule fires — the trigger, not the when_to_apply: WHEN this rule fires — the trigger, not the
instruction. State the moment or the material: "before any git instruction. State the moment or the material: "before any git
push", "when adding a value to a CHECK-gated column", "when a push", "when adding a value to a CHECK-gated column", "when a
release is being cut". Write it even though the parameter is release is being cut". REQUIRED, and not as ceremony: nothing is
optional: it is how the rule is found preloaded, so this is the whole of how the rule is found when it
when it matters, and a rule nobody can place is a rule nobody matters and it is half of what the rule is EMBEDDED as, so a
applies. rule without one is not merely hard to find, it is stored in a
different shape from every rule it competes with. Name the SYMPTOM
— the words someone would type while stuck — rather than the
category: "the CI job passed locally and fails on the runner with
a permission error" retrieves; "when touching CI config" does not.
This field is also the rule's RETRIEVAL SURFACE — it and the This field is also the rule's RETRIEVAL SURFACE — it and the
statement are what a search is matched against, so it should statement are what a search is matched against, so it should
carry the SYMPTOM, not just the situation: the words someone carry the SYMPTOM, not just the situation: the words someone
@@ -411,7 +415,7 @@ async def create_rule(
async def create_project_rule( async def create_project_rule(
project_id: int, statement: str, title: str = "", when_to_apply: str = "", project_id: int, statement: str, when_to_apply: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0, why: str = "", how_to_apply: str = "", order_index: int = 0,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "", arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
system_ids: list[int] | None = None, force: bool = False, system_ids: list[int] | None = None, force: bool = False,
+21 -3
View File
@@ -100,6 +100,13 @@ async def autoinject_retrieve():
purpose: one session keeps ONE rule ledger, so a purpose: one session keeps ONE rule ledger, so a
rule named by any arm is not re-announced by rule named by any arm is not re-announced by
another. Ages out (#3751), so salience decays. another. Ages out (#3751), so salience decays.
held_rule_ids — comma-separated rule ids the session actually
(opt) OPENED, observed from the `get_rule` call itself
rather than claimed. A rule on exclude_rule_ids
was NAMED; a rule here was READ, and the two say
different things about what the reader holds —
so they get different lines (#4100). Shares the
ledger directory and the same clear-on-compact.
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
but nothing else: the notes menu can be disabled, thresholded and top-k'd but nothing else: the notes menu can be disabled, thresholded and top-k'd
@@ -116,9 +123,10 @@ async def autoinject_retrieve():
project_id, _repo, _unbound = await _project_scope() project_id, _repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids")) exclude_ids = _int_list(request.args.get("exclude_ids"))
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids")) exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
rules = await plugin_ctx_svc.build_prompt_rule_hint( rules = await plugin_ctx_svc.build_prompt_rule_hint(
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids
) )
result = await plugin_ctx_svc.build_autoinject_hint( result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
@@ -157,14 +165,20 @@ async def pre_tool_rules():
ledger on purpose: one session keeps one ledger on purpose: one session keeps one
list, so a rule named by either arm is not list, so a rule named by either arm is not
re-offered by the other. re-offered by the other.
held_rule_ids (opt) — rule ids the session actually OPENED, as
against merely named. Named and read are
different claims about the reader's
context, so they get different lines
(#4100).
""" """
tool = (request.args.get("tool") or "tool").strip() tool = (request.args.get("tool") or "tool").strip()
command = request.args.get("command") or "" command = request.args.get("command") or ""
project_id, _repo, _unbound = await _project_scope() project_id, _repo, _unbound = await _project_scope()
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids")) exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
result = await plugin_ctx_svc.build_tool_rule_hint( result = await plugin_ctx_svc.build_tool_rule_hint(
g.user.id, tool, command, g.user.id, tool, command,
project_id=project_id, exclude_rule_ids=exclude_rule_ids, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
) )
return jsonify(result) return jsonify(result)
@@ -202,6 +216,9 @@ async def write_path_prior_art():
above, and for the same reason: a rule named above, and for the same reason: a rule named
twenty turns ago should not be re-offered on twenty turns ago should not be re-offered on
every subsequent write. every subsequent write.
held_rule_ids (opt) — RULE ids the session actually OPENED, as
against merely named; drives the third
reference wording (#4100).
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
@@ -225,6 +242,7 @@ async def write_path_prior_art():
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")) exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
held_rule_ids = _int_list(request.args.get("held_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"
@@ -235,7 +253,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, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
) )
return jsonify(result) return jsonify(result)
+9
View File
@@ -345,6 +345,15 @@ async def create_project_rule(project_id: int):
statement = (data.get("statement") or "").strip() statement = (data.get("statement") or "").strip()
if not statement: if not statement:
return jsonify({"error": "statement is required"}), 400 return jsonify({"error": "statement is required"}), 400
# Checked here as well as in the service, only for the STATUS CODE: the
# service raises ValueError, which this route maps to 404 for "project not
# found", and a missing trigger is a 400. The service stays the guard —
# this is the door telling the truth about whose mistake it was.
if not (data.get("when_to_apply") or "").strip():
return jsonify({
"error": "when_to_apply is required: a rule with no trigger never "
"surfaces at the moment it applies."
}), 400
title = (data.get("title") or "").strip() or statement.split(".")[0][:50] title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
try: try:
rule = await rulebooks_svc.create_project_rule( rule = await rulebooks_svc.create_project_rule(
+61 -5
View File
@@ -471,6 +471,25 @@ PROMPTRULE_DEFAULT_THRESHOLD = 0.72
# at all, where the act arms never face it because a command is one thing. # at all, where the act arms never face it because a command is one thing.
PROMPTRULE_LIMIT = 3 PROMPTRULE_LIMIT = 3
# THE COMPLETION-REPORT ARM'S OWN BAR (services/reply_preferences.py).
#
# It borrowed PROMPTRULE_THRESHOLD_KEY when it shipped, which made the two
# arms one dial: an operator lowering the bar for their own prose moved this
# one with it, silently. That contradicts the rule every other bar here
# follows — one number cannot serve arms whose queries are different shapes —
# and this arm's query is the most different of all. The others score an
# operator's prose or a session's code, both of which vary per call; this one
# scores a FIXED string (`COMPLETION_QUERY`) against rule triggers, so its
# score for a given corpus is a constant. A constant that lands under the bar
# is not a quiet arm, it is a dead one, and nothing about the prose arm's
# traffic would ever reveal it.
#
# Kept at the prose arm's starting value rather than tuned: the split is what
# makes the two independently movable, and a default is a product decision
# that this install's corpus cannot settle (rule 115).
REPORTPREF_THRESHOLD_KEY = "kb_reportpref_threshold"
REPORTPREF_DEFAULT_THRESHOLD = 0.72
def _slugify(text: str) -> str: def _slugify(text: str) -> str:
"""kebab-case slug for a skill directory name (a-z0-9 + single hyphens).""" """kebab-case slug for a skill directory name (a-z0-9 + single hyphens)."""
@@ -908,6 +927,7 @@ async def build_prompt_rule_hint(
*, *,
project_id: int = 0, project_id: int = 0,
exclude_rule_ids: list[int] | None = None, exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
) -> dict: ) -> dict:
"""Rules and preferences that may apply to what the operator just asked. """Rules and preferences that may apply to what the operator just asked.
@@ -966,6 +986,7 @@ async def build_prompt_rule_hint(
duration_ms = (time.perf_counter() - t0) * 1000.0 duration_ms = (time.perf_counter() - t0) * 1000.0
already = set(exclude_rule_ids or []) already = set(exclude_rule_ids or [])
held = set(held_rule_ids or [])
fresh = [(score, rule) for score, rule in hits if rule.id not in already] fresh = [(score, rule) for score, rule in hits if rule.id not in already]
# BEFORE the early return, for the reason both sibling arms spell out # BEFORE the early return, for the reason both sibling arms spell out
@@ -1001,7 +1022,10 @@ async def build_prompt_rule_hint(
return out return out
lines = [ lines = [
_rule_hint_line(rule, where="to this request", seen=rule.id in already) _rule_hint_line(
rule, where="to this request",
seen=rule.id in already, held=rule.id in held,
)
for _score, rule in hits for _score, rule in hits
] ]
# FRESH-ONLY (#3752). A reference is a rendering decision, not a # FRESH-ONLY (#3752). A reference is a rendering decision, not a
@@ -1278,7 +1302,9 @@ def _rule_band(hits: list) -> list:
return [(s, r) for s, r in hits if s >= top - _RULEHINT_BAND] return [(s, r) for s, r in hits if s >= top - _RULEHINT_BAND]
def _rule_hint_line(rule, *, where: str, seen: bool, compact: bool = False) -> str: def _rule_hint_line(
rule, *, where: str, seen: bool, held: bool = False, compact: bool = False,
) -> str:
"""One rule hint line — both arms, both tails, both kinds (#3750, #3849). """One rule hint line — both arms, both tails, both kinds (#3750, #3849).
THREE INDEPENDENT AXES SINCE #3851. `compact` joins `kind` and `seen`, and THREE INDEPENDENT AXES SINCE #3851. `compact` joins `kind` and `seen`, and
@@ -1361,10 +1387,34 @@ def _rule_hint_line(rule, *, where: str, seen: bool, compact: bool = False) -> s
"for how this has been done before" if preference "for how this has been done before" if preference
else "before deciding it does not apply" else "before deciding it does not apply"
) )
# THREE STATES, BECAUSE TWO OF THEM WERE BEING TOLD THE SAME LIE (#4100).
#
# `seen` means an arm NAMED this rule earlier. It does not mean the session
# read it — the line is a teaser, and a teaser skimmed past leaves nothing
# behind, least of all after a compaction summarises the turn it arrived
# in. "You saw it earlier this session" asserted something about the
# reader's context that the server had no way to know.
#
# `held` is the observable half: a PostToolUse hook watches for the
# `get_rule` call itself, so this is a recorded EVENT rather than a claim.
# That distinction is what keeps the non-goal above intact — the objection
# was to asking a model about its own context, not to noticing what it did.
#
# The middle state is the honest one and the one that was missing: named,
# not opened. It gets the full invitation, because a session that skipped
# the teaser is in almost the same position as one that never saw it.
if held:
tail = (
f"You opened it earlier this session; pull it with "
f"get_rule({rule.id}) again if you no longer hold it."
)
elif seen:
tail = (
f"Mentioned earlier this session but not opened — read it with "
f"get_rule({rule.id}) {reason}."
)
else:
tail = ( tail = (
f"You saw it earlier this session; pull it with get_rule({rule.id}) "
"if you no longer hold it."
if seen else
f"Read it with get_rule({rule.id}) {reason}; it is not in this " f"Read it with get_rule({rule.id}) {reason}; it is not in this "
"session's loaded set." "session's loaded set."
) )
@@ -1402,6 +1452,7 @@ async def build_write_path_hint(
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, exclude_rule_ids: list[int] | None = None,
held_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.
@@ -1823,6 +1874,7 @@ async def build_write_path_hint(
rule_ids: list[int] = [] rule_ids: list[int] = []
try: try:
already = set(exclude_rule_ids or []) already = set(exclude_rule_ids or [])
held = set(held_rule_ids or [])
# Timed like the notes arm above. Without this the rule row was the one # Timed like the notes arm above. Without this the rule row was the one
# source in the whole readout reporting a null p90_duration_ms (#3311) # source in the whole readout reporting a null p90_duration_ms (#3311)
# — a gap that reads as "this surface is somehow not measurable" rather # — a gap that reads as "this surface is somehow not measurable" rather
@@ -1848,6 +1900,7 @@ async def build_write_path_hint(
lines.append( lines.append(
_rule_hint_line( _rule_hint_line(
rule, where="here", seen=rule.id in already, rule, where="here", seen=rule.id in already,
held=rule.id in held,
compact=idx > 0, compact=idx > 0,
) )
) )
@@ -1938,6 +1991,7 @@ async def build_tool_rule_hint(
*, *,
project_id: int = 0, project_id: int = 0,
exclude_rule_ids: list[int] | None = None, exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
) -> dict: ) -> dict:
"""Standing rules that may apply to the ACTION about to be taken (#3476). """Standing rules that may apply to the ACTION about to be taken (#3476).
@@ -1992,6 +2046,7 @@ async def build_tool_rule_hint(
duration_ms = (time.perf_counter() - t0) * 1000.0 duration_ms = (time.perf_counter() - t0) * 1000.0
already = set(exclude_rule_ids or []) already = set(exclude_rule_ids or [])
held = set(held_rule_ids or [])
# Band first, dedup second — see the sibling arm for why that order is # Band first, dedup second — see the sibling arm for why that order is
# load-bearing rather than incidental. # load-bearing rather than incidental.
kept = _rule_band(hits) kept = _rule_band(hits)
@@ -2029,6 +2084,7 @@ async def build_tool_rule_hint(
_rule_hint_line( _rule_hint_line(
rule, where=f"to this {tool_name} call", rule, where=f"to this {tool_name} call",
seen=rule.id in already, seen=rule.id in already,
held=rule.id in held,
# Rank decides volume (#3851): the ranker's best guess gets the # Rank decides volume (#3851): the ranker's best guess gets the
# trigger, the rest get cited. # trigger, the rest get cited.
compact=idx > 0, compact=idx > 0,
+23 -11
View File
@@ -34,14 +34,26 @@ delivery entirely in retrieval, as 394 decided, and leaves an operator nothing
new to learn: a preference reaches the completion report the same way every new to learn: a preference reaches the completion report the same way every
other record reaches its moment. other record reaches its moment.
THE BAR IS THE PROMPT ARM'S, AND IT IS NOT YET EARNED HERE THE BAR IS ITS OWN, AND THE FIRST READING EARNED IT (#3860)
A fixed query against triggers is a different score distribution from an This borrowed the prompt arm's key when it shipped, on the argument that a
operator's message against the same documents. Starting at the prompt arm's fixed query against triggers is a different score distribution from an
setting is the value with evidence behind it, and an operator's tuning of that operator's message against the same documents — true, and the reason the two
bar reaches this too. Every call logs under its own source, could not stay one dial. Five days of traffic settled it.
`report_preference`, so step 6 can read this surface's near misses apart from
the prompt arm's before anyone moves the number. What the readout said: 69 calls, 69 declines, every one naming the SAME record
at the SAME score (rule 77 at 0.7194 against a 0.72 bar). That constancy is
the signature of this arm — `COMPLETION_QUERY` never varies, so for a given
corpus its best score is a constant, and a constant sitting under the bar is a
dead arm rather than a quiet one. The record it kept declining was about
reading a REQUEST, not about the shape of a report, so the decline was right
and the arm is healthy: this install simply has no completion-report
preference on file.
The bar stayed at 0.72, and the key moved out (REPORTPREF_THRESHOLD_KEY) so
that staying is a decision rather than a side effect of what the prose arm is
set to. A surface whose score cannot vary is the one surface where a borrowed
bar can be wrong forever without a single call looking unusual.
""" """
from __future__ import annotations from __future__ import annotations
@@ -50,8 +62,8 @@ import time
from scribe.services.embeddings import semantic_search_rules from scribe.services.embeddings import semantic_search_rules
from scribe.services.plugin_context import ( from scribe.services.plugin_context import (
PROMPTRULE_DEFAULT_THRESHOLD, REPORTPREF_DEFAULT_THRESHOLD,
PROMPTRULE_THRESHOLD_KEY, REPORTPREF_THRESHOLD_KEY,
) )
from scribe.services.retrieval_telemetry import record_retrieval from scribe.services.retrieval_telemetry import record_retrieval
from scribe.services.rule_usage import record_rule_surfaced from scribe.services.rule_usage import record_rule_surfaced
@@ -79,9 +91,9 @@ LIMIT = 3
async def _threshold(user_id: int) -> float: async def _threshold(user_id: int) -> float:
try: try:
value = float(await get_setting( value = float(await get_setting(
user_id, PROMPTRULE_THRESHOLD_KEY, str(PROMPTRULE_DEFAULT_THRESHOLD))) user_id, REPORTPREF_THRESHOLD_KEY, str(REPORTPREF_DEFAULT_THRESHOLD)))
except (TypeError, ValueError): except (TypeError, ValueError):
value = PROMPTRULE_DEFAULT_THRESHOLD value = REPORTPREF_DEFAULT_THRESHOLD
return min(1.0, max(0.0, value)) return min(1.0, max(0.0, value))
+51
View File
@@ -457,12 +457,44 @@ async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = N
return data return data
def _require_trigger(when_to_apply: str | None) -> None:
"""A rule without a trigger is not a quiet rule — it is an unreachable one.
Nothing is preloaded, so `when_to_apply` is the whole of how a rule
arrives. It is also what the record is EMBEDDED as: `rule_document` builds
`{title}{trigger}` / `When to apply: {trigger}\\n\\n{statement}`, with
the trigger appearing twice so that purpose dominates a short vector. Drop
it and the document silently changes shape to title + statement, so the
same score means something different for that rule than for its
neighbours — and every bar and every rank in the system assumes one shape.
ENFORCED IN THE SERVICE, so both doors are covered: the MCP tools and the
frontend's fast path (`routes/rulebooks.py`) both land here, and a guard
written in one of them would leave the other able to create a rule that
never fires.
Deliberately NOT following `arose_from_id`, which the human door exempts
itself from on the stated grounds that provenance is about auditing what
the AGENT changed. That reasoning does not reach this field: a missing
trigger is not a missing explanation, it is a rule that does not work, and
it fails an operator exactly as badly as it fails a session.
"""
if not (when_to_apply or "").strip():
raise ValueError(
"when_to_apply is required: a rule with no trigger never surfaces "
"at the moment it applies. Name that moment in the words a session "
"would actually be producing then — the command, the error, the "
"half-formed ask — not the category it belongs to."
)
async def create_rule( async def create_rule(
topic_id: int, user_id: int, title: str, statement: str, topic_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0, why: str = "", how_to_apply: str = "", order_index: int = 0,
when_to_apply: str = "", arose_from_id: int = 0, when_to_apply: str = "", arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "", kind: str = "rule", verify_with: str = "", expires_when: str = "", kind: str = "rule",
) -> Rule: ) -> Rule:
_require_trigger(when_to_apply)
async with async_session() as session: async with async_session() as session:
await _assert_topic_owned(session, topic_id, user_id) await _assert_topic_owned(session, topic_id, user_id)
rule = Rule( rule = Rule(
@@ -498,6 +530,7 @@ async def create_project_rule(
rule in a rulebook topic is global. Topic_id is left NULL — the CHECK rule in a rulebook topic is global. Topic_id is left NULL — the CHECK
constraint enforces exactly-one of (topic_id, project_id). constraint enforces exactly-one of (topic_id, project_id).
""" """
_require_trigger(when_to_apply)
async with async_session() as session: async with async_session() as session:
await _assert_project_owned(session, project_id, user_id) await _assert_project_owned(session, project_id, user_id)
rule = Rule( rule = Rule(
@@ -651,6 +684,17 @@ async def update_rule(
"verify_with", "expires_when", "verify_with", "expires_when",
} }
check_before = rule.verify_with check_before = rule.verify_with
# A create-time guard is worth nothing if an edit can undo it, and
# both doors can: `clear=["when_to_apply"]` from the MCP side, and a
# emptied form input normalised to None from the REST side. Checked
# AFTER the mutation instead, so it covers every route to an empty
# trigger including ones added later.
#
# Asked as "did this edit REMOVE a trigger", not "does one exist":
# a rule predating the guard has none, and refusing to save it would
# make the record permanently unfixable — freezing the exact rules
# that most need the edit.
trigger_before = (rule.when_to_apply or "").strip()
# Captured BEFORE anything is written, and as plain values — this has # Captured BEFORE anything is written, and as plain values — this has
# to survive the mutation below. A rule's history is the only record # to survive the mutation below. A rule's history is the only record
# of what it used to say; the edit itself destroys that. # of what it used to say; the edit itself destroys that.
@@ -677,6 +721,13 @@ async def update_rule(
# rule wrongly vouched for costs the thing the sweep exists to catch. # rule wrongly vouched for costs the thing the sweep exists to catch.
if rule.verify_with != check_before: if rule.verify_with != check_before:
rule.verified_at = None rule.verified_at = None
if trigger_before and not (rule.when_to_apply or "").strip():
raise ValueError(
"when_to_apply cannot be cleared: it is how this rule arrives, "
"and it is half of what the rule is embedded as. Replace the "
"trigger with a better one rather than removing it — a rule "
"with none is not a quieter rule, it is an unreachable one."
)
# Same session as the edit, so the two commit together. The snapshot # Same session as the edit, so the two commit together. The snapshot
# holds the OLD verify_with — the check that was in force when that # holds the OLD verify_with — the check that was in force when that
# wording was written — which is why it is taken before the loop and # wording was written — which is why it is taken before the loop and
@@ -77,6 +77,7 @@ async def seeded():
rule = await rulebooks_svc.create_rule( rule = await rulebooks_svc.create_rule(
topic.id, uid, "A rule with vectors", topic.id, uid, "A rule with vectors",
"Something for the embedder to index.", "Something for the embedder to index.",
when_to_apply="when the moment this fixture stands in for arises",
) )
async with async_session() as s: async with async_session() as s:
note = Note(user_id=uid, title="A note with vectors", body="Body text.") note = Note(user_id=uid, title="A note with vectors", body="Body text.")
@@ -48,6 +48,7 @@ async def constraint():
"Write every `run:` step in POSIX sh.", "Write every `run:` step in POSIX sh.",
verify_with="read the workflow's shell setting", verify_with="read the workflow's shell setting",
expires_when="the runner can be given a bash shell", expires_when="the runner can be given a bash shell",
when_to_apply="when the moment this fixture stands in for arises",
) )
async with async_session() as s: async with async_session() as s:
row = await s.get(Rule, rule.id) row = await s.get(Rule, rule.id)
@@ -154,14 +155,17 @@ async def rulebook_of_three():
topic = await rulebooks_svc.create_topic(book.id, uid, "mixed") topic = await rulebooks_svc.create_topic(book.id, uid, "mixed")
decision = await rulebooks_svc.create_rule( decision = await rulebooks_svc.create_rule(
topic.id, uid, "dev is home", "Work directly on dev.", topic.id, uid, "dev is home", "Work directly on dev.",
when_to_apply="when the moment this fixture stands in for arises",
) )
never = await rulebooks_svc.create_rule( never = await rulebooks_svc.create_rule(
topic.id, uid, "The runner has no bash", "Use POSIX sh.", topic.id, uid, "The runner has no bash", "Use POSIX sh.",
verify_with="read the workflow's shell setting", verify_with="read the workflow's shell setting",
when_to_apply="when the moment this fixture stands in for arises",
) )
stale = await rulebooks_svc.create_rule( stale = await rulebooks_svc.create_rule(
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.", topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
verify_with="cat CI-runner/renovate/config.js", verify_with="cat CI-runner/renovate/config.js",
when_to_apply="when the moment this fixture stands in for arises",
) )
async with async_session() as s: async with async_session() as s:
row = await s.get(Rule, stale.id) row = await s.get(Rule, stale.id)
+2
View File
@@ -61,6 +61,7 @@ async def constraint():
"Write every `run:` step in POSIX sh.", "Write every `run:` step in POSIX sh.",
why="the image ships no bash", why="the image ships no bash",
verify_with="read the workflow's shell setting", verify_with="read the workflow's shell setting",
when_to_apply="when the moment this fixture stands in for arises",
) )
return {"uid": uid, "rule_id": rule.id} return {"uid": uid, "rule_id": rule.id}
@@ -259,6 +260,7 @@ async def test_a_version_cannot_be_read_through_a_DIFFERENT_rule(constraint):
topic_id = rule.topic_id topic_id = rule.topic_id
sibling = await rulebooks_svc.create_rule( sibling = await rulebooks_svc.create_rule(
topic_id, constraint["uid"], "A different rule", "Unrelated.", topic_id, constraint["uid"], "A different rule", "Unrelated.",
when_to_apply="when the moment this fixture stands in for arises",
) )
assert await rulebooks_svc.get_rule_version( assert await rulebooks_svc.get_rule_version(
+6 -2
View File
@@ -63,6 +63,7 @@ async def test_create_rule_passes_required_fields():
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
await create_rule( await create_rule(
topic_id=10, title="dev is home", statement="Work directly on dev", topic_id=10, title="dev is home", statement="Work directly on dev",
when_to_apply="when the moment this fixture stands in for arises",
) )
kwargs = mock.call_args.kwargs kwargs = mock.call_args.kwargs
assert kwargs["user_id"] == 7 assert kwargs["user_id"] == 7
@@ -79,7 +80,7 @@ async def test_create_rule_blocked_by_duplicate_gate():
AsyncMock(return_value=dup)), \ AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock): patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x") out = await create_rule(topic_id=10, title="dev is home", statement="x", when_to_apply="when the moment this fixture stands in for arises")
assert out["duplicate"] is True assert out["duplicate"] is True
assert out["existing_id"] == 47 assert out["existing_id"] == 47
assert "update_rule" in out["message"] assert "update_rule" in out["message"]
@@ -94,7 +95,7 @@ async def test_create_rule_force_bypasses_duplicate_gate():
AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \ AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \
_plain_detail(): _plain_detail():
from scribe.mcp.tools.rulebooks import create_rule from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True) out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True, when_to_apply="when the moment this fixture stands in for arises")
assert out["id"] == 5 assert out["id"] == 5
find_mock.assert_not_called() find_mock.assert_not_called()
@@ -248,6 +249,7 @@ async def test_create_project_rule_passes_required_fields():
project_id=42, project_id=42,
statement="Always run migrations through alembic, not raw SQL.", statement="Always run migrations through alembic, not raw SQL.",
why="audit trail", why="audit trail",
when_to_apply="when the moment this fixture stands in for arises",
) )
kwargs = mock.call_args.kwargs kwargs = mock.call_args.kwargs
assert kwargs["user_id"] == 7 assert kwargs["user_id"] == 7
@@ -265,6 +267,7 @@ async def test_create_project_rule_derives_title_from_statement():
await create_project_rule( await create_project_rule(
project_id=42, project_id=42,
statement="Avoid auto-generated docstrings. Reviewers find them noise.", statement="Avoid auto-generated docstrings. Reviewers find them noise.",
when_to_apply="when the moment this fixture stands in for arises",
) )
kwargs = mock.call_args.kwargs kwargs = mock.call_args.kwargs
# Title should be derived from the first sentence, capped at 50 chars # Title should be derived from the first sentence, capped at 50 chars
@@ -281,6 +284,7 @@ async def test_create_project_rule_uses_explicit_title_when_given():
project_id=42, project_id=42,
statement="anything", statement="anything",
title="no auto-docstrings", title="no auto-docstrings",
when_to_apply="when the moment this fixture stands in for arises",
) )
kwargs = mock.call_args.kwargs kwargs = mock.call_args.kwargs
assert kwargs["title"] == "no auto-docstrings" assert kwargs["title"] == "no auto-docstrings"
+1 -1
View File
@@ -9,7 +9,7 @@ a rule binds should have agreed to be bound.
A preference inverts it. The operator's framing: *"preferences are rules that A preference inverts it. The operator's framing: *"preferences are rules that
scribe can and should update during use."* A preference that asks every time scribe can and should update during use."* A preference that asks every time
never drifts, and drifting is the whole feature. Reaching one through never drifts, and drifting is the whole feature. Reaching one through
`create_rule(kind=...)` would mean reading it through the gate's prose, and `create_rule(kind=..., when_to_apply="when the moment this fixture stands in for arises")` would mean reading it through the gate's prose, and
the caller would hesitate over exactly the act this kind exists to make the caller would hesitate over exactly the act this kind exists to make
routine. routine.
+19 -6
View File
@@ -149,16 +149,29 @@ def test_shortening_a_line_does_not_decide_what_it_says_about_holding():
precisely because the session may no longer HOLD what it was told — and precisely because the session may no longer HOLD what it was told — and
the tail is the entire difference a reader can act on. the tail is the entire difference a reader can act on.
`compact` and `seen` are independent axes. How much room a line gets is a `compact` and the ledger are independent axes. How much room a line gets is
fact about its rank; whether the session holds it is a fact about the a fact about its rank; what the session holds is a fact about the ledger;
ledger; and neither may be allowed to answer the other's question. and neither may be allowed to answer the other's question.
THREE STATES SINCE #4100, so this checks three. The axis grew and the test
grew with it — pinning only two would leave the compact branch free to
collapse the new middle state into either neighbour, which is the same
regression this was written for with one more place to hide.
""" """
rule = fake_rule(id=4, title="dev is home", when_to_apply=_TRIGGER) rule = fake_rule(id=4, title="dev is home", when_to_apply=_TRIGGER)
seen = _rule_hint_line(rule, where="here", seen=True, compact=True)
fresh = _rule_hint_line(rule, where="here", seen=False, compact=True) fresh = _rule_hint_line(rule, where="here", seen=False, compact=True)
assert seen != fresh named = _rule_hint_line(rule, where="here", seen=True, compact=True)
assert "no longer hold it" in seen held = _rule_hint_line(rule, where="here", seen=True, held=True, compact=True)
assert len({fresh, named, held}) == 3, (
"the compact branch collapsed two holding states into one line"
)
assert "not in this session's loaded set" in fresh assert "not in this session's loaded set" in fresh
assert "not opened" in named
assert "no longer hold it" in held
# The claim that most needs to survive shortening: a line the session never
# opened must not imply it did, however little room the line was given.
assert "no longer hold it" not in named
def test_a_compact_line_is_materially_shorter_than_a_full_one(): def test_a_compact_line_is_materially_shorter_than_a_full_one():
+166
View File
@@ -0,0 +1,166 @@
"""Named is not read: the ledger's third state (#4100).
WHY THIS EXISTS
Milestone 386 made a repeat REFERENCED rather than withheld, and the line it
chose says "You saw it earlier this session". That claim was never checked.
The arms emit a TEASER — title, trigger, `get_rule(N)` — so a session can be
shown a rule twenty times and never read a word of it, and a compaction
summarises the teaser away leaving nothing at all. The server was asserting
something about the reader's context it had no way to know.
`.opened.ids` is the observable half, written by a PostToolUse hook from the
`get_rule` call itself. That is why this is not the self-report 386 rejected:
the objection was to ASKING a model about its own context, and a tool call is
an event the harness reports whether anyone asks or not.
WHAT THIS PINS
The three states and their three lines, the hook that records an open, and the
fact that BOTH ledgers die together on a compaction — a session told "you
opened it earlier" about a rule that was just summarised out of its context
would be a more confident version of the bug this removes.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from scribe.services.plugin_context import _rule_hint_line
ROOT = Path(__file__).resolve().parents[1]
HOOKS = ROOT / "plugin" / "hooks"
RECORDER = HOOKS / "scribe_record_opened.sh"
def _rule(rid=156, kind="rule"):
return MagicMock(id=rid, kind=kind, title="A wait with no deadline is a bug",
when_to_apply="crossing a process boundary")
# ── the three lines ────────────────────────────────────────────────────────
def test_a_rule_never_surfaced_is_offered_as_new():
line = _rule_hint_line(_rule(), where="here", seen=False, held=False)
assert "not in this session's loaded set" in line
def test_a_rule_named_but_not_opened_says_so_and_still_invites():
"""The state that did not exist. It must NOT claim the reader saw it, and
must still carry the pull pointer — a session that skipped the teaser is
in nearly the position of one that was never shown it."""
line = _rule_hint_line(_rule(), where="here", seen=True, held=False)
assert "not opened" in line
assert "get_rule(156)" in line
assert "You saw it earlier" not in line, (
"the middle state is claiming the reader read something they did not"
)
def test_a_rule_the_session_opened_is_described_as_opened():
line = _rule_hint_line(_rule(), where="here", seen=True, held=True)
assert "opened it earlier" in line
assert "get_rule(156)" in line
def test_the_three_states_produce_three_different_lines():
"""Guard against a refactor collapsing two branches: each state has to be
distinguishable, or the distinction this milestone bought is gone while
every individual assertion above still passes."""
lines = {
_rule_hint_line(_rule(), where="here", seen=s, held=h)
for s, h in ((False, False), (True, False), (True, True))
}
assert len(lines) == 3
def test_held_outranks_seen_regardless_of_kind():
"""`kind` moves the head and the ledger moves the tail; #3497's history is
the two being reasoned about together and one of them being forgotten."""
for kind in ("rule", "preference"):
line = _rule_hint_line(_rule(kind=kind), where="here", seen=True, held=True)
assert "opened it earlier" in line
# ── the recorder ───────────────────────────────────────────────────────────
def _run_recorder(event: dict, tmp: Path) -> Path:
for tool in ("bash", "jq"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
env = {"PATH": os.environ["PATH"], "HOME": str(tmp), "TMPDIR": str(tmp)}
out = subprocess.run(["bash", str(RECORDER)], input=json.dumps(event),
capture_output=True, text=True, env=env, timeout=30)
assert out.returncode == 0, out.stderr
return tmp / "scribe-priorart" / "s1.opened.ids"
def test_opening_a_rule_is_recorded(tmp_path):
led = _run_recorder(
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
"tool_input": {"rule_id": 156}}, tmp_path)
assert led.exists()
assert led.read_text().split("\t")[0] == "156"
def test_the_entry_is_stamped_so_it_ages_like_the_naming_ledger(tmp_path):
"""Both ledgers are read by `scribe_rules_live`, which ages on that stamp.
An unstamped entry never expires — bounded, but it would mean an opened
rule stays 'opened' for a session's whole life."""
led = _run_recorder(
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
"tool_input": {"rule_id": 9}}, tmp_path)
parts = led.read_text().strip().split("\t")
assert len(parts) == 2 and parts[1].isdigit()
@pytest.mark.parametrize("event", [
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule", "tool_input": {}},
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
"tool_input": {"rule_id": "../../etc"}},
{"tool_name": "mcp__scribe__get_rule", "tool_input": {"rule_id": 5}},
])
def test_an_event_with_nothing_usable_records_nothing_and_still_exits_zero(event, tmp_path):
"""A hook that fails a tool call over bookkeeping is worse than one that
misses an id: the cost of a miss is one extra line, in the direction that
shows more rather than less."""
led = _run_recorder(event, tmp_path)
assert not led.exists()
# ── the two ledgers stay in step ───────────────────────────────────────────
def test_a_compaction_clears_both_ledgers():
"""The one that would be worst to get wrong. `.opened.ids` describes a
context the compaction just destroyed, so keeping it while clearing the
naming ledger would have the arms telling a freshly-summarised session
"you opened it earlier" about a rule now nowhere in its context.
"""
sh = (HOOKS / "scribe_session_context.sh").read_text()
block = sh.split("case \"$source\" in")[1].split("esac")[0]
assert "compact|clear)" in block
for led in (".rules.ids", ".opened.ids"):
assert re.search(rf"rm -f .*{re.escape(led)}", block), (
f"{led} survives a compaction that destroyed what it describes"
)
def test_the_recorder_is_registered_on_the_get_rule_tool():
hooks = json.loads((HOOKS / "hooks.json").read_text())["hooks"]
posts = hooks["PostToolUse"]
mine = [b for b in posts
if any("scribe_record_opened.sh" in h["command"] for h in b["hooks"])]
assert len(mine) == 1, "the opened-recorder is not registered exactly once"
# An MCP tool's server segment varies with how the plugin was installed, so
# the matcher must not pin one spelling of it.
matcher = mine[0]["matcher"]
assert re.fullmatch(matcher, "mcp__plugin_scribe_scribe__get_rule"), matcher
assert re.fullmatch(matcher, "mcp__scribe__get_rule"), matcher
assert not re.fullmatch(matcher, "Bash"), matcher
+119
View File
@@ -0,0 +1,119 @@
"""A rule cannot be created, or edited into, having no trigger (#4099).
WHY THIS EXISTS
`when_to_apply` is not metadata. `rule_document` embeds a rule as
`{title}{trigger}` / `When to apply: {trigger}\n\n{statement}`, with the
trigger appearing TWICE so purpose dominates a short vector — the shape note
2485 measured. Drop the trigger and the document silently becomes title +
statement: a DIFFERENT shape, ranked against a corpus it does not match, with
nothing to report it. Every bar and every rank in the system assumes one shape.
`create_preference` has always refused an empty trigger. `create_rule` and
`create_project_rule` defaulted it to `""` — so the shape was enforced for the
record kind that merely guides and optional for the kind that binds.
WHAT THIS PINS
The guard lives in the SERVICE, because both doors reach it: the MCP tools and
the frontend's fast path in `routes/rulebooks.py`. A guard written in either
one alone would leave the other able to create a rule that never fires, which
is the failure mode this whole change exists to remove.
These call the service directly and assert on the refusal, so an empty guard
cannot make them pass by accident (rule 167).
"""
from __future__ import annotations
import re
import pytest
from scribe.services import rulebooks as rulebooks_svc
TRIGGER_RE = re.compile(r"when_to_apply", re.I)
@pytest.mark.parametrize("blank", ["", " ", "\n\t "])
def test_a_rulebook_rule_needs_a_trigger(blank):
"""Whitespace is not a trigger — it embeds exactly like an empty one."""
with pytest.raises(ValueError, match=TRIGGER_RE):
rulebooks_svc._require_trigger(blank)
def test_a_real_trigger_passes():
"""The guard has to let the normal case through, or the parametrised
refusals above would pass for a guard that rejects everything."""
assert rulebooks_svc._require_trigger("before any git push") is None
@pytest.mark.asyncio
async def test_create_rule_refuses_before_it_touches_the_database():
"""The guard runs ahead of the ownership check, so a missing trigger is
reported as such rather than as whatever the session lookup says. A bogus
topic_id proves no database was reached: were the guard absent, this would
fail on the topic instead, with a different message."""
with pytest.raises(ValueError, match=TRIGGER_RE):
await rulebooks_svc.create_rule(
topic_id=999_999, user_id=1, title="t", statement="s",
)
@pytest.mark.asyncio
async def test_create_project_rule_refuses_too():
"""The second door into the same defect. Named separately because the two
functions are separate code paths that have drifted apart before."""
with pytest.raises(ValueError, match=TRIGGER_RE):
await rulebooks_svc.create_project_rule(
project_id=999_999, user_id=1, title="t", statement="s",
)
def test_both_creators_actually_call_the_guard():
"""Structural, and deliberately not satisfied by the behavioural tests
above: those would still pass if a future edit inlined a second copy of
the check. One definition is the point — a rule shape enforced in two
places is a rule shape that will be enforced differently in two places."""
import inspect
for fn in (rulebooks_svc.create_rule, rulebooks_svc.create_project_rule):
src = inspect.getsource(fn)
assert "_require_trigger(when_to_apply)" in src, (
f"{fn.__name__} no longer delegates to the shared guard"
)
def test_update_cannot_empty_a_trigger_that_exists():
"""The create guard is worth nothing if an edit can undo it, and both
doors can try: `clear=['when_to_apply']` from the MCP side and an emptied
form input from the REST side.
Asserted on the source because the check sits mid-transaction, after the
mutation and before the commit — reaching it needs a database. What must
not silently vanish is the pairing: the before-value captured, and the
raise that reads it.
"""
import inspect
src = inspect.getsource(rulebooks_svc.update_rule)
assert "trigger_before" in src, "the pre-edit trigger is no longer captured"
assert re.search(r"if trigger_before and not", src), (
"update_rule no longer refuses to empty an existing trigger"
)
def test_a_rule_that_never_had_one_is_still_editable():
"""The deliberate asymmetry, and the reason the check asks 'did this edit
REMOVE a trigger' rather than 'does one exist'.
A rule predating the guard has no trigger. Refusing to save it would
freeze exactly the records that most need fixing — the unreachable ones —
so the guard must not fire when there was nothing to remove.
"""
import inspect
src = inspect.getsource(rulebooks_svc.update_rule)
assert "trigger_before and not" in src, (
"the guard no longer keys on the BEFORE value, so a legacy rule with "
"no trigger can no longer be edited at all"
)
+30 -1
View File
@@ -747,7 +747,18 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
# repo and `project_id=` where a `.scribe` marker names the project. Both # repo and `project_id=` where a `.scribe` marker names the project. Both
# halves are one contract with the route, so both are pinned. # halves are one contract with the route, so both are pinned.
assert "scribe_scope_query" in hook, "hook no longer asks for a project scope" assert "scribe_scope_query" in hook, "hook no longer asks for a project scope"
# `held_rule_ids` joins them for the same reason (#4100): the OPENED ledger
# is read by a shared helper so its ageing and format cannot drift from the
# naming ledger's, which means the key is spelled in defs rather than here.
assert "scribe_held_query" in hook, "hook no longer sends the opened ledger"
assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"} assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"}
# Spelled with its own leading `&` because it is APPENDED to a query that
# already has a scope key, where the two above are alternatives that open
# one. Asserted separately for that reason: a scope key and a ledger key
# are different contracts that happen to share a file.
assert "printf '&held_rule_ids=" in defs, (
"the opened ledger's query key is no longer spelled in the helper"
)
# Both scope keys are read by the shared _project_scope() helper, not inline. # Both scope keys are read by the shared _project_scope() helper, not inline.
assert "_project_scope()" in handler assert "_project_scope()" in handler
@@ -756,6 +767,11 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
assert f'request.args.get("{arg}"' in scope, ( assert f'request.args.get("{arg}"' in scope, (
f"the hooks can send {arg!r} and the route never reads it" f"the hooks can send {arg!r} and the route never reads it"
) )
# Read by the handler itself, not the scope helper — it is about what the
# reader holds, not about which project the work belongs to.
assert 'request.args.get("held_rule_ids")' in handler, (
"the hook sends held_rule_ids and the route never reads it"
)
for arg in ("tool", "command", "exclude_rule_ids"): for arg in ("tool", "command", "exclude_rule_ids"):
assert f'request.args.get("{arg}")' in handler, ( assert f'request.args.get("{arg}")' in handler, (
f"the hook sends {arg!r} and the route never reads it" f"the hook sends {arg!r} and the route never reads it"
@@ -1130,7 +1146,14 @@ _HELD = fake_rule(
when_to_apply="writing any call that crosses a process boundary", when_to_apply="writing any call that crosses a process boundary",
) )
_FRESH_TAIL = "not in this session's loaded set" _FRESH_TAIL = "not in this session's loaded set"
_SEEN_TAIL = "You saw it earlier this session" # `seen` WITHOUT `held` is the middle state since #4100: an arm named this rule
# earlier, and the session never opened it. Every test below drives the arms
# with `exclude_rule_ids` alone — the naming ledger — so this is the tail they
# produce, and it is the one that used to claim "You saw it earlier this
# session" about a teaser nobody had read.
_SEEN_TAIL = "Mentioned earlier this session but not opened"
# The third state, reached only when the opened ledger names the rule too.
_HELD_TAIL = "You opened it earlier this session"
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool", "prompt"]) @pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool", "prompt"])
@@ -1183,6 +1206,12 @@ async def test_the_two_tails_are_distinguishable_and_say_the_true_one(source, ru
f"in its loaded set" f"in its loaded set"
) )
assert fresh_ctx != seen_ctx, "the two tails collapsed into one" assert fresh_ctx != seen_ctx, "the two tails collapsed into one"
# Neither call named an OPENED ledger, so neither may claim the session
# read anything (#4100). Catches `held` defaulting true, which would make
# every repeat assert the strongest of the three claims on no evidence.
assert _HELD_TAIL not in fresh_ctx and _HELD_TAIL not in seen_ctx, (
f"{source} said the session had opened a rule it was only shown"
)
# ── the third axis: KIND (milestone 399) ──────────────────────────────── # ── the third axis: KIND (milestone 399) ────────────────────────────────
+40
View File
@@ -73,6 +73,46 @@ def test_it_is_a_ranked_source():
assert not is_ambient("report_preference") assert not is_ambient("report_preference")
def test_its_bar_is_its_own_key_not_the_prompt_arm_s(monkeypatch):
"""Regression on the coupling #3860 found (and on the fix being real).
This arm shipped reading PROMPTRULE_THRESHOLD_KEY, so an operator tuning
the bar for their own prose moved this one with it and was never told.
That is worse here than anywhere else: every other arm scores a query that
varies per call, while COMPLETION_QUERY is fixed — so this arm's score for
a given corpus is a CONSTANT, and a constant that lands under the bar is a
dead arm rather than a quiet one. No amount of traffic reveals it.
Asserted on the key the lookup actually asks for, which is the thing that
broke, rather than on the constant being defined somewhere.
"""
import asyncio
from scribe.services import plugin_context
from scribe.services.reply_preferences import completion_preferences
asked: list[str] = []
async def get_setting(user_id, key, default):
asked.append(key)
return default
async def search(user_id, query, **kw):
kw["report"].update({"searched": True})
return []
with patch(f"{M}.get_setting", AsyncMock(side_effect=get_setting)), \
patch(f"{M}.semantic_search_rules", AsyncMock(side_effect=search)), \
patch(f"{M}.record_retrieval"):
asyncio.run(completion_preferences(7))
assert asked == [plugin_context.REPORTPREF_THRESHOLD_KEY]
assert plugin_context.REPORTPREF_THRESHOLD_KEY != plugin_context.PROMPTRULE_THRESHOLD_KEY, (
"the two keys are the same string again, so the settings form has one "
"dial driving two arms — which is the defect, whatever the value is"
)
def test_the_query_assumes_no_particular_domain(): def test_the_query_assumes_no_particular_domain():
from scribe.services.reply_preferences import COMPLETION_QUERY from scribe.services.reply_preferences import COMPLETION_QUERY
+1
View File
@@ -141,6 +141,7 @@ async def test_create_rule_requires_owned_topic():
with pytest.raises(ValueError, match="topic .* not found"): with pytest.raises(ValueError, match="topic .* not found"):
await create_rule( await create_rule(
topic_id=999, user_id=7, title="x", statement="y", topic_id=999, user_id=7, title="x", statement="y",
when_to_apply="when the moment this fixture stands in for arises",
) )
+1
View File
@@ -50,6 +50,7 @@ _PAIRS = (
("plugin_context.py", "RULEHINT_DEFAULT_THRESHOLD", "kbRuleHintThreshold"), ("plugin_context.py", "RULEHINT_DEFAULT_THRESHOLD", "kbRuleHintThreshold"),
("plugin_context.py", "TOOLRULE_DEFAULT_THRESHOLD", "kbToolRuleThreshold"), ("plugin_context.py", "TOOLRULE_DEFAULT_THRESHOLD", "kbToolRuleThreshold"),
("plugin_context.py", "PROMPTRULE_DEFAULT_THRESHOLD", "kbPromptRuleThreshold"), ("plugin_context.py", "PROMPTRULE_DEFAULT_THRESHOLD", "kbPromptRuleThreshold"),
("plugin_context.py", "REPORTPREF_DEFAULT_THRESHOLD", "kbReportPrefThreshold"),
# The plan gate (milestone 415): it blocks a create, so a form showing a # The plan gate (milestone 415): it blocks a create, so a form showing a
# looser bar than the one in force would be the more misleading drift. # looser bar than the one in force would be the more misleading drift.
("dedup.py", "PLAN_MATCH_DEFAULT_THRESHOLD", "kbPlanMatchThreshold"), ("dedup.py", "PLAN_MATCH_DEFAULT_THRESHOLD", "kbPlanMatchThreshold"),
+8
View File
@@ -938,8 +938,16 @@ def test_route_reads_every_arg_the_hook_sends():
# whichever of `repo=` / `project_id=` scribe_scope_query picks, so the hook # whichever of `repo=` / `project_id=` scribe_scope_query picks, so the hook
# splices in its output and the helper is the other half of the contract. # splices in its output and the helper is the other half of the contract.
assert "scribe_scope_query" in hook, "hook no longer asks for a project scope" assert "scribe_scope_query" in hook, "hook no longer asks for a project scope"
# Same arrangement for the OPENED ledger (#4100): spelled once in the
# helper so its ageing and format cannot drift from the naming ledger's.
assert "scribe_held_query" in hook, "hook no longer sends the opened ledger"
defs = (HOOK.parent / "scribe_defs.sh").read_text() defs = (HOOK.parent / "scribe_defs.sh").read_text()
assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"} assert set(re.findall(r"printf '([a-z_]+)=", defs)) == {"repo", "project_id"}
# Its own leading `&` — it is appended to a query that already carries a
# scope key, rather than being one of the alternatives that opens one.
assert "printf '&held_rule_ids=" in defs, (
"the opened ledger's query key is no longer spelled in the helper"
)
def test_route_resolves_repo_to_a_project_not_to_a_location_filter(): def test_route_resolves_repo_to_a_project_not_to_a_location_filter():