feat(plugin): a high-confidence rule is put in front of a command, not beside its result (#4214)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
Milestone 419 step 3, the pre-act checkpoint. Every rule surface in this plugin returns `additionalContext`, which Claude Code delivers alongside the tool RESULT — so the rule is read after the call is written and lands as commentary on a decision already made. That is the milestone's central finding, measured over a session with seven misses, three caught by the operator and none by this system. The action arm can now return a `deny` instead. The act does not run, the rule's text can be read before the call exists, and the remedy is one `get_rule` call after which the act may be re-submitted unchanged. Nothing reaches the operator: a deny is a message to the model. "CONSEQUENTIAL" IS DERIVED, NOT ENUMERATED. The obvious implementation lists act kinds — a write to product code, a schema change, a bulk classification, a merge. Every one of those is consequential because THIS operator wrote rules about it, and shipping that list is this instance's corpus hard-coded into the product (rule 115). So the corpus decides: an act is consequential when the install's own rules speak to it above the checkpoint bar. A fresh install with no rules never stops anything. FOUR CONDITIONS, EACH PREVENTING A DIFFERENT WRONG. Above the bar; a rule and never a preference (which claims no such force); the band's top hit only (the ranker's confidence claim attaches to its first element); and only a rule the session has NOT opened — `held` is observable from the get_rule PostToolUse hook (#4100), not self-report. WHY "NOT OPENED" RATHER THAN "NO OUTCOME RECORDED". An outcome can be satisfied with one cheap call asserting compliance without producing any, and a checkpoint dismissible that way manufactures exactly the compliance data step 2 was built to measure. Reading a rule cannot be faked in that direction: after `get_rule` the statement is in context, which is the whole of what was wanted. THE BAR IS MEASURED. `retrieval_telemetry(days=30)`: write_path_rule p90 0.7628 max 0.8817; pre_tool_rule p90 0.7373 max 0.8293. 0.80 is above p90 on both and below max on both, so it selects from the top decile of an already selective arm and is still reachable. It ships as a setting with a Settings card, because a cosine distance in one model's geometry over one corpus cannot transfer. TWO GUARDS ON THE WORST CASE: at most one hold per rule and five per session, so a mis-set floor degrades to a noisy session rather than one that cannot proceed. The ledger lives in the swept directory and is named `.ids`, so the existing compaction-clear guards cover it. WRITES ARE NOT HELD, AND THAT IS THE OPERATOR'S DECISION RATHER THAN MINE. `scribe_prior_art.sh` carries a tested property that it never returns a permissionDecision — a recall aid may not stand in the way of a write. Three of the milestone's seven misses were file edits and none are reachable from the command side, so there is a live argument for extending this; that argument is exactly why the boundary is now asserted by a test rather than left to memory. The write-path arm computes and returns the same block so the decision can be revisited with evidence; the hook ignores it, and a change of mind is a hook edit rather than a feature. Verified by lifting `checkpoint_for` and `_rule_band` out of source with `ast` and exercising the shipped functions over 17 populations, by running the ledger and deny envelope in bash (10 cases, including that a refused hold is not written and that a garbled rule id fails closed), and by scripts/check_plugin.py — which caught the unminted plugin version, 0300 -> 0426. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -87,6 +87,7 @@ const kbWritePathEnabled = ref(true);
|
||||
// unrelated code through (#2223). Shares top-k, not the threshold.
|
||||
const kbWritePathThreshold = ref("0.68");
|
||||
const kbRuleHintThreshold = ref("0.72");
|
||||
const kbCheckpointThreshold = ref("0.80");
|
||||
// The two ACT arms no longer share a bar (#3853). A write-path query is a code
|
||||
// payload; a pre-tool query is a shell command, often under a dozen words —
|
||||
// less text, less signal, lower scores for the same relevance. Measured at one
|
||||
@@ -275,6 +276,10 @@ 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));
|
||||
// The checkpoint bar, and the `|| default` guard matters most here of all:
|
||||
// this is the only number that can STOP a call, so a fallback of 0 would
|
||||
// hold the first command of every session behind whatever ranked first.
|
||||
const cpT = Math.min(1, Math.max(0, Number(kbCheckpointThreshold.value) || 0.8));
|
||||
const rpT = Math.min(1, Math.max(0, Number(kbReportPrefThreshold.value) || 0.72));
|
||||
// The budgets, clamped the way the server clamps them: a whole number in
|
||||
// [1, 10]. Never 0 — an arm turned off is turned off by its switch, and a
|
||||
@@ -300,6 +305,7 @@ async function saveKbInject() {
|
||||
kbPlanMatchThreshold.value = String(planT);
|
||||
kbWritePathThreshold.value = String(wpT);
|
||||
kbRuleHintThreshold.value = String(rhT);
|
||||
kbCheckpointThreshold.value = String(cpT);
|
||||
kbToolRuleThreshold.value = String(trT);
|
||||
kbPromptRuleThreshold.value = String(prT);
|
||||
kbReportPrefThreshold.value = String(rpT);
|
||||
@@ -319,6 +325,12 @@ async function saveKbInject() {
|
||||
// in services/plugin_context.py for why rules cannot share the
|
||||
// code threshold any more than code could share the prose one.
|
||||
kb_rulehint_threshold: String(rhT),
|
||||
// The one bar that stops a call rather than annotating it. Its own key,
|
||||
// never derived from the two rule bars above: it answers a different
|
||||
// question of the same scores — not "is this worth showing" but "is the
|
||||
// corpus confident enough to be read first" — and a number that moves
|
||||
// when another moves is a number nobody can reason about.
|
||||
kb_checkpoint_threshold: String(cpT),
|
||||
// A FOURTH and FIFTH bar, and they are separate keys on purpose: the
|
||||
// whole finding of #3853 is that one number cannot serve arms whose
|
||||
// queries are different shapes. Moving one must not move the others.
|
||||
@@ -790,6 +802,9 @@ onMounted(async () => {
|
||||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||||
}
|
||||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
||||
if (allSettings.kb_checkpoint_threshold !== undefined) {
|
||||
kbCheckpointThreshold.value = allSettings.kb_checkpoint_threshold;
|
||||
}
|
||||
if (allSettings.kb_rulehint_threshold !== undefined) {
|
||||
kbRuleHintThreshold.value = allSettings.kb_rulehint_threshold;
|
||||
}
|
||||
@@ -1723,6 +1738,32 @@ async function deleteUser(userId: number) {
|
||||
/>
|
||||
<p class="field-hint">How many standing rules one edit may be shown (1–10).</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-checkpoint-threshold">Hold-before-acting threshold (0–1)</label>
|
||||
<input
|
||||
id="kb-checkpoint-threshold"
|
||||
v-model="kbCheckpointThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Every hint above arrives <em>beside</em> the result of the action it
|
||||
was about — useful to read afterwards, too late to change what the
|
||||
action was. Above this bar a standing rule is instead put
|
||||
<em>in front</em> of the command: Claude is asked to read the rule
|
||||
first, and then runs the command anyway if the rule does not apply.
|
||||
Set well above the thresholds above, because it costs a round trip
|
||||
rather than a line. Only standing rules can hold a command, never
|
||||
preferences; only a rule this session has not already read; and
|
||||
never more than once per rule or five times per session, so a bar
|
||||
set too low is a chatty session rather than a stuck one. Commands
|
||||
only — file edits are never held.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-toolrule-threshold">Command confidence threshold (0–1)</label>
|
||||
<input
|
||||
|
||||
@@ -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.21.0300",
|
||||
"version": "2026.09.21.0426",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
@@ -558,6 +558,69 @@ scribe_rules_append() {
|
||||
#
|
||||
# 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.
|
||||
# ── The pre-act checkpoint's session ledger (#4214, milestone 419) ─────────
|
||||
#
|
||||
# A checkpoint STOPS an act rather than annotating it, so unlike every other
|
||||
# ledger here its job is to make sure the same stop cannot happen twice. Two
|
||||
# guards, and they fail in different directions:
|
||||
#
|
||||
# per rule — a rule may hold at most ONE act per session. Once it has, the
|
||||
# remedy has been offered; repeating it on the next call would
|
||||
# turn a reader who decided the rule does not apply into a
|
||||
# reader who cannot proceed.
|
||||
# per session — at most `_SCRIBE_CHECKPOINT_CAP` stops in total, whatever the
|
||||
# corpus scores. A mis-set floor or a corpus that suddenly
|
||||
# resembles everything must degrade to a noisy session, never
|
||||
# to one that cannot make progress. This is the guard on the
|
||||
# worst case, not a tuning value.
|
||||
#
|
||||
# NOT AGED, unlike the naming ledgers. Those age because they describe what a
|
||||
# session is still HOLDING, and a context stops holding things. This one
|
||||
# describes what already HAPPENED — a stop was raised and its remedy offered —
|
||||
# and that does not become untrue an hour later. Ageing it would let a long
|
||||
# session be stopped by the same rule repeatedly, which is the one outcome
|
||||
# both guards exist to prevent.
|
||||
_SCRIBE_CHECKPOINT_CAP=5
|
||||
|
||||
scribe_checkpoint_allowed() {
|
||||
# $1 ledger file, $2 rule id. Returns 0 (and RECORDS the stop) when this act
|
||||
# may be held; non-zero otherwise. Records on the way out rather than asking
|
||||
# the caller to, because a caller that forgets is a session that can be
|
||||
# stopped forever and the failure is invisible until it happens.
|
||||
local f="$1" id="$2" n
|
||||
[ -n "$f" ] || return 1
|
||||
id=$(printf '%s' "$id" | tr -cd '0-9')
|
||||
[ -n "$id" ] || return 1
|
||||
if [ -f "$f" ]; then
|
||||
# Already held an act for this rule — the remedy has been offered once.
|
||||
grep -qx "$id" "$f" 2>/dev/null && return 1
|
||||
n=$(grep -c '^[0-9][0-9]*$' "$f" 2>/dev/null || printf '0')
|
||||
# `grep -c` over a missing file can print nothing; a bare arithmetic test
|
||||
# on an empty string is a syntax error in some shells and silently true in
|
||||
# others, which is how a cap comes to cap nothing (#3191's shape).
|
||||
n=$(printf '%s' "$n" | tr -cd '0-9')
|
||||
[ -n "$n" ] || n=0
|
||||
[ "$n" -ge "$_SCRIBE_CHECKPOINT_CAP" ] && return 1
|
||||
fi
|
||||
printf '%s\n' "$id" >> "$f" 2>/dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
scribe_json_deny() {
|
||||
# $1 hook event name, $2 the reason the agent reads INSTEAD of the result.
|
||||
#
|
||||
# The one place this plugin emits a decision on a tool call. `deny` returns
|
||||
# the reason to the MODEL and the call does not run — it is not a prompt to
|
||||
# the operator, costs them nothing, and is undone by the model simply
|
||||
# submitting the call again. That is the whole difference from `ask`, which
|
||||
# would hand a judgement that is the agent's to make to the person who asked
|
||||
# for the work.
|
||||
local esc
|
||||
esc=$(printf '%s' "$2" | scribe_json_escape) || return 0
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"%s","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' \
|
||||
"$1" "$esc"
|
||||
}
|
||||
|
||||
scribe_held_query() {
|
||||
local ids
|
||||
ids=$(scribe_rules_live "$1")
|
||||
|
||||
@@ -102,12 +102,46 @@ body=$(curl -fsS --max-time 5 \
|
||||
|
||||
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||||
context=$(scribe_json_pick "$body_flat" '.context')
|
||||
[ -n "$context" ] || exit 0
|
||||
|
||||
# Remember what was named so it is not repeated this session.
|
||||
# Remember what was named so it is not repeated this session. BEFORE the
|
||||
# checkpoint branch and before the empty-context exit: the ledger records what
|
||||
# the server chose to surface, which happened whichever way this hook then
|
||||
# renders it. Doing it inside one branch is how the two arms' ledgers came to
|
||||
# disagree once already.
|
||||
if [ -n "$rulefile" ]; then
|
||||
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
|
||||
fi
|
||||
|
||||
# ── The pre-act checkpoint (#4214, milestone 419) ────────────────────────
|
||||
#
|
||||
# WHY THIS ARM AND NOT THE WRITE PATH. scribe_prior_art.sh carries an explicit,
|
||||
# tested property that it never returns a permissionDecision — a recall aid may
|
||||
# not stand in the way of a write, which is the operator's decision and is
|
||||
# guarded by test_hook_never_returns_a_permission_decision. No equivalent
|
||||
# decision covers this arm, and the misses that motivated the milestone on the
|
||||
# command side are the ones a stop actually reaches: a commit message asserting
|
||||
# what CI said, a verification script that checks nothing.
|
||||
#
|
||||
# WHAT THE STOP IS FOR. Everything else this plugin emits is additionalContext,
|
||||
# which Claude Code delivers alongside the tool RESULT — so the rule is read
|
||||
# after the call is written and reads as commentary on a decision already made.
|
||||
# That is milestone 419's central finding. A deny returns the reason to the
|
||||
# model with the call unrun, so the rule's own text can be read before the act
|
||||
# exists. It costs the operator nothing: no prompt reaches them, and the model
|
||||
# clears it by reading one record and re-submitting.
|
||||
#
|
||||
# It cannot recur. `scribe_checkpoint_allowed` holds at most one act per rule
|
||||
# and at most five per session, so the worst case of a mis-set floor is a noisy
|
||||
# session rather than one that cannot move.
|
||||
cp_rule=$(scribe_json_pick "$body_flat" '.checkpoint.rule_id')
|
||||
cp_reason=$(scribe_json_pick "$body_flat" '.checkpoint.reason')
|
||||
if [ -n "$cp_rule" ] && [ -n "$cp_reason" ] && [ -n "$session_id" ]; then
|
||||
if scribe_checkpoint_allowed "$state_dir/${safe_sid}.checkpoint.ids" "$cp_rule"; then
|
||||
scribe_json_deny PreToolUse "$cp_reason"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -n "$context" ] || exit 0
|
||||
scribe_json_out PreToolUse "$context"
|
||||
exit 0
|
||||
|
||||
@@ -170,6 +170,24 @@ async def pre_tool_rules():
|
||||
different claims about the reader's
|
||||
context, so they get different lines
|
||||
(#4100).
|
||||
|
||||
Returns `context`, `rule_ids`, and `checkpoint` (#4214, milestone 419).
|
||||
|
||||
`checkpoint` IS THE ONE PART OF THIS RESPONSE THAT IS NOT A HINT. It is
|
||||
empty on almost every call. When present it carries `rule_id`, `title`,
|
||||
`trigger`, `score` and a rendered `reason`, and it means the hook should
|
||||
DENY the call rather than annotate it — because every other line this
|
||||
endpoint returns is delivered by Claude Code alongside the tool RESULT,
|
||||
so it reaches the reader after the act is composed and reads as
|
||||
commentary on a decision already made.
|
||||
|
||||
It is raised only for a rule (never a preference, which claims no such
|
||||
force), only for the ranker's top hit, only above the checkpoint
|
||||
threshold — well above this arm's own floor — and only when the session
|
||||
has NOT opened that rule. The remedy is one `get_rule` call, and the act
|
||||
may then be re-submitted unchanged; the hook caps stops at one per rule
|
||||
and five per session so a mis-set floor degrades to noise rather than to
|
||||
a session that cannot proceed.
|
||||
"""
|
||||
tool = (request.args.get("tool") or "tool").strip()
|
||||
command = request.args.get("command") or ""
|
||||
@@ -223,6 +241,14 @@ async def write_path_prior_art():
|
||||
or `canon:<snippet_id>`) already named this
|
||||
session by the ledger arm (#2900); its own
|
||||
channel, like the two above.
|
||||
(Returns a `checkpoint` block on the same contract as /tool-rules —
|
||||
see that endpoint. The write-path HOOK deliberately does not act on
|
||||
it: scribe_prior_art.sh carries a tested property that it never
|
||||
returns a permissionDecision, on the operator's decision that a recall
|
||||
aid may not stand in the way of a write. The block is computed and
|
||||
returned so that decision can be revisited with evidence rather than
|
||||
re-argued, and so a change of mind is a hook edit and not a feature.)
|
||||
|
||||
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||
found in (or enclosing) the payload, kind being
|
||||
css|sym. The shape ledger's write-path feed
|
||||
|
||||
@@ -271,6 +271,66 @@ RULEHINT_LIMIT = SURFACES["write_path_rule"].budget_default
|
||||
# stops working and the answer is a reranker (#1038), not a smaller number.
|
||||
_RULEHINT_BAND = 0.05
|
||||
|
||||
# ── The pre-act checkpoint (#4214, milestone 419) ───────────────────────
|
||||
#
|
||||
# WHAT A CHECKPOINT IS, AND WHY IT IS NOT A LOUDER HINT. Every arm above
|
||||
# appends text to an act the agent has already composed. Claude Code delivers
|
||||
# `additionalContext` alongside the tool result, so by the time the line is
|
||||
# read the call is written and the rule reads as commentary on a decision
|
||||
# already made. That is milestone 419's central finding, measured over a
|
||||
# session where three misses were caught by the operator and none by this
|
||||
# system.
|
||||
#
|
||||
# A checkpoint is the same retrieval, spent differently: the hook returns a
|
||||
# `deny` and the act does not run, so the rule's own text is read BEFORE the
|
||||
# call exists. The remedy is `get_rule(id)` and nothing else — which is why
|
||||
# the condition below is "the session has not opened it" rather than "the
|
||||
# session has not recorded an outcome for it". An outcome can be satisfied
|
||||
# with one cheap call that asserts compliance without producing any, and a
|
||||
# checkpoint that can be dismissed that way manufactures exactly the
|
||||
# compliance data step 2 exists to measure. Reading a rule cannot be faked in
|
||||
# that direction: after `get_rule` the statement is in context, which is the
|
||||
# whole of what was wanted.
|
||||
#
|
||||
# WHAT "CONSEQUENTIAL" MEANS HERE, AND WHY IT IS NOT A LIST. The obvious
|
||||
# implementation enumerates act kinds — a write to product code, a schema
|
||||
# change, a bulk classification, a merge. Every one of those is consequential
|
||||
# because THIS operator wrote rules about it, and a shipped list would be this
|
||||
# instance's corpus hard-coded into the product (rule 115). So the corpus
|
||||
# decides: an act is consequential when the install's own rules speak to it
|
||||
# with high confidence. A fresh install with no rules never stops anything,
|
||||
# and an install whose rules are about something else entirely stops on that
|
||||
# instead.
|
||||
_CHECKPOINT_THRESHOLD_KEY = "kb_checkpoint_threshold"
|
||||
|
||||
# MEASURED, not chosen (2026-09-21, `retrieval_telemetry(days=30)` on the
|
||||
# instance this was built on — recorded as provenance for the number, not as
|
||||
# a defence of it under rule 115):
|
||||
#
|
||||
# write_path_rule floor 0.72 p10 0.6984 p50 0.7319 p90 0.7628 max 0.8817
|
||||
# pre_tool_rule floor 0.68 p10 0.6838 p50 0.7018 p90 0.7373 max 0.8293
|
||||
#
|
||||
# 0.80 sits above p90 on BOTH arms and below max on both, so it selects from
|
||||
# the top decile of an already-selective arm rather than from its bulk — and
|
||||
# it is reachable, which a bar above 0.8293 would not be for the busier arm.
|
||||
# Over that window the two arms returned ~3,584 non-empty calls between them,
|
||||
# so an upper bound of one tenth of those is ~12 a day across a very heavy
|
||||
# install, and the true rate is lower because 0.80 is not p90 but above it.
|
||||
#
|
||||
# A SETTING, because the number above is a distance in one embedding model's
|
||||
# geometry over one corpus and cannot transfer (retrieval_surfaces' opening
|
||||
# argument). The default ships as a starting point with the means to correct
|
||||
# it, which is the only honest form for a number like this.
|
||||
_CHECKPOINT_DEFAULT = 0.80
|
||||
|
||||
# A session cannot be stopped more than this many times, however the corpus
|
||||
# scores. Not a tuning value — a guard on the worst case, like MAX_BUDGET: a
|
||||
# mis-set floor or a corpus that suddenly resembles everything must degrade to
|
||||
# a noisy session, never to one that cannot make progress. The hook enforces
|
||||
# it, because only the hook knows what a session is.
|
||||
CHECKPOINT_SESSION_CAP = 5
|
||||
|
||||
|
||||
# AND A REPEAT COMPETES ON RANK ALONE (#3750), WHICH SURVIVES THE BAND.
|
||||
#
|
||||
# Since #3750 a hit already on the session's exclusion ledger is RENDERED
|
||||
@@ -1524,8 +1584,35 @@ async def get_writepath_config(user_id: int) -> dict:
|
||||
"rule_top_k": await budget_for(user_id, "write_path_rule"),
|
||||
"tool_rule_threshold": await floor_for(user_id, "pre_tool_rule"),
|
||||
"tool_rule_top_k": await budget_for(user_id, "pre_tool_rule"),
|
||||
# NOT from the surfaces registry, and deliberately so. Everything in
|
||||
# that table is a pair belonging to one QUERY — a floor saying what is
|
||||
# worth ranking and a budget saying how many lines it may spend. The
|
||||
# checkpoint runs no query of its own; it re-reads hits the two rule
|
||||
# arms already produced and asks a different question of them. Putting
|
||||
# it in the registry would give it a phantom budget and make the
|
||||
# tuning tool offer to change how many checkpoints an act may raise,
|
||||
# which is not a number anyone should have.
|
||||
"checkpoint_threshold": await _checkpoint_floor(user_id),
|
||||
}
|
||||
|
||||
|
||||
async def _checkpoint_floor(user_id: int) -> float:
|
||||
"""The confidence at which a hint becomes a stop. Clamped, never trusted.
|
||||
|
||||
A floor read out of settings reaches here as operator-typed text. Below
|
||||
zero it would stop every act with a rule anywhere near it; above one it
|
||||
can never fire and the feature is silently dead, which is the failure mode
|
||||
#3430 found and the reason this clamps rather than validating at the door.
|
||||
"""
|
||||
raw = await get_setting(
|
||||
user_id, _CHECKPOINT_THRESHOLD_KEY, str(_CHECKPOINT_DEFAULT),
|
||||
)
|
||||
try:
|
||||
value = float(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return _CHECKPOINT_DEFAULT
|
||||
return min(1.0, max(0.0, value))
|
||||
|
||||
def _rule_band(hits: list) -> list:
|
||||
"""The top hit, plus every hit within `_RULEHINT_BAND` of it (#3851).
|
||||
|
||||
@@ -1547,6 +1634,104 @@ def _rule_band(hits: list) -> list:
|
||||
return [(s, r) for s, r in hits if s >= top - _RULEHINT_BAND]
|
||||
|
||||
|
||||
def checkpoint_for(
|
||||
kept: list, *, held: set[int], floor: float, where: str,
|
||||
) -> dict:
|
||||
"""The one rule, if any, that should STOP this act rather than annotate it.
|
||||
|
||||
Sync and pure so it can be read against a fixed list of hits without a
|
||||
database — the two arms share it for the reason `_rule_band` is shared:
|
||||
#3497 is the record of these two drifting apart by being modelled on each
|
||||
other instead of sharing one function.
|
||||
|
||||
FOUR CONDITIONS, AND EACH IS A DIFFERENT KIND OF WRONG IT PREVENTS.
|
||||
|
||||
1. THE SCORE CLEARS `floor`. Not the arm's own floor — a much higher bar,
|
||||
measured at `_CHECKPOINT_DEFAULT`. The hint arms keep nudging at their
|
||||
floor; only a hit the corpus is confident about is allowed to stop
|
||||
anything.
|
||||
|
||||
2. IT IS A RULE, NEVER A PREFERENCE. A preference says how something has
|
||||
been done before and following it is what keeps work consistent; a rule
|
||||
says what happens if you do not. Stopping an act over a preference
|
||||
would assert a force the record explicitly does not claim, and
|
||||
`_rule_hint_line` already keeps that distinction in the one word that
|
||||
names it.
|
||||
|
||||
3. THE SESSION HAS NOT OPENED IT. `held` is observable — a PostToolUse
|
||||
hook watches for the `get_rule` call (#4100) — so this is a recorded
|
||||
event and not a model's self-report about its own context. A session
|
||||
that read the rule has already had the thing the checkpoint exists to
|
||||
produce, and stopping it again would be punishing the behaviour being
|
||||
asked for.
|
||||
|
||||
4. IT IS THE TOP HIT. `kept` is a band, and a band's tail is there to let
|
||||
an act surface a SET; the ranker's confidence claim attaches to its
|
||||
first element only. A checkpoint raised on the fourth line of a band is
|
||||
a stop justified by a score nobody claimed.
|
||||
|
||||
Returns a dict rather than a rule or a tuple. Widening a tuple is an
|
||||
interface change to every unpack site that the compiler does not report
|
||||
(#4207, learned the expensive way in this same milestone's first week), and
|
||||
this value crosses a JSON boundary into a shell script where a missing
|
||||
field is a silently empty variable.
|
||||
"""
|
||||
if not kept or floor <= 0:
|
||||
return {}
|
||||
score, rule = kept[0]
|
||||
if score < floor:
|
||||
return {}
|
||||
if getattr(rule, "kind", "") == "preference":
|
||||
return {}
|
||||
if rule.id in held:
|
||||
return {}
|
||||
found = {
|
||||
"rule_id": rule.id,
|
||||
"title": rule.title,
|
||||
"trigger": (rule.when_to_apply or "").strip(),
|
||||
"score": round(float(score), 4),
|
||||
"where": where,
|
||||
}
|
||||
# RENDERED HERE, at the one call site, rather than by each arm. Two arms
|
||||
# that each remember to render it are two arms that can stop agreeing on
|
||||
# what a stop says — which is #3497's history for this exact pair. The
|
||||
# text stays a separate function so it can be read and tested without a
|
||||
# rule object, but nothing outside this line decides whether to call it.
|
||||
found["reason"] = checkpoint_reason(found)
|
||||
return found
|
||||
|
||||
|
||||
def checkpoint_reason(checkpoint: dict) -> str:
|
||||
"""The text the agent reads INSTEAD of running the act.
|
||||
|
||||
Written as a practice rather than a prohibition (rule 165): it says what
|
||||
to do and why it is worth doing, not what is forbidden. The act is not
|
||||
wrong — nothing here knows whether it is — and saying so plainly is what
|
||||
keeps the stop from reading as an accusation the system is in no position
|
||||
to make.
|
||||
|
||||
It names the remedy as ONE call, because a stop whose remedy is vague
|
||||
costs more than the miss it prevents. And it says the act may simply be
|
||||
re-submitted afterwards, so a reader who finds the rule irrelevant is out
|
||||
in two calls rather than negotiating with a hook.
|
||||
"""
|
||||
if not checkpoint:
|
||||
return ""
|
||||
trigger = checkpoint.get("trigger") or ""
|
||||
return (
|
||||
f"Held for one read. “{checkpoint['title']}” is a standing rule "
|
||||
f"this session has not opened, and it scores {checkpoint['score']} "
|
||||
f"against what you are about to do"
|
||||
+ (f" ({trigger})" if trigger else "")
|
||||
+ f". Read it with get_rule({checkpoint['rule_id']}), then go ahead — "
|
||||
f"re-submit this call unchanged if the rule does not apply, which is a "
|
||||
f"judgement only you can make. Nothing here has decided the act is "
|
||||
f"wrong; the rule is being put in front of it rather than beside it, "
|
||||
f"because a rule delivered alongside a result arrives after the "
|
||||
f"decision it was meant to inform."
|
||||
)
|
||||
|
||||
|
||||
def _rule_hint_line(
|
||||
rule, *, where: str, seen: bool, held: bool = False, compact: bool = False,
|
||||
) -> str:
|
||||
@@ -2203,6 +2388,7 @@ async def build_write_path_hint(
|
||||
#
|
||||
# Fails open like every other arm: a rule hint must never break a write.
|
||||
rule_ids: list[int] = []
|
||||
checkpoint: dict = {}
|
||||
try:
|
||||
already = set(exclude_rule_ids or [])
|
||||
held = set(held_rule_ids or [])
|
||||
@@ -2242,6 +2428,14 @@ async def build_write_path_hint(
|
||||
# would inflate pull_through's denominator with a choice this arm never
|
||||
# made. A reference is a RENDERING decision, not a retrieval outcome.
|
||||
rule_ids.extend(rule.id for _score, rule in fresh)
|
||||
# The stop, beside the lines rather than instead of them — see
|
||||
# `checkpoint_for`. `kept` is passed, not `fresh`: whether a rule
|
||||
# was named earlier this session says nothing about whether this
|
||||
# act should wait for it to be READ, and those are the two axes
|
||||
# #3750 exists to keep apart.
|
||||
checkpoint = checkpoint_for(
|
||||
kept, held=held, floor=cfg["checkpoint_threshold"], where="here",
|
||||
)
|
||||
# TWO tables, and the split is not arbitrary. retrieval_logs is one
|
||||
# row per CALL, keyed on the score distribution a threshold is tuned
|
||||
# from. rule_usage_events is one row per RULE per event, which is the
|
||||
@@ -2312,6 +2506,7 @@ async def build_write_path_hint(
|
||||
"derive": derive,
|
||||
"derive_keys": [d["key"] for d in derive],
|
||||
"rule_ids": rule_ids,
|
||||
"checkpoint": checkpoint,
|
||||
}
|
||||
|
||||
|
||||
@@ -2351,7 +2546,14 @@ async def build_tool_rule_hint(
|
||||
Fails open and returns an empty context on any error: a recall aid may
|
||||
never break the operator's action.
|
||||
"""
|
||||
out: dict = {"context": "", "rule_ids": []}
|
||||
# `checkpoint` is present on EVERY return, including the early ones. The
|
||||
# two arms feed one shell reader, and a key that exists on some responses
|
||||
# and not others is read there as an empty variable either way — so the
|
||||
# difference is invisible at the point it would bite and only shows up in
|
||||
# a test that asserts the contract. Same reason `warnings` is always a
|
||||
# list in the telemetry readout: an absent key and an empty one must not
|
||||
# be two ways of saying nothing.
|
||||
out: dict = {"context": "", "rule_ids": [], "checkpoint": {}}
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
return out
|
||||
@@ -2440,6 +2642,14 @@ async def build_tool_rule_hint(
|
||||
)
|
||||
out["context"] = "\n".join(lines)
|
||||
out["rule_ids"] = rule_ids
|
||||
# AFTER the lines, never instead of them. A checkpoint stops the act;
|
||||
# it does not decide what the act should be told, and a reader who
|
||||
# reads the rule and re-submits must find the same hint waiting. The
|
||||
# two are independent renderings of one retrieval.
|
||||
out["checkpoint"] = checkpoint_for(
|
||||
kept, held=held, floor=cfg["checkpoint_threshold"],
|
||||
where=f"this {tool_name} call",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("pre-tool rule arm failed", exc_info=True)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""A rule put IN FRONT of an act, not beside its result (#4214, milestone 419).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
Every other rule surface in this plugin returns `additionalContext`, which
|
||||
Claude Code delivers alongside the tool RESULT. By the time the line is read
|
||||
the call is written, so the rule reads as commentary on a decision already
|
||||
made. Milestone 419 measured the cost over one session: seven misses, three
|
||||
caught by the operator and none by this system, five of them the same move —
|
||||
acting on the thing in hand without reading the contract around it.
|
||||
|
||||
A checkpoint spends the same retrieval differently. The hook returns a `deny`,
|
||||
the act does not run, and the rule's own text can be read before the call
|
||||
exists. The remedy is one `get_rule` call and the act may then be re-submitted
|
||||
unchanged.
|
||||
|
||||
WHAT THIS PINS, AND WHAT IT DELIBERATELY DOES NOT
|
||||
|
||||
Pinned: the four conditions under which an act may be held, the two guards
|
||||
that stop it recurring, and the shape of the envelope. Not pinned: the bar
|
||||
itself (a tuning value, and a test asserting 0.80 would fail on every retune
|
||||
while proving nothing) or the wording of the reason (prose, and it will be
|
||||
rewritten). The cases below express scores relative to the constant.
|
||||
|
||||
THE ONE BOUNDARY THAT IS A RECORDED DECISION, NOT A DESIGN CHOICE. Only the
|
||||
ACTION arm can hold. `scribe_prior_art.sh` carries a tested property that it
|
||||
never returns a permissionDecision — the operator's decision that a recall aid
|
||||
may not stand in the way of a write — and this milestone does not get to
|
||||
quietly overturn it. The write-path arm computes the same block and returns
|
||||
it, so the decision can be revisited with evidence; the hook ignores it. The
|
||||
last test here asserts that boundary holds, because it is exactly the kind of
|
||||
property that erodes when someone extends the feature later.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services.plugin_context import (
|
||||
_CHECKPOINT_DEFAULT,
|
||||
CHECKPOINT_SESSION_CAP,
|
||||
_rule_band,
|
||||
checkpoint_for,
|
||||
)
|
||||
from tests.helpers import fake_rule
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HOOKS = ROOT / "plugin" / "hooks"
|
||||
DEFS = HOOKS / "scribe_defs.sh"
|
||||
ACTION_HOOK = HOOKS / "scribe_tool_rules.sh"
|
||||
WRITE_HOOK = HOOKS / "scribe_prior_art.sh"
|
||||
|
||||
WHERE = "this Bash call"
|
||||
TRIGGER = "about to assert in a commit message what CI said"
|
||||
|
||||
|
||||
def hit(score: float, rule_id: int, **attrs):
|
||||
# `attrs` overrides rather than duplicates: passing `when_to_apply=""` for
|
||||
# the no-trigger case alongside a hard-coded default is a duplicate keyword
|
||||
# and a TypeError, not a test.
|
||||
fields = {"id": rule_id, "title": f"rule {rule_id}", "when_to_apply": TRIGGER}
|
||||
fields.update(attrs)
|
||||
return (score, fake_rule(**fields))
|
||||
|
||||
|
||||
def hold(kept, held=(), floor=None):
|
||||
return checkpoint_for(
|
||||
kept, held=set(held), floor=_CHECKPOINT_DEFAULT if floor is None else floor,
|
||||
where=WHERE,
|
||||
)
|
||||
|
||||
|
||||
# ── The four conditions ───────────────────────────────────────────────────
|
||||
|
||||
def test_nothing_retrieved_holds_nothing():
|
||||
"""The common case by a wide margin, and the one that must be cheapest."""
|
||||
assert hold([]) == {}
|
||||
|
||||
|
||||
def test_a_hit_under_the_bar_is_a_hint_and_not_a_stop():
|
||||
"""The hint arms keep working at their own floor; only a hit the corpus is
|
||||
confident about is allowed to cost a round trip."""
|
||||
assert hold([hit(_CHECKPOINT_DEFAULT - 0.001, 1)]) == {}
|
||||
|
||||
|
||||
def test_a_hit_on_the_bar_holds_the_act():
|
||||
got = hold([hit(_CHECKPOINT_DEFAULT, 1)])
|
||||
assert got["rule_id"] == 1
|
||||
assert got["where"] == WHERE
|
||||
|
||||
|
||||
def test_a_preference_never_holds_an_act():
|
||||
"""A preference says how something has been done and following it is what
|
||||
keeps work consistent; a rule says what happens if you do not. Stopping an
|
||||
act over a preference would assert a force the record does not claim, and
|
||||
the renderer already keeps that distinction in the word that names it."""
|
||||
assert hold([hit(0.99, 2, kind="preference")]) == {}
|
||||
|
||||
|
||||
def test_a_rule_the_session_already_opened_never_holds_an_act():
|
||||
"""`held` is observable — a PostToolUse hook watches for the `get_rule`
|
||||
call (#4100) — so this is a recorded event, not a model's self-report
|
||||
about its own context. A session that read the rule has already had the
|
||||
thing the checkpoint exists to produce, and holding it again would punish
|
||||
the behaviour being asked for."""
|
||||
assert hold([hit(0.99, 3)], held={3}) == {}
|
||||
|
||||
|
||||
def test_holding_one_rule_does_not_excuse_another():
|
||||
assert hold([hit(0.99, 4)], held={3})["rule_id"] == 4
|
||||
|
||||
|
||||
def test_only_the_bands_top_hit_may_hold():
|
||||
"""`_rule_band` keeps a SET so an act can surface several rules, but the
|
||||
ranker's confidence claim attaches to its first element only. A stop
|
||||
raised on the fourth line of a band is a stop justified by a score nobody
|
||||
claimed — so a preference on top ends the question rather than deferring
|
||||
to the rule behind it."""
|
||||
band = [hit(0.99, 5, kind="preference"), hit(0.985, 6)]
|
||||
assert hold(band) == {}
|
||||
|
||||
|
||||
def test_position_decides_not_score():
|
||||
"""Deliberately falsifiable from the other side: hits arrive ordered, and
|
||||
this reads `kept[0]` rather than re-maximising. A version that took the
|
||||
highest score would pick 8 here."""
|
||||
assert hold([hit(0.985, 7), hit(0.99, 8)])["rule_id"] == 7
|
||||
|
||||
|
||||
def test_the_band_trims_before_the_checkpoint_sees_it():
|
||||
"""The two instruments compose in one direction only: the band decides
|
||||
what is close enough to show, and the checkpoint reads what survived."""
|
||||
kept = _rule_band([hit(0.83, 11), hit(0.81, 12), hit(0.70, 13)])
|
||||
assert len(kept) == 2
|
||||
assert hold(kept, floor=0.80)["rule_id"] == 11
|
||||
|
||||
|
||||
def test_a_floor_of_zero_disables_rather_than_holding_everything():
|
||||
"""The failure direction that matters. A bar read as 0 — a cleared
|
||||
setting, a bad parse — must turn the feature OFF, never hold the first
|
||||
command of every session behind whatever ranked first."""
|
||||
assert hold([hit(0.99, 9)], floor=0.0) == {}
|
||||
|
||||
|
||||
# ── What the held act is told ─────────────────────────────────────────────
|
||||
|
||||
def test_the_reason_names_the_one_call_that_clears_it():
|
||||
"""A stop whose remedy is vague costs more than the miss it prevents."""
|
||||
reason = hold([hit(0.99, 42)])["reason"]
|
||||
assert "get_rule(42)" in reason
|
||||
assert "rule 42" in reason
|
||||
assert TRIGGER in reason
|
||||
|
||||
|
||||
def test_the_reason_says_the_act_may_proceed_unchanged():
|
||||
"""Nothing here knows whether the act is wrong, and saying so is what
|
||||
keeps the stop from reading as an accusation the system cannot support."""
|
||||
assert "re-submit" in hold([hit(0.99, 42)])["reason"]
|
||||
|
||||
|
||||
def test_a_rule_with_no_trigger_renders_without_an_empty_bracket():
|
||||
assert "()" not in hold([hit(0.99, 10, when_to_apply="")])["reason"]
|
||||
|
||||
|
||||
def test_every_held_act_carries_a_rendered_reason():
|
||||
"""Rendered at the one call site inside `checkpoint_for`, never by each
|
||||
arm: two arms that each remember to render it are two arms that can stop
|
||||
agreeing on what a stop says, which is #3497's history for this pair."""
|
||||
assert hold([hit(0.99, 1)])["reason"].strip()
|
||||
|
||||
|
||||
# ── The two guards, in the shell that enforces them ───────────────────────
|
||||
|
||||
def sh(script: str) -> subprocess.CompletedProcess:
|
||||
for tool in ("bash", "awk"):
|
||||
if shutil.which(tool) is None:
|
||||
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
||||
return subprocess.run(
|
||||
["bash", "-c", f'set -uo pipefail\n. "{DEFS}"\n{script}'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ledger(tmp_path):
|
||||
return tmp_path / "sid.checkpoint.ids"
|
||||
|
||||
|
||||
def allowed(ledger, rule_id) -> bool:
|
||||
r = sh(f'scribe_checkpoint_allowed "{ledger}" "{rule_id}" && echo YES || echo NO')
|
||||
assert r.returncode == 0, r.stderr
|
||||
return r.stdout.strip().endswith("YES")
|
||||
|
||||
|
||||
def test_a_rule_may_hold_at_most_one_act_per_session(ledger):
|
||||
"""Once the remedy has been offered, repeating it turns a reader who
|
||||
decided the rule does not apply into a reader who cannot proceed."""
|
||||
assert allowed(ledger, 101)
|
||||
assert not allowed(ledger, 101)
|
||||
assert allowed(ledger, 102), "a different rule is a different claim"
|
||||
|
||||
|
||||
def test_a_session_cannot_be_held_more_than_the_cap(ledger):
|
||||
"""The guard on the worst case, not a tuning value: a mis-set floor or a
|
||||
corpus that suddenly resembles everything must degrade to a noisy session,
|
||||
never to one that cannot make progress."""
|
||||
for n in range(CHECKPOINT_SESSION_CAP):
|
||||
assert allowed(ledger, 200 + n)
|
||||
assert not allowed(ledger, 999)
|
||||
|
||||
|
||||
def test_a_refused_hold_is_not_written_to_the_ledger(ledger):
|
||||
"""Otherwise the cap eats itself: refusals would count toward it and the
|
||||
ledger would grow without a single act ever being held."""
|
||||
for n in range(CHECKPOINT_SESSION_CAP):
|
||||
allowed(ledger, 200 + n)
|
||||
allowed(ledger, 999)
|
||||
assert len(ledger.read_text().split()) == CHECKPOINT_SESSION_CAP
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule_id", ["", "abc", " "])
|
||||
def test_an_unreadable_rule_id_refuses_rather_than_holding(ledger, rule_id):
|
||||
"""Fails CLOSED in the direction that costs nothing. A garbled id cannot
|
||||
be written to the ledger, so allowing it would be a stop that recurs
|
||||
forever with no way to clear it."""
|
||||
assert not allowed(ledger, rule_id)
|
||||
|
||||
|
||||
def test_no_ledger_file_refuses_rather_than_holding(tmp_path):
|
||||
"""A session with no id gets no ledger, and a stop that cannot be recorded
|
||||
is a stop that cannot be capped."""
|
||||
assert not allowed("", 101)
|
||||
|
||||
|
||||
def test_the_deny_envelope_is_valid_json_carrying_the_reason():
|
||||
r = sh('scribe_json_deny PreToolUse "read \\"rule 9\\" first — then re-submit"')
|
||||
assert r.returncode == 0, r.stderr
|
||||
out = json.loads(r.stdout)["hookSpecificOutput"]
|
||||
assert out["hookEventName"] == "PreToolUse"
|
||||
assert out["permissionDecision"] == "deny"
|
||||
# Quotes and an em dash survive the escaper — the reason is prose and will
|
||||
# contain both, and a broken envelope is silently ignored by the harness.
|
||||
assert '"rule 9"' in out["permissionDecisionReason"]
|
||||
assert "re-submit" in out["permissionDecisionReason"]
|
||||
|
||||
|
||||
# ── The boundary that is a recorded decision ──────────────────────────────
|
||||
|
||||
def test_only_the_action_hook_can_hold_an_act():
|
||||
"""THE GUARD ON THE RECORDED DECISION, and the reason it is here rather
|
||||
than left to memory.
|
||||
|
||||
`scribe_prior_art.sh` runs before every Write and Edit and has never been
|
||||
able to stop one. That is the operator's decision — a recall aid may not
|
||||
stand in the way of the work — and `test_hook_never_returns_a_permission_
|
||||
decision` in test_write_path_trigger.py holds the other half of it.
|
||||
|
||||
This milestone has a live argument for extending the checkpoint to writes:
|
||||
three of its seven misses were file edits and none of them are reachable
|
||||
from the command side. That argument is exactly why this assertion exists.
|
||||
A feature with a good reason to spread is the kind that spreads without
|
||||
anyone deciding to, and the decision here is the operator's to revisit.
|
||||
"""
|
||||
assert "scribe_json_deny" in ACTION_HOOK.read_text()
|
||||
write_code = [
|
||||
ln for ln in WRITE_HOOK.read_text().splitlines()
|
||||
if not ln.lstrip().startswith("#")
|
||||
]
|
||||
assert not any("scribe_json_deny" in ln for ln in write_code)
|
||||
assert not any("permissionDecision" in ln for ln in write_code)
|
||||
|
||||
|
||||
def test_the_action_hook_caps_every_hold_it_emits():
|
||||
"""Structural, and able to fail (rule 167): the deny and the ledger call
|
||||
must appear together. A deny emitted outside the guard is a session that
|
||||
can be held by the same rule on every command, which is the one outcome
|
||||
both guards exist to prevent."""
|
||||
text = ACTION_HOOK.read_text()
|
||||
code = [ln for ln in text.splitlines() if not ln.lstrip().startswith("#")]
|
||||
denies = [i for i, ln in enumerate(code) if "scribe_json_deny" in ln]
|
||||
assert denies, "the action arm no longer emits a hold at all"
|
||||
for i in denies:
|
||||
window = "\n".join(code[max(0, i - 6):i])
|
||||
assert "scribe_checkpoint_allowed" in window, (
|
||||
"a hold is emitted without passing the per-rule and per-session "
|
||||
"guards first"
|
||||
)
|
||||
|
||||
|
||||
def test_the_action_hook_records_what_was_surfaced_whichever_way_it_renders():
|
||||
"""The ledger append sits BEFORE the checkpoint branch. What the server
|
||||
chose to surface happened whichever way this hook then renders it, and
|
||||
doing the bookkeeping inside one branch is how the two arms' ledgers came
|
||||
to disagree once already."""
|
||||
code = [
|
||||
ln for ln in ACTION_HOOK.read_text().splitlines()
|
||||
if not ln.lstrip().startswith("#")
|
||||
]
|
||||
append = next(i for i, ln in enumerate(code) if "scribe_rules_append" in ln)
|
||||
deny = next(i for i, ln in enumerate(code) if "scribe_json_deny" in ln)
|
||||
assert append < deny
|
||||
|
||||
|
||||
def test_the_action_hook_is_still_shell_valid():
|
||||
subprocess.run(["bash", "-n", str(ACTION_HOOK)], check=True)
|
||||
Reference in New Issue
Block a user