feat(rules): a preference does not speak in a rule's voice (#3849 step 3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 41s

Two independent axes on one hint line. `kind` decides the head, `seen`
decides the tail, and neither reads the other — which is what let a second
kind arrive without reopening #3750's repeat question. Whether a record is
already on the exclusion ledger has nothing to do with how much force it
carries, so the seen branch is shared verbatim.

The noun carries the whole visual difference, deliberately. A reader skimming
an injected block gets one word to place the register, so the word that moves
is the one naming force: "Standing rule" / "Preference". Everything
structural after it is identical, so the kinds read as one set rather than
two formats.

Force is asserted in exactly one other place, and that moves too. A rule's
line says to read it BEFORE DECIDING IT DOES NOT APPLY, because dismissing a
rule unread is how the thing it prevents happens. A preference makes no such
claim: it says where to find HOW THIS HAS BEEN DONE BEFORE, and following it
buys consistency rather than correctness.

Guarded on both places at once. Pinning the noun alone would pass a line
reading "Preference … before deciding it does not apply" — label swapped,
instruction kept — which is worse than not distinguishing them, because it
looks handled.

And a guard on the independence claim itself, exercising all four
combinations: the way this breaks silently is a seen branch that grows a kind
test, leaving one combination rendered by nobody's intention.

