feat(rules): the update surfaces teach the trigger shape, not just the create ones (#3855)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Successful in 24s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Successful in 24s
A trigger is two-thirds of a rule's embedded document, so one naming a
CATEGORY rather than a moment collapses the record toward its title and it
never arrives. #3835 measured that across 113 rules; #3855 hit it again on
the eight preferences, where six named a category and two did not.
The split was not carelessness, it was an uneven contract. create_rule has
carried the full argument since c61925b (2026-08-27) and the two
preferences authored that day got good triggers; the six written weeks
earlier got categories. The guidance worked wherever it existed — and it
existed on three of five write surfaces. Both update_* tools were silent,
and the update path is where every RETROFITTED trigger is written, which is
most of them: a trigger that already reads fine as English is the one
nobody rewrites.
So:
- update_rule gains the retrofit case, which is a different trap from the
create case. There the field is empty and the instruction is "write one".
Here one exists, reads perfectly well, and the honest-looking verdict is
that it is fine.
- update_preference gains it too, plus why the field is load-bearing there
specifically: preferences get a reserved slot filled by a kind-filtered
query at limit=1, so the corpus ranks against ITSELF and the trigger is
nearly all that separates one from the next.
- create_preference and create_project_rule now SHOW a moment instead of
describing one. Advice about being concrete that is not itself concrete
is the shape that was already on file while the corpus filled up.
The guard pins one property: a tool taking when_to_apply mentions it. That
is exactly what update_preference failed. The surface list is derived from
register() rather than hand-kept, so a write tool added later is in scope
the day it lands.
Two stronger predicates were written for the softer regression — guidance
kept but abstracted — and both were discarded after falsification: counting
quoted phrases measured ambient quotation and passed the broken version,
and scoping that count to a window failed create_preference while correct.
Rule 167 settles it; the discarded attempts are recorded in the test
docstring so the next author does not repeat them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
@@ -518,8 +518,12 @@ async def create_project_rule(
|
||||
characters of statement.
|
||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||
instruction, and the rule's retrieval surface: name the SYMPTOM,
|
||||
the words someone would type while stuck. See create_rule for the
|
||||
full argument. It informs the tier below rather than deciding it,
|
||||
the words someone would type while stuck. Show the moment rather
|
||||
than classifying it — "the migration failed with a check
|
||||
violation on a column we just extended" retrieves; "when working
|
||||
on migrations" does not, because it names a category no session
|
||||
ever types. See create_rule for the full argument. It informs the
|
||||
tier below rather than deciding it,
|
||||
since a project rule's tier turns on area-scope, not on whether
|
||||
the trigger can be named.
|
||||
tier: "always_on" (default) or "conditional". The SAME two values as
|
||||
@@ -597,6 +601,26 @@ async def update_rule(
|
||||
a rule stops being preloaded into every session and starts arriving when it
|
||||
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
|
||||
|
||||
RETROFITTING A TRIGGER HAS ITS OWN TRAP, and it is not the one create_rule
|
||||
warns about. There the field is empty and the instruction is "write one".
|
||||
Here a trigger usually already EXISTS and reads perfectly well as English —
|
||||
"during hard debugging", "when reading any request from the operator",
|
||||
"before starting an action while a previous one is still settling" — so the
|
||||
honest-looking verdict is that it is fine. It is not. Those three named a
|
||||
CATEGORY rather than a moment, and a category is not a thing any session
|
||||
ever types. `rule_document()` puts this field in twice, as the title's
|
||||
other half and again above the body, so it dominates the vector: a trigger
|
||||
describing the abstraction collapses the record toward its title and the
|
||||
rule never arrives. Measured in #3835 across 113 rules, and again in #3855
|
||||
on six preferences written before this was understood.
|
||||
|
||||
So when you touch a rule with an old trigger, re-read it against the query
|
||||
that would have to match it — the command about to run, the code being
|
||||
written, the operator's actual message — and rewrite it in that vocabulary
|
||||
if it does not. Prefer the words someone produces while the rule applies,
|
||||
including the rationalisation they would be drafting to talk themselves out
|
||||
of it. See create_rule for the full argument and the measurement behind it.
|
||||
|
||||
TO EMPTY A FIELD, NAME IT: clear_fields=["verify_with"]. Passing "" cannot
|
||||
do it — "" means "leave this alone" here, which is what lets you update
|
||||
two fields without wiping the other six. Clearable: why, how_to_apply,
|
||||
@@ -692,6 +716,14 @@ async def create_preference(
|
||||
session would actually be producing then: the command it is about to run,
|
||||
the code it is writing, the thing the operator just asked for.
|
||||
|
||||
Show the moment rather than classifying it. Write "the operator pasted a stack
|
||||
trace and said it is still broken" rather than "during hard debugging";
|
||||
write "about to `git push` with an earlier CI run still unread" rather
|
||||
than "when pacing actions". The second spelling of each is a category, and
|
||||
no session ever produces a category — it produces the command, the error
|
||||
text, the half-formed ask. A trigger naming the abstraction collapses the
|
||||
record toward its title and it never arrives.
|
||||
|
||||
`arose_from_id` IS REQUIRED for the same kind of reason. A preference is
|
||||
expected to change as the work teaches it, and a corpus that drifts with
|
||||
no record of what taught each change is one nobody can audit. Point it at
|
||||
@@ -781,12 +813,28 @@ async def update_preference(
|
||||
create_rule rather than hardening a preference in place. Softening in the
|
||||
other direction is equally an edit worth flagging out loud.
|
||||
|
||||
EDITING `when_to_apply` IS THE HIGHEST-LEVERAGE EDIT HERE, and the easiest
|
||||
to skip, because a preference's trigger is load-bearing in a way a rule's
|
||||
is not. Preferences get a RESERVED slot at the prompt boundary, filled by a
|
||||
kind-filtered query at limit=1 — so the corpus does not merely rank against
|
||||
rules, it ranks against ITSELF, and the trigger is almost all of what
|
||||
separates one preference from the next. Six preferences whose triggers all
|
||||
named a category ("during hard debugging", "when reading any request from
|
||||
the operator") made that slot pick close to arbitrarily on every prompt.
|
||||
|
||||
So whenever you are here for any reason, read the trigger against the
|
||||
operator's message that should have summoned it. If it describes a
|
||||
situation rather than quoting the moment, rewrite it in the words they
|
||||
actually type — and in the words YOU would be producing while about to get
|
||||
this wrong. update_rule carries the full argument.
|
||||
|
||||
Empty strings leave fields unchanged; clear_fields empties them by name,
|
||||
exactly as update_rule does.
|
||||
|
||||
Args:
|
||||
rule_id: The preference to update.
|
||||
arose_from_id: What taught this change. Required; see above.
|
||||
when_to_apply: The moment it applies, in session vocabulary. See above.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not arose_from_id:
|
||||
|
||||
Reference in New Issue
Block a user