A rule must say when it applies; the ledger says what was read, not what was shown #162
@@ -96,6 +96,7 @@ const kbToolRuleThreshold = ref("0.68");
|
||||
// And the prompt boundary is a third query shape again — the operator's own
|
||||
// prose rather than anything a tool produced (#3852).
|
||||
const kbPromptRuleThreshold = ref("0.72");
|
||||
const kbReportPrefThreshold = ref("0.72");
|
||||
// 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
|
||||
// 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.
|
||||
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 rpT = Math.min(1, Math.max(0, Number(kbReportPrefThreshold.value) || 0.72));
|
||||
kbInjectThreshold.value = String(t);
|
||||
kbInjectTopK.value = String(k);
|
||||
kbDupThresholdSnippet.value = String(dupSnip);
|
||||
@@ -181,6 +183,7 @@ async function saveKbInject() {
|
||||
kbRuleHintThreshold.value = String(rhT);
|
||||
kbToolRuleThreshold.value = String(trT);
|
||||
kbPromptRuleThreshold.value = String(prT);
|
||||
kbReportPrefThreshold.value = String(rpT);
|
||||
savingKbInject.value = true;
|
||||
kbInjectSaved.value = false;
|
||||
try {
|
||||
@@ -202,6 +205,12 @@ async function saveKbInject() {
|
||||
// queries are different shapes. Moving one must not move the others.
|
||||
kb_toolrule_threshold: String(trT),
|
||||
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_note: String(dupNote),
|
||||
kb_duplicate_threshold_task: String(dupTask),
|
||||
@@ -657,6 +666,9 @@ onMounted(async () => {
|
||||
if (allSettings.kb_promptrule_threshold !== undefined) {
|
||||
kbPromptRuleThreshold.value = allSettings.kb_promptrule_threshold;
|
||||
}
|
||||
if (allSettings.kb_reportpref_threshold !== undefined) {
|
||||
kbReportPrefThreshold.value = allSettings.kb_reportpref_threshold;
|
||||
}
|
||||
if (allSettings.kb_writepath_threshold !== undefined) {
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-reportpref-threshold">Completion-report confidence threshold (0–1)</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
|
||||
the project. There was a setting here that designated the system
|
||||
this install's own interface was built from; it only ever described
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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).",
|
||||
"version": "2026.09.16.1232",
|
||||
"version": "2026.09.16.2102",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
@@ -53,6 +53,15 @@
|
||||
"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": [
|
||||
|
||||
@@ -100,6 +100,8 @@ if [ -n "$session_id" ]; then
|
||||
# stops holding what it was told. scribe_rules_live carries the reasoning.
|
||||
rule_seen=$(scribe_rules_live "$rulefile")
|
||||
[ -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
|
||||
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
|
||||
@@ -289,6 +289,25 @@ scribe_rules_append() {
|
||||
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)
|
||||
#
|
||||
|
||||
@@ -207,6 +207,8 @@ if [ -n "$session_id" ]; then
|
||||
# this arm's sibling hook reads the same rule file through the same helper.
|
||||
rule_seen=$(scribe_rules_live "$rulefile")
|
||||
[ -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
|
||||
|
||||
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
||||
|
||||
@@ -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
|
||||
@@ -105,6 +105,13 @@ case "$source" in
|
||||
# Best-effort, like every other filesystem touch in these hooks: a ledger
|
||||
# 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
|
||||
# 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
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -87,6 +87,8 @@ if [ -n "$session_id" ]; then
|
||||
# session is still holding. scribe_rules_live carries the reasoning.
|
||||
rule_seen=$(scribe_rules_live "$rulefile")
|
||||
[ -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
|
||||
|
||||
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
|
||||
|
||||
+37
-8
@@ -345,13 +345,43 @@ SMOKE_EVENTS: dict[str, str] = {
|
||||
{"session_id": "smoke", "transcript_path": "/nonexistent/smoke.jsonl",
|
||||
"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
|
||||
# functions and exits — silent by construction.
|
||||
"scribe_defs.sh": "",
|
||||
}
|
||||
|
||||
# The one hook that legitimately produces output with no credentials.
|
||||
STATIC_FLOOR = "scribe_session_context.sh"
|
||||
# The hooks that legitimately produce output with NO credentials, each for its
|
||||
# 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).
|
||||
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
|
||||
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")
|
||||
continue
|
||||
out = proc.stdout.strip()
|
||||
if script.name == STATIC_FLOOR:
|
||||
# Emits its bundled static tier regardless; that floor is the
|
||||
# whole point of the two-tier design.
|
||||
if script.name in STATIC_EMITTERS:
|
||||
what = STATIC_EMITTERS[script.name]
|
||||
if not out:
|
||||
fail(f"{rel} [{label}]: emitted nothing — the static "
|
||||
f"behavioural floor must survive having no credentials")
|
||||
fail(f"{rel} [{label}]: emitted nothing — this hook's "
|
||||
f"output must survive having no credentials ({what})")
|
||||
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:
|
||||
# The only thing allowed here is the outage line itself.
|
||||
try:
|
||||
|
||||
@@ -246,7 +246,7 @@ async def get_rule(rule_id: int) -> dict:
|
||||
|
||||
|
||||
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,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
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
|
||||
instruction. State the moment or the material: "before any git
|
||||
push", "when adding a value to a CHECK-gated column", "when a
|
||||
release is being cut". Write it even though the parameter is
|
||||
optional: it is how the rule is found
|
||||
when it matters, and a rule nobody can place is a rule nobody
|
||||
applies.
|
||||
release is being cut". REQUIRED, and not as ceremony: nothing is
|
||||
preloaded, so this is the whole of how the rule is found when it
|
||||
matters — and it is half of what the rule is EMBEDDED as, so a
|
||||
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
|
||||
statement are what a search is matched against, so it should
|
||||
carry the SYMPTOM, not just the situation: the words someone
|
||||
@@ -411,7 +415,7 @@ async def create_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,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
system_ids: list[int] | None = None, force: bool = False,
|
||||
|
||||
@@ -100,6 +100,13 @@ async def autoinject_retrieve():
|
||||
purpose: one session keeps ONE rule ledger, so a
|
||||
rule named by any arm is not re-announced by
|
||||
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
|
||||
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()
|
||||
exclude_ids = _int_list(request.args.get("exclude_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(
|
||||
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(
|
||||
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
|
||||
list, so a rule named by either arm is not
|
||||
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()
|
||||
command = request.args.get("command") or ""
|
||||
project_id, _repo, _unbound = await _project_scope()
|
||||
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(
|
||||
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)
|
||||
|
||||
@@ -202,6 +216,9 @@ async def write_path_prior_art():
|
||||
above, and for the same reason: a rule named
|
||||
twenty turns ago should not be re-offered on
|
||||
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
|
||||
or `canon:<snippet_id>`) already named this
|
||||
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()
|
||||
]
|
||||
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 "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
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,
|
||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||
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)
|
||||
|
||||
|
||||
@@ -345,6 +345,15 @@ async def create_project_rule(project_id: int):
|
||||
statement = (data.get("statement") or "").strip()
|
||||
if not statement:
|
||||
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]
|
||||
try:
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
|
||||
@@ -471,6 +471,25 @@ PROMPTRULE_DEFAULT_THRESHOLD = 0.72
|
||||
# at all, where the act arms never face it because a command is one thing.
|
||||
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:
|
||||
"""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,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
held_rule_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""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
|
||||
|
||||
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]
|
||||
|
||||
# BEFORE the early return, for the reason both sibling arms spell out
|
||||
@@ -1001,7 +1022,10 @@ async def build_prompt_rule_hint(
|
||||
return out
|
||||
|
||||
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
|
||||
]
|
||||
# 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]
|
||||
|
||||
|
||||
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).
|
||||
|
||||
THREE INDEPENDENT AXES SINCE #3851. `compact` joins `kind` and `seen`, and
|
||||
@@ -1361,13 +1387,37 @@ def _rule_hint_line(rule, *, where: str, seen: bool, compact: bool = False) -> s
|
||||
"for how this has been done before" if preference
|
||||
else "before deciding it does not apply"
|
||||
)
|
||||
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 "
|
||||
"session's loaded set."
|
||||
)
|
||||
# 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 = (
|
||||
f"Read it with get_rule({rule.id}) {reason}; it is not in this "
|
||||
"session's loaded set."
|
||||
)
|
||||
if compact:
|
||||
# THE TRIGGER GOES; THE TAIL STAYS. Only one of the two is expensive \u2014
|
||||
# a trigger runs 300-400 characters after #3855, the tail about 100 \u2014
|
||||
@@ -1402,6 +1452,7 @@ async def build_write_path_hint(
|
||||
repo_key: str = "",
|
||||
exclude_derive: list[str] | None = None,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
held_rule_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""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] = []
|
||||
try:
|
||||
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
|
||||
# source in the whole readout reporting a null p90_duration_ms (#3311)
|
||||
# — a gap that reads as "this surface is somehow not measurable" rather
|
||||
@@ -1848,6 +1900,7 @@ async def build_write_path_hint(
|
||||
lines.append(
|
||||
_rule_hint_line(
|
||||
rule, where="here", seen=rule.id in already,
|
||||
held=rule.id in held,
|
||||
compact=idx > 0,
|
||||
)
|
||||
)
|
||||
@@ -1938,6 +1991,7 @@ async def build_tool_rule_hint(
|
||||
*,
|
||||
project_id: int = 0,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
held_rule_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""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
|
||||
|
||||
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
|
||||
# load-bearing rather than incidental.
|
||||
kept = _rule_band(hits)
|
||||
@@ -2029,6 +2084,7 @@ async def build_tool_rule_hint(
|
||||
_rule_hint_line(
|
||||
rule, where=f"to this {tool_name} call",
|
||||
seen=rule.id in already,
|
||||
held=rule.id in held,
|
||||
# Rank decides volume (#3851): the ranker's best guess gets the
|
||||
# trigger, the rest get cited.
|
||||
compact=idx > 0,
|
||||
|
||||
@@ -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
|
||||
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
|
||||
operator's message against the same documents. Starting at the prompt arm's
|
||||
setting is the value with evidence behind it, and an operator's tuning of that
|
||||
bar reaches this too. Every call logs under its own source,
|
||||
`report_preference`, so step 6 can read this surface's near misses apart from
|
||||
the prompt arm's before anyone moves the number.
|
||||
This borrowed the prompt arm's key when it shipped, on the argument that a
|
||||
fixed query against triggers is a different score distribution from an
|
||||
operator's message against the same documents — true, and the reason the two
|
||||
could not stay one dial. Five days of traffic settled it.
|
||||
|
||||
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
|
||||
|
||||
@@ -50,8 +62,8 @@ import time
|
||||
|
||||
from scribe.services.embeddings import semantic_search_rules
|
||||
from scribe.services.plugin_context import (
|
||||
PROMPTRULE_DEFAULT_THRESHOLD,
|
||||
PROMPTRULE_THRESHOLD_KEY,
|
||||
REPORTPREF_DEFAULT_THRESHOLD,
|
||||
REPORTPREF_THRESHOLD_KEY,
|
||||
)
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
from scribe.services.rule_usage import record_rule_surfaced
|
||||
@@ -79,9 +91,9 @@ LIMIT = 3
|
||||
async def _threshold(user_id: int) -> float:
|
||||
try:
|
||||
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):
|
||||
value = PROMPTRULE_DEFAULT_THRESHOLD
|
||||
value = REPORTPREF_DEFAULT_THRESHOLD
|
||||
return min(1.0, max(0.0, value))
|
||||
|
||||
|
||||
|
||||
@@ -457,12 +457,44 @@ async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = N
|
||||
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(
|
||||
topic_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||
) -> Rule:
|
||||
_require_trigger(when_to_apply)
|
||||
async with async_session() as session:
|
||||
await _assert_topic_owned(session, topic_id, user_id)
|
||||
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
|
||||
constraint enforces exactly-one of (topic_id, project_id).
|
||||
"""
|
||||
_require_trigger(when_to_apply)
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
rule = Rule(
|
||||
@@ -651,6 +684,17 @@ async def update_rule(
|
||||
"verify_with", "expires_when",
|
||||
}
|
||||
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
|
||||
# to survive the mutation below. A rule's history is the only record
|
||||
# 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.
|
||||
if rule.verify_with != check_before:
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -77,6 +77,7 @@ async def seeded():
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "A rule with vectors",
|
||||
"Something for the embedder to index.",
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
)
|
||||
async with async_session() as s:
|
||||
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.",
|
||||
verify_with="read the workflow's shell setting",
|
||||
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:
|
||||
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")
|
||||
decision = await rulebooks_svc.create_rule(
|
||||
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(
|
||||
topic.id, uid, "The runner has no bash", "Use POSIX sh.",
|
||||
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(
|
||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||
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:
|
||||
row = await s.get(Rule, stale.id)
|
||||
|
||||
@@ -61,6 +61,7 @@ async def constraint():
|
||||
"Write every `run:` step in POSIX sh.",
|
||||
why="the image ships no bash",
|
||||
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}
|
||||
@@ -259,6 +260,7 @@ async def test_a_version_cannot_be_read_through_a_DIFFERENT_rule(constraint):
|
||||
topic_id = rule.topic_id
|
||||
sibling = await rulebooks_svc.create_rule(
|
||||
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(
|
||||
|
||||
@@ -63,6 +63,7 @@ async def test_create_rule_passes_required_fields():
|
||||
from scribe.mcp.tools.rulebooks import create_rule
|
||||
await create_rule(
|
||||
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
|
||||
assert kwargs["user_id"] == 7
|
||||
@@ -79,7 +80,7 @@ async def test_create_rule_blocked_by_duplicate_gate():
|
||||
AsyncMock(return_value=dup)), \
|
||||
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
|
||||
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["existing_id"] == 47
|
||||
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))), \
|
||||
_plain_detail():
|
||||
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
|
||||
find_mock.assert_not_called()
|
||||
|
||||
@@ -248,6 +249,7 @@ async def test_create_project_rule_passes_required_fields():
|
||||
project_id=42,
|
||||
statement="Always run migrations through alembic, not raw SQL.",
|
||||
why="audit trail",
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["user_id"] == 7
|
||||
@@ -265,6 +267,7 @@ async def test_create_project_rule_derives_title_from_statement():
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
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
|
||||
# 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,
|
||||
statement="anything",
|
||||
title="no auto-docstrings",
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["title"] == "no auto-docstrings"
|
||||
|
||||
@@ -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
|
||||
scribe can and should update during use."* A preference that asks every time
|
||||
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
|
||||
routine.
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
fact about its rank; whether the session holds it is a fact about the
|
||||
ledger; and neither may be allowed to answer the other's question.
|
||||
`compact` and the ledger are independent axes. How much room a line gets is
|
||||
a fact about its rank; what the session holds is a fact about the ledger;
|
||||
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)
|
||||
seen = _rule_hint_line(rule, where="here", seen=True, compact=True)
|
||||
fresh = _rule_hint_line(rule, where="here", seen=False, compact=True)
|
||||
assert seen != fresh
|
||||
assert "no longer hold it" in seen
|
||||
named = _rule_hint_line(rule, where="here", seen=True, compact=True)
|
||||
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 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():
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
# 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"
|
||||
# `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"}
|
||||
# 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.
|
||||
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, (
|
||||
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"):
|
||||
assert f'request.args.get("{arg}")' in handler, (
|
||||
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",
|
||||
)
|
||||
_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"])
|
||||
@@ -1183,6 +1206,12 @@ async def test_the_two_tails_are_distinguishable_and_say_the_true_one(source, ru
|
||||
f"in its loaded set"
|
||||
)
|
||||
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) ────────────────────────────────
|
||||
|
||||
@@ -73,6 +73,46 @@ def test_it_is_a_ranked_source():
|
||||
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():
|
||||
from scribe.services.reply_preferences import COMPLETION_QUERY
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ async def test_create_rule_requires_owned_topic():
|
||||
with pytest.raises(ValueError, match="topic .* not found"):
|
||||
await create_rule(
|
||||
topic_id=999, user_id=7, title="x", statement="y",
|
||||
when_to_apply="when the moment this fixture stands in for arises",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ _PAIRS = (
|
||||
("plugin_context.py", "RULEHINT_DEFAULT_THRESHOLD", "kbRuleHintThreshold"),
|
||||
("plugin_context.py", "TOOLRULE_DEFAULT_THRESHOLD", "kbToolRuleThreshold"),
|
||||
("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
|
||||
# looser bar than the one in force would be the more misleading drift.
|
||||
("dedup.py", "PLAN_MATCH_DEFAULT_THRESHOLD", "kbPlanMatchThreshold"),
|
||||
|
||||
@@ -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
|
||||
# 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"
|
||||
# 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()
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user