Merge pull request 'A rule must say when it applies; the ledger says what was read, not what was shown' (#162) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 14s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 14s
This commit was merged in pull request #162.
This commit is contained in:
@@ -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 (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
|
<!-- 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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
# 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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
# 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) ────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user