Noted, not fixed: plugin/skills/using-scribe/SKILL.md still says "Standing
rules are binding" with no room for a kind that does not. That surface is
step 6's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
2026-09-10 22:04:14 -04:00
co-authored by Claude Opus 5
parent 89d16d89a9
commit 26e0dff706
2 changed files with 120 additions and 4 deletions
+33 -4
View File
@@ -859,7 +859,13 @@ async def get_writepath_config(user_id: int) -> dict:
} }
def _rule_hint_line(rule, *, where: str, seen: bool) -> str: def _rule_hint_line(rule, *, where: str, seen: bool) -> str:
"""One rule hint line — both arms, both tails (#3750). """One rule hint line — both arms, both tails, both kinds (#3750, #3849).
TWO INDEPENDENT AXES. `kind` decides the head, `seen` decides the tail,
and neither reads the other. A preference and a rule differ in force; a
repeat and a first surfacing differ in whether the session already holds
the line. Those are unrelated facts, and keeping them unrelated in the
code is what stopped the second kind from reopening the repeat question.
ONE FUNCTION BECAUSE THE TAILS MUST NOT DRIFT. The two arms phrase their ONE FUNCTION BECAUSE THE TAILS MUST NOT DRIFT. The two arms phrase their
heads differently ("may apply here" vs "may apply to this Bash call") and heads differently ("may apply here" vs "may apply to this Bash call") and
@@ -891,15 +897,38 @@ def _rule_hint_line(rule, *, where: str, seen: bool) -> str:
managing it. managing it.
""" """
trigger = (rule.when_to_apply or "").strip() trigger = (rule.when_to_apply or "").strip()
preference = rule.kind == "preference"
# KIND CHANGES THE HEAD; `seen` CHANGES THE TAIL. The two axes are
# independent and stay that way, which is what lets the repeat logic above
# survive a second kind without being reasoned about again: whether a
# record is already on the ledger has nothing to do with how much force it
# carries, so the seen-branch is shared verbatim.
#
# The noun is the whole of the visual difference, and that is deliberate.
# A reader skimming an injected block gets one word to place the register \u2014
# so it is the SECOND word that moves, and it is the word naming force.
# Everything structural after it is identical, so the three kinds read as
# one set rather than three formats (milestone 385's step 5 writes the
# lesson voice against these two; they are one paragraph, not three).
noun = "Preference" if preference else "Standing rule"
# The only other place force is asserted. A rule's line tells the reader
# not to dismiss it unread, because dismissing a rule unread is how the
# thing it prevents happens. A preference makes no such claim: it says
# where to find how this has been done, and following it is what keeps
# things consistent rather than what keeps them correct.
reason = (
"for how this has been done before" if preference
else "before deciding it does not apply"
)
tail = ( tail = (
f"You saw it earlier this session; pull it with get_rule({rule.id}) " f"You saw it earlier this session; pull it with get_rule({rule.id}) "
"if you no longer hold it." "if you no longer hold it."
if seen else if seen else
f"Read it with get_rule({rule.id}) before deciding it does not " f"Read it with get_rule({rule.id}) {reason}; it is not in this "
"apply; it is not in this session's loaded set." "session's loaded set."
) )
return ( return (
f"Standing rule that may apply {where} \u2014 \u201c{rule.title}\u201d" f"{noun} that may apply {where} \u2014 \u201c{rule.title}\u201d"
+ (f" ({trigger})" if trigger else "") + (f" ({trigger})" if trigger else "")
+ f". {tail}" + f". {tail}"
) )
+87
View File
@@ -1006,6 +1006,93 @@ async def test_the_two_tails_are_distinguishable_and_say_the_true_one(source, ru
assert fresh_ctx != seen_ctx, "the two tails collapsed into one" assert fresh_ctx != seen_ctx, "the two tails collapsed into one"
# ── the third axis: KIND (milestone 399) ────────────────────────────────
#
# A preference is a rule row that does not bind, so it rides the same arms and
# the same line. What must differ is the REGISTER: a rule's line tells the
# reader not to dismiss it unread, because dismissing a rule unread is how the
# thing it prevents happens. A preference makes no such claim — it says where
# to find how this has been done, and following it buys consistency rather
# than correctness.
#
# Rendered in the rule's voice, a preference becomes the thing milestone 399
# exists to avoid: a rule with a different column.
_PREF = fake_rule(
id=177,
kind="preference",
title="Pace hard debugging one step at a time",
statement="Advance one investigative step per turn.",
when_to_apply="during hard debugging",
)
_RULE_FORCE = "before deciding it does not apply"
_PREF_FORCE = "for how this has been done before"
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
@pytest.mark.asyncio
async def test_a_preference_does_not_speak_in_the_rules_voice(source, run):
"""The register, pinned on the two places force is actually asserted.
On BOTH the noun and the clause, because either alone is weak. A line
reading "Preference … before deciding it does not apply" has swapped the
label and kept the instruction, which is worse than not distinguishing
them at all: it looks handled.
"""
ctx = (await run([(0.81, _PREF)], MagicMock()))["context"]
assert "Preference that may apply" in ctx, (
f"{source} announced a preference as something else. The noun is the "
f"one word a skimming reader gets to place the register, so it is the "
f"word that has to move. Context was: {ctx!r}"
)
assert _PREF_FORCE in ctx and _RULE_FORCE not in ctx, (
f"{source} told the session to read a PREFERENCE before deciding it "
f"does not apply. That is a rule's claim: it is the sentence that "
f"makes a line bind, and a preference does not."
)
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
@pytest.mark.asyncio
async def test_kind_and_seen_do_not_read_each_other(source, run):
"""The structural claim the design rests on: two INDEPENDENT axes.
Whether a record is already on the exclusion ledger has nothing to do with
how much force it carries. Keeping the two unrelated in the code is what
let a second kind arrive without reopening #3750's repeat question — and
the way that silently breaks is a `seen` branch that grows a kind test,
or a kind branch that grows a `seen` test, leaving one of the four
combinations rendered by nobody's intention.
So: all four are exercised, and the seen tail must come out identical for
both kinds.
"""
pref_fresh = (await run([(0.81, _PREF)], MagicMock()))["context"]
pref_seen = (await run([(0.81, _PREF)], MagicMock(),
exclude_rule_ids=[177]))["context"]
rule_seen = (await run([(0.81, _HELD)], MagicMock(),
exclude_rule_ids=[156]))["context"]
assert _SEEN_TAIL in pref_seen and _FRESH_TAIL in pref_fresh, (
f"{source}: the seen/fresh split stopped working once kind was added — "
f"the tail axis is now reading the head axis"
)
# The tail is about the LEDGER, so it is shared verbatim. Compared as the
# tail alone rather than the whole line, since the heads differ by design.
assert _SEEN_TAIL in rule_seen, "the rule's seen tail changed"
assert pref_seen.count(_SEEN_TAIL) == rule_seen.count(_SEEN_TAIL) == 1, (
f"{source} rendered the repeat clause a different number of times for "
f"the two kinds; the tail is about the ledger and does not vary by force"
)
# And the head still differs in the seen case — a repeat of a preference
# is still a preference.
assert "Preference that may apply" in pref_seen, (
f"{source} lost the preference register on a repeat, so a preference "
f"seen twice reads as a rule the second time"
)
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"]) @pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_neither_tail_injects_the_rule_statement(source, run): async def test_neither_tail_injects_the_rule_statement(source, run):