feat(rules): the trigger contract is shown as a worked contrast, and pinned (#3855)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m29s
CI & Build / Build & push image (push) Successful in 25s

Follows 8c9f947, which taught the trigger shape on the two update_*
surfaces but left the softer regression unguarded: guidance kept and
abstracted back to "name the moment in session vocabulary" — advice about
being concrete that is not itself concrete, which is the shape that was
already on file while the corpus filled with categories.

Two attempts to detect that in free prose were written and discarded:

- Counting quoted multi-word phrases anywhere in a docstring measured
  ambient quotation rather than demonstrated triggers. It PASSED the
  abstracted version by scoring unrelated prose, and text with an odd
  number of quote characters produced matches spanning the gap BETWEEN two
  unrelated phrases.
- Scoping that count to a window after each trigger mention then FAILED
  create_preference in its CORRECT state, its examples sitting further from
  the first mention than any defensible window reaches.

Both were proxies inferring demonstration from prose. Where a property
cannot be measured, changing the shape of the thing is cheaper than a
cleverer measurement — so all five trigger-writing surfaces now carry a
two-line labelled contrast:

  RETRIEVES: "the migration failed with a check violation on a column we
    just extended"
  COLLAPSES: "when working on migrations"

Unambiguous to parse, free in its wording, and a better teaching form than
the sentences it replaces: the labels name the mechanism, so they do work
for the reader rather than only for the test.

The guard now pins both halves — the field is documented, and the contrast
is present, complete and non-identical. Falsified against three regressions
before committing: the paragraph stripped, the examples abstracted away,
and one half of the pair removed. All three fail; the current tree passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
2026-09-11 11:48:14 -04:00
co-authored by Claude Opus 5
parent 8c9f947f09
commit 8e06cdf749
2 changed files with 106 additions and 46 deletions
+33 -13
View File
@@ -420,6 +420,14 @@ async def create_rule(
brought it back as the top hit. Where a rule prevents a specific
failure, put that failure's vocabulary here — the error text,
the wrong behaviour, the dead end.
The two spellings, side by side:
RETRIEVES: "the migration failed with a check violation on a
column we just extended"
COLLAPSES: "when working on migrations"
The second names a CATEGORY. No session ever produces a
category — it produces the command, the error, the half-formed
ask — so a trigger written that way leaves the embedded
document to be carried by the title alone.
tier: "always_on" (default) or "conditional".
The test: can you name the trigger WITHOUT naming a system, an
artifact type or a moment? If the honest answer is "whenever you
@@ -519,10 +527,11 @@ async def create_project_rule(
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. 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
than classifying it:
RETRIEVES: "the CI job passed locally and fails on the runner
with a permission error"
COLLAPSES: "when touching CI config"
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.
@@ -619,7 +628,12 @@ async def update_rule(
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.
of it — that rationalisation is often the only text in existence at the
moment the rule should fire:
RETRIEVES: "catching yourself drafting 'this is small enough to not
count' about a rule you have already read"
COLLAPSES: "when the next action would conflict with a standing rule"
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
@@ -716,13 +730,15 @@ 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.
Show the moment rather than classifying it:
RETRIEVES: "the operator pasted a stack trace and said it is still
broken"
COLLAPSES: "during hard debugging"
The second 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. Both of those describe the same preference; only one of
them can be found at the moment it applies.
`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
@@ -826,7 +842,11 @@ async def update_preference(
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.
this wrong:
RETRIEVES: "the operator said 'clean this up' or 'make it work like',
naming an outcome rather than a change"
COLLAPSES: "when reading any request from the operator"
update_rule carries the full argument.
Empty strings leave fields unchanged; clear_fields empties them by name,
exactly as update_rule does.