Commit Graph
1371 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 88e9c0b0bd feat(tasks): task_kind gains 'spike' — the investigation, not the change (#3099, milestone 312 step 5)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 32s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 31s
A spike is a shape the other kinds cannot hold. `work` ships a change;
`issue` fixes something broken. A spike is time-boxed and its output is
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the end
of it. Filing one as `work` makes a finished investigation look like an
abandoned change, which is why the distinction earns a value rather than a
convention.

It is also the record a failed check asks for. This milestone gave rules a
verify_with; when one fails the rule is wrong, and the next move is often to
go and find out what replaced it. notes.arose_from_id already exists (0065),
so constraint -> spike provenance needed no schema at all — only a docstring
saying it is there.

Rule 36: the value and the widened CHECK land in the same migration, DROP
then ADD, exactly as 0065 did for 'issue'. The two whitelists live in one
tuple each so upgrade and downgrade cannot disagree about what the list was
on either side. The downgrade demotes existing spikes to 'work' first —
lossy, deliberately, because the alternative is a downgrade that fails on
real data, and one that says what it did beats one that cannot run.

'plan' stays whitelisted though retired: historical plan-tasks carry it, and
a row that cannot be rewritten cannot be edited, restored or migrated.

The integration test asserts both halves. A test that only proved 'spike' is
accepted would pass just as happily against a table whose CHECK had been
dropped and never re-added — which is the other way rule 36's failure
happens — so an unknown kind is asserted to still raise.

Not in scope, deliberately: any special lifecycle, time-box enforcement, or
gating relationship. It is a kind, not a workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:02:41 -04:00
bvandeusenandClaude Opus 5 c83bedf3be feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
Rule 27 — the milestone was backend-only until this. Four surfaces:

RULE EDITOR — verify_with and expires_when under a legend that asks the
actual question ("Can this rule go stale?") and says empty is the normal
answer, because most rules are decisions and a form that implies a missing
field would get them filled in out of tidiness. When the SAVED rule carries
a check, the stamp shows with Still true / No longer true beside it. The
stamp reads the stored value, not the draft: an unsaved edit to the textarea
has not been run against anything.

SWEEP PANE — its own surface, not a filter on the rule list. That list can
only ever show one topic of one rulebook, and a rule that has gone false
belongs to no one rulebook; filtering it would under-report, which is the
failure this whole surface exists to catch. Reached from the rulebook list,
below the rulebooks, because that is where you go to look at rules.

RULE ROWS — a chip only on rules carrying a check, so its presence is the
signal. PROJECT RULES TAB — the check shows beside `why` when a rule has
one, read-only: that tab is the project's view of what binds it.

NO AGE-GRADED COLOUR anywhere, deliberately. The sweep is already ordered by
urgency, so a red/amber ramp would restate the ordering AND require an
invented "stale after N days" threshold — a magic number nobody could defend
and the first thing to go out of date. --fs-overdue is error red and reserved
for a broken promise like a missed due date; a verification age is not one,
and colouring it that way makes a rule someone just wrote look broken. Only
"never" is marked, because it is categorically different from a date rather
than a worse one — and it is marked by weight, not hue.

An empty sweep says "Nothing to check", not nothing: good news must not read
as a broken page.

Two chips (tier, then verification) turned out byte-identical, so .rule-chip
moves to rules-shared.css and snippet #2906 is updated to match rather than
left describing a file that has moved on. Its header comment counted the
panes it served; that count went stale the moment a fourth arrived, so it no
longer counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:53:35 -04:00
bvandeusenandClaude Opus 5 9d7485df2d docs(rules): a project rule is shaped differently, not just scoped differently (milestone 312)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 28s
The three surfaces already agree on WHERE a rule goes — the using-scribe
skill's "Where a new rule goes" section and both tool docstrings frame it
as one question, who should this bind. What they did not say is that the
two homes want differently SHAPED rules, and one deferral was actively
misleading.

`create_project_rule` said `tier: "always_on" or "conditional" — see
create_rule`. That imports a bar calibrated for a different blast radius.
On a rulebook rule always_on means every session in every project, so the
test is severe: the trigger must be nameless. A project rule is already
scoped by construction, so always_on costs only that project's sessions —
and being specific, which the family test treats as the signal for
conditional, is what project rules are FOR. The instance's own data says
so: rules 78, 115 and 119 are all project rules and all always_on.

Not zero bar, a different one: conditional is right when the rule is about
one AREA of a large project, because forty always-on rules on one project
reproduces locally the preload bloat milestone 307 fixed globally.

Also:
- create_rule now says to write the general form WITHOUT hedging for
  exceptions — a project needing to narrow it writes its own and links
  with overrides/elaborates. A rulebook rule padded with "unless…" for two
  projects is two project rules that were never written. Only the project
  side mentioned that relationship; the side that benefits from it did not.
- arose_from_id: reach for it harder on a project rule, which usually comes
  from one traceable incident in the repo, where a family rule is more
  often a standing preference with no single origin.
- system_ids is worth setting on a project rule too — it is what lets a
  conditional one arrive with its area.
- when_to_apply no longer claims to "decide" the tier here, which stopped
  being true one entry down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:29:10 -04:00
bvandeusenandClaude Opus 5 410d616c22 feat(rules): the staleness sweep — which standing rules assert a fact nobody has confirmed (#3097, milestone 312 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 35s
The query the last two steps were storage for. `rules_due_for_verification`
returns every rule carrying a `verify_with`, ordered by `verified_at` ASC
NULLS FIRST, each row carrying the check IN FULL — the opposite call from
rule_brief, because the reader is about to go and run it.

NULLS FIRST is the ordering this turns on. Postgres sorts NULLs last on an
ASC ordering, which would put the rules nobody has ever confirmed BEHIND
every rule someone once looked at. Exactly backwards: a claim with no
evidence at all outranks an old one.

Rules with no check never appear, and that is the property that keeps the
list worth reading. Most rules are decisions — no truth value, nothing to go
and check. If they appeared here the sweep would be the rulebook.

`mark_rule_verified(rule_id, still_true)` closes the loop, asymmetrically:
passing writes a stamp, FAILING WRITES NOTHING. There is no "verified false"
state because a rule whose check failed is not in a special condition, it is
wrong — and recording the failure as a flag would let it sit there being
false with the sweep satisfied that someone had looked. So it stays at the
top until someone corrects or retires it, and the response says so.

An unrecognised `tier` filter raises rather than falling back. _valid_tier's
silent always_on default is right for a WRITE — a typo should leave a rule
binding — and wrong for a FILTER, where the same fallback quietly answers a
different question and returns a short list that reads as good news.

Deliberately NOT filterable by project: a project reaches rules through
project scope, subscriptions, always-on rulebooks and exclusions, and a
filter missing one of those paths would UNDER-report — the exact failure
this surface exists to prevent. Said so in the docstring rather than
shipping a half-correct filter.

Ownership-scoped like every other rule read (owned rulebook, or owned
project), in ONE statement with an OR across the XOR rather than two queries
merged in Python, so the ordering is the database's and cannot disagree with
itself. Note that rules have no sharing ACL in this schema — no rule_shares,
no rulebook_shares — so there is no wider set for access.py to consult here.

Also fixes a test title that had been lying for ten tools: "all sixteen
tools" asserted 26. The number now lives only in the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:49:47 -04:00
bvandeusenandClaude Opus 5 469b43f222 test(rules): the kwargs assertion learns about clear (#3096, milestone 312 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 37s
test_update_rule_only_sends_non_default_fields pins that the MCP door
forwards only what the caller actually gave. `clear` is now always
forwarded — an empty tuple is "clear nothing", a value rather than an
absent argument — so the expected kwargs gained it. The property under
test is unchanged: everything left at its default still stays out.

Two tests added beside it while the shape is in view: naming a field for
clearing reaches the service as `clear`, and the check fields are
forwarded when given.

CI 4630 otherwise green — the integration lane ran all six of the new
real-Postgres cases (72 selected, was 66) and applied 0089 -> 0090.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:31:30 -04:00
bvandeusenandClaude Opus 5 c61925be76 feat(rules): the write path carries a rule's check, and empty finally means empty (#3096, milestone 312 step 2)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 29s
verify_with / expires_when now reach a rule through both doors and come back
on every read. The open question this step existed to settle was how to
UNSET a nullable field, and the answer is one convention per door:

- MCP: "" still means "leave unchanged" — an agent filling three fields must
  not wipe the other five — so clearing is explicit, clear_fields=["..."].
  Naming the field is the one form that cannot happen by accident.
- REST: a cleared form input arrives as "", and the service normalises "" to
  NULL for every nullable rule column, so an emptied input does what it looks
  like it does.

Two idioms, one outcome, and the normalisation is what makes the step-3 sweep
correct: `verify_with IS NOT NULL` would otherwise be true for every rule ever
touched through the UI, and the sweep would list the whole rulebook and mean
nothing. to_dict renders "" and NULL identically, so this is only visible
against a real column — hence the integration module rather than a mock.

Editing verify_with drops verified_at. A stamp certifies A CHECK, not a rule;
reword the check and the old stamp vouches for something that no longer
exists. Safe direction, same asymmetry as _valid_tier: a rule wrongly listed
as due costs one look, a rule wrongly vouched for costs the thing the sweep
exists to catch. Editing anything else leaves the stamp alone, or a rulebook
tidy-up would reset every constraint and the ordering would carry nothing.

Reads: rule_brief attaches `last_verified` ONLY to a rule that carries a
check — its presence is the signal, and it says both "this asserts a fact
that can go false" and "here is how long ago anyone confirmed it". "never"
rather than null, per #2483. The check text itself stays in get_rule; a
listing needs to know which rules can rot, not how to test them. Search hits
carry the full trio, since a hit is exactly the moment someone is about to
act on a rule.

Also folds in the #3078 finding, which had been sitting as a note: create_rule
now teaches that when_to_apply is the retrieval surface and must carry the
SYMPTOM — the words you would type while stuck — not just the situation.

fake_rule gains the three fields as None for the reason the helper already
documents one line up: unnamed, verify_with is a truthy MagicMock and every
stand-in rule would claim a check it does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:28:03 -04:00
bvandeusenandClaude Opus 5 e08e999406 feat(rules): a rule can carry its own check — verify_with, expires_when, verified_at (#3095, milestone 312 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 28s
CI & Build / Build & push image (push) Successful in 25s
A rulebook holds two kinds of row in one table. A NORM is a decision: no
truth value, changes only when its author changes it, and they know they
did. A CONSTRAINT asserts a fact about someone else's software, and goes
false with nobody present. Milestone 307's audit found nine stale sites;
every one was a constraint, and not one norm had rotted.

Three nullable columns so a rule can say how to check itself. expires_when
is a STATE, not a date — constraints expire when the ground moves, not on a
schedule. verified_at NULL means never checked and sorts FIRST in the sweep
to come: unexamined outranks examined-long-ago. Most rules set none of the
three; a null verify_with is the marker for "this is a decision, there is
nothing to go and check," and it only reads that way while it stays honest.

Nothing is backfilled and nothing is indexed. A migration cannot invent a
check any more than 0088 could invent a trigger, and the sweep reads a whole
rulebook — hundreds of rows, on operator demand, never on a request path.

Also, in the backup service the fields had to pass through:

- Restore now remaps arose_from_id through note_id_map. It has been exported
  since 0088 and silently dropped on the way back in ever since, so every
  restore lost every rule's provenance link.
- _dt_or_none, because _dt substitutes now() for an absent value. That is
  right for created_at/updated_at and wrong here: a rule nobody ever checked
  would restore looking freshly checked and fall to the bottom of the sweep
  it should top.

Column additions do not move BACKUP_VERSION; only new sections do, as when
0088 added when_to_apply/tier/arose_from_id to the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:44:13 -04:00
bvandeusen 0e5aed58a9 Rules become findable: canon catalog, triggers, tiers, edges, retrieval, surfacing (milestone 307, steps 1–5) (#131)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 15s
Steps 1-5 of milestone 307. Design in note 3026; the step-6 true-up proposal is note 3061 and needs this deployed first.

Behaviour-neutral by construction: tier defaults to always_on, so every rule this instance already has keeps binding exactly as it did. That guarantee is the first case in tests/test_integration_rule_surfacing.py, against real Postgres.

Migrations 0087-0089 are additive. First boot backfills rule embeddings in the background. Plugin manifest at 0.1.47.
2026-08-26 17:12:40 -04:00
bvandeusenandClaude Opus 5 02c1e37620 feat(rules): the write path can notice a standing rule it was never given (#3031, milestone 307 step 5, hook arm)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
A conditional rule is not resident, so a session can be about to violate one it
was never handed. This arm notices: when what is being written resembles a
rule's trigger, the hint names it and says to read it before deciding it does
not apply.

A SUGGESTION, and the plan was wrong about why it could be more. It claimed the
hook "already resolves a path to an area" — it does not, and nothing in Scribe
maps a path to a System or a canonical area (build_write_path_hint resolves
paths against snippet LOCATIONS, a different index; the learned-alias idea
belongs to another project). Correction logged on the task. Rather than invent
path→area inference to make a stale claim true, the arm does what D7 already
decided and what this surface already IS: tags bind at enter_project, meaning
suggests here. The header of the hook says NEVER BLOCKS; dressing a hint up as
binding would have been the actual mistake.

CONDITIONAL RULES ONLY. An always-on rule is already in the session, so
re-offering it is noise — and noise on a hint that fires on every write is how
a hint gets ignored.

Telemetry goes to retrieval_logs, NOT note_usage_events, and that is a
correctness call rather than a preference: note_usage ids are REMAPPED on a
backup restore, so a rule id written there would come back attached to whatever
note took that number — silently corrupting the evidence the next true-up is
supposed to read. retrieval_logs is never restored and `source` already
separates surfaces. record_retrieval's `results` type widened to match what it
actually needs (an `.id`), instead of passing a Rule to something annotated Note.

The rule dedup gets its OWN state file and query parameter, like the three
channels before it — #2708's lesson was that one shared channel lets a hint of
one class silence a different class that had never been shown. Plugin version
bumped: a hook change clients cannot see did not ship (#1040).

The stub is autouse in conftest rather than added to forty-odd call sites: the
arm loads an embedding model, and every existing test that stubs the NOTES
search would otherwise pull a real model in through the one arm it had no way
to know about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:35:36 -04:00
bvandeusenandClaude Opus 5 4585cda3ff fix(rules): import or_ in rulebooks — the tier gate used it unimported (#3031)
CI & Build / Python lint (push) Successful in 7s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 24s
NameError at runtime: the tier/area clause and co_surfaced_partners both build
an or_(), which rulebooks.py never imported. py_compile passes on this (it is
a name error, not a syntax error) and so did the whole unit suite.

Where it surfaced is the useful part. The unit tests mock the session, so the
project-areas query returns empty, `reachable` stays None, and the or_ branch
is never taken — they exercised the path that avoids the bug. Only the
integration lane, with a real project and real rows, went down the branch that
needed the name. Six integration tests caught it, including the inception one
that merely calls get_applicable_rules in passing.

A reminder about which lane proves what: mocking the thing that selects the
branch means the branch is untested by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:13:40 -04:00
bvandeusenandClaude Opus 5 cd9aa87aa4 feat(rules): tier 1 preloads, tier 2 arrives by area — and a split rule can no longer be read half-way (#3031, milestone 307 step 5)
CI & Build / Python lint (push) Failing after 6s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Failing after 26s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Skipped
The payoff step: a rule stops having to be resident to be honoured.

- list_always_on_rules returns the ALWAYS-ON tier only. It is the session-start
  call, made before any project is in scope, so there is no area vocabulary to
  match a conditional rule against yet.
- get_applicable_rules carries a conditional rule when the project works in an
  area the rule is tagged to — resolved through systems.canonical_id, so the
  project's own NAME for the area is irrelevant, which is the entire reason the
  catalog exists. The gate is applied IN SQL, so `limit` counts rules that will
  actually surface rather than rules about to be dropped.
- Bindingness is a deterministic TAG match, never a similarity score (D7). The
  vector channel stays a suggestion, in search.

co_surfaced_partners is the fix that rule 144 never had. It was split off rule
46 and folded back the same day because "either rule could surface without the
other and miss exposing a project to what the entire shape is intended to be" —
correct, and merging was the only remedy available. Now a partner ARRIVES with
its other half even when nothing else selected it, tagged `via: co_surfaces` so
the payload says why. Two limits, both deliberate: only rules the caller owns,
because an edge is not a back door into someone else's rulebook; and a
project's suppressions are passed as exclusions, because an explicit mute is a
decision and an edge does not outrank it.

COMPATIBILITY, asserted first in the integration test rather than reasoned
about: a rule with no tier, no areas and no edges binds exactly as it did
before any of this existed. `tier` defaults to always_on, so an install
upgrades and every rule it already had keeps arriving. Getting that backwards
would silently stop enforcing rules people rely on, which is worse than any
amount of payload bloat.

Four unit tests were coupled to the ORDER of a mocked session's execute()
calls, so a new query broke them. Rather than pad the sequence and deepen that
coupling, the three post-query lookups are stubbed by name — they have their
own coverage, and the real wiring is proven against Postgres.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:10:41 -04:00
bvandeusenandClaude Opus 5 6ada97bb0b fix(rules): resolve the Rule forward ref for ruff and readers (#3030)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 44s
CI & Build / Python tests (push) Successful in 1m11s
CI & Build / Build & push image (push) Successful in 23s
F821: the quoted `"Rule"` in semantic_search_rules' return annotation
evaluates fine at runtime — a string inside a subscript is a value, not a name
lookup — but it points at nothing a reader or a type checker can follow, which
is what ruff is objecting to and it is right to.

A TYPE_CHECKING import resolves it at zero runtime cost, while the real import
stays inside the function so this module still does not pull in the rulebook
models. Placed after the import block, where isort wants the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:03:40 -04:00
bvandeusenandClaude Opus 5 95a37318fc feat(rules): rules become findable by meaning (#3030, milestone 307 step 4)
CI & Build / Python lint (push) Failing after 9s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 44s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Skipped
Rules were the only major record type with no vector, so `search` could never
return one and a rule could arrive only by being preloaded. That single fact is
what made every rule compete for one always-on budget.

THE DECISION THE TASK ASKED FOR, made explicitly: a sibling rule_embeddings
table, not a polymorphic embedding row. The ROW could have been generalised;
the SEARCH could not. semantic_search_notes is Note-specific scoping end to end
— the visibility clause, the supersession penalty, note_type/task_kind/system
filters — and a rule shares none of it, scoping instead by rulebook ownership
or project. Generalising the row while still needing two searches is the worst
of both: a key with referential integrity to neither table, on the path every
session start runs, to share four columns. What is genuinely common is
BEHAVIOUR — get_embedding, chunk_document, embedding_text, CHUNKER_VERSION —
and those are reused as-is. Sharing them is the DRY win; sharing the table
would have been the DRY costume.

The document shape is measured, not chosen (note 2485). That pass found the
snippet was the only discriminative record in the corpus — a 0.153
top-to-second gap against 0.010-0.023 — and that the cause was its SHAPE:
purpose stated twice in a short single-topic document. rule_document
reproduces it: the trigger in the title AND as the body's first line.

And it excludes `why`, which matters more than any of it. `why` is dated
incident narrative — rule 46's runs to 4,300 characters — and long multi-topic
prose is exactly what made sixteen dev-logs mutually indistinguishable. Adding
it would not give the vector more to work with; it would give every rule the
SAME thing to work with. rule_document takes no `why` parameter at all, so a
well-meaning caller cannot pass one.

A rule with no trigger degrades to title + statement — findable, less sharp.
That is an argument for backfilling triggers (step 6), not for padding the
document with whatever text is nearby.

search(content_type="rule") returns the rule WITH its why and how_to_apply:
they are its operational half, the session payload never carries them, and a
caller who went looking should not have to re-fetch. Writes re-index
fire-and-forget like notes; startup backfills in its own try block so neither
backfill can skip the other. rule_embeddings is derived, so it joins
note_embeddings in the backup's explicitly-NOT-included list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:00:49 -04:00
bvandeusenandClaude Opus 5 682bea5257 fix(rules): the third row literal — fetchRules builds a list row too (#3029)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 40s
vue-tsc caught what I missed: there were THREE places hand-building a rule
list row, not two. fetchRules mapped full rules down to the same four fields
in a spot far from the other two, so consolidating the pair I could see left
this one behind — which is precisely how the server side ended up with three
divergent trim dicts in the first place.

All three now go through toHeader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:26:20 -04:00
bvandeusenandClaude Opus 5 8b60d552d2 feat(rules): the rule editor asks when it applies, and the list shows its age (#3029, milestone 307 step 3, UI)
CI & Build / Python lint (push) Successful in 6s
CI & Build / integration (push) Successful in 38s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped
Rule 27 — the schema and both doors shipped with no human surface, so step 3
was not shippable until this.

RuleEditorSlideOver gains the trigger, the tier, the areas, and a read-only
view of the rule's edges. The tier is a radio pair carrying the test itself
rather than a bare toggle: can you name the trigger WITHOUT naming a system,
an artifact type or a moment? If the honest answer is "whenever you are
working", it is always on. It also says why conditional is not a demotion —
it costs nothing when irrelevant, which is what lets a rule be as long as it
needs to be. The relations block states the rule the whole milestone turns on:
rules that FAIL TOGETHER are linked, never merged.

RuleListPane shows the trigger and the LAST-CHANGED DATE on every row, and
marks conditional only — always_on is the default and badging every row would
say nothing. The date is the cheap triage the FabledCurator case wanted: a
rule whose age predates the capability it duplicates is visible at a glance
instead of needing a get_rule to find out.

ProjectRulesTab's inline create form gains the same two fields, because a
project rule bloats exactly the way a family one does — FabledCurator has 23
of them.

Two type fixes the new shapes forced, both worth keeping:
- toHeader() in the store: a list row is the server's rule_brief, so patching
  a list locally has to mirror every field it carries or the two disagree.
  There were two hand-built four-field literals doing that job.
- ApplicableRules.rules / .project_rules are now described AS RuleHeader
  rather than as two more hand-written shapes — the same builder produces
  them, so the same type should describe them.

groupByRulebookAndTopic skips a null-topic rule rather than widening
TopicGroup to accept one: a rule carries topic_id XOR project_id, so a null
topic in that list means something is wrong upstream, and a widened type would
hide it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:23:40 -04:00
bvandeusenandClaude Opus 5 ffb7a0fe38 feat(rules): both doors carry the trigger, the tier, the areas and the edges (#3029, milestone 307 step 3, surfaces)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
MCP and REST both gain when_to_apply / tier / system_ids / arose_from_id on
create and update, plus relate_rules / unrelate_rules for the typed edges, and
get_rule now returns a rule's areas and relations alongside it.

rule_detail() is a SERVICE function, not one per door. It started as a copy in
each — identical, and the prior-art hook flagged it immediately, which is the
same lesson rules_payload (#2858) already recorded: a second copy drifts. Both
doors call the one seam, so create, update and get cannot disagree about what a
rule looks like coming back.

The authoring guidance lands in create_rule's docstring rather than in a rule,
per rule 119 as the operator described it: this is behaviour every instance
should inherit, not one operator's preference. It states the test —

  ONE RULE = ONE THING YOU COULD VIOLATE. Rules that FAIL TOGETHER get linked
  with relate_rules(kind="co_surfaces"), never merged into one row.

— and names why merging loses: a merged rule cannot be cited, surfaced or
suppressed a clause at a time, and it grows without limit because adding to it
is always cheaper than adding a rule. create_project_rule says the same about
"overrides", which is what FabledCurator's 85/86 should have been instead of
near-copies that drift from their parent.

The tier arg carries the test itself: can you name the trigger WITHOUT naming a
system, an artifact type or a moment? If the honest answer is "whenever you are
working", it is always_on.

Tests: the applicable-rules cases fabricated raw tuples matching the old column
lists, so they move to the entity shape via fake_rule; new cases pin rule_brief
(a DATE not a stamp, the depth left to get_rule, no null keys) and that an
unknown tier falls back to BINDING. fake_rule gains when_to_apply / tier /
arose_from_id for the note-2109 reason the helper exists: unnamed, they would
be truthy MagicMocks. The tool tests stub the new rule_detail seam — they are
about argument forwarding and have no database.

The module header's "Sixteen tools" had been wrong for two milestones; the
registration count test is what actually catches that, so the header now says
so instead of carrying a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:17:41 -04:00
bvandeusenandClaude Opus 5 6ddb8bf859 feat(rules): a rule can say when it applies, which area it is about, and what it belongs with (#3029, milestone 307 step 3, schema)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 47s
CI & Build / Build & push image (push) Skipped
A rule could not state its trigger, its area, or its siblings, so all three
were being written as prose instead: a System's charter restating rule text,
a `why` naming the note that caused it, and two halves of one shape merged
into a single row because either could surface without the other.

Migration 0088 adds the four fields those workarounds stood in for:

- `when_to_apply` — the trigger. Nullable in the DB and required at the
  service layer: existing rules have none and a migration cannot invent one.
- `tier` — always_on | conditional, defaulting to always_on. This migration
  therefore changes NOTHING about which rules bind; an install upgrades and
  every rule keeps arriving exactly as before. Getting that backwards is the
  one failure this milestone exists to prevent, so _valid_tier falls back to
  always_on rather than silently un-binding a rule with a typo'd tier.
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
  already have. SET NULL: trashing the source does not repeal the rule.
- `rule_systems` / `rule_relations` — the canon tag and the typed edges
  (co_surfaces / overrides / elaborates), each earned from a workaround its
  absence forced.

rule_brief() replaces the THREE hand-written trim dicts that had already
diverged — two carried topic_id, one didn't, and none carried the timestamps
the model has held all along. That omission is why a rule written before the
capability it duplicates was indistinguishable at read time from one still
doing work. It now carries updated_at as a DATE: the question is "how old is
this", and a full stamp across the always-on set is ~2k characters for
precision nobody reads. The two callers select the ENTITY rather than a column
list, so rule_brief stays the single place deciding what a surfaced rule says.

Backup: both new tables carried, area tags by canonical SLUG (ids are
per-install). The rule-relation restore runs after ALL rules exist and after
the catalog, because an edge names two rules and a tag names a global row —
sections renumbered so the file reads in dependency order. A pre-0088 payload
restores with tier=always_on, i.e. binding exactly as when it was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:11:32 -04:00
bvandeusenandClaude Opus 5 67874268bb test(systems): move the name-gate cases to the service the gate moved into (#3028)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 35s
Two tests further down test_mcp_tool_systems.py still drove the gate through
the tool — `svc.list_systems` stubbed, the tool doing the normalising — so the
new `await systems_svc.assess_system_name(...)` hit an unstubbed MagicMock.

Stubbing them at the tool would have kept testing the wrong layer. The
normalisation cases belong with the logic, so they move to
tests/test_services_systems.py as real coverage of assess_system_name: case and
whitespace folding, exact-beats-overlap (and that an exact hit short-circuits
the lesser lookup), no invented match, fail-open on both arms, and silence for a
nameless system.

What stays the tool's job — rendering a duplicate, applying an exact area,
offering an overlap — is already covered at the top of that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:02:09 -04:00
bvandeusenandClaude Opus 5 c58529718b feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
Step 1 found the reason the standard names never held, and it is sharper than
"prose doesn't fire": the list WAS real and it WAS seeded — but only on the
inception path, for a project with zero Systems. Ad-hoc create_system never
consulted it, which is how Forge minted "CI and Release" and Portal minted
"CI & release" after the constant already existed. This wires the vocabulary to
the moment that mints a name.

- services/systems.assess_system_name: the local duplicate gate AND the
  catalog lookup, in ONE service function both doors call. The gate lived only
  in the MCP tool, which is exactly how the web UI shipped without a check the
  agent surface enforced (#2482). REST now answers 409 with the System that
  already covers the area.
- An `exact` catalog hit is APPLIED (mechanical — the names differ only in
  spelling). An `overlap` is only OFFERED, on both doors: applying a judgment
  call silently is how a cross-project rule surfaces in the wrong project.
- canonical_systems.best_overlap is the ONE scorer behind the create-time
  offer and the review sweep, so the two surfaces can never name different
  areas for one System. It also takes the catalog the caller already holds,
  so the review is not an N+1.

UI (folded in from step 1 — rule 27, that step shipped with no human surface):
- SystemsSection: a Shared area picker on create and edit, the area on each
  card, and a collapsed review of proposals that appears only when there is
  something to decide. `exact` and `overlap` never share a style — one is
  mechanical, the other is the reviewer's judgment, and presenting them alike
  is how a wrong mapping gets waved through.
- Settings → Admin → Areas: the catalog itself, showing each entry's slug,
  because the slug is what decides whether two names are the same area and a
  rename moves it.
- A picker rather than a live matcher: reproducing the slug rule in TypeScript
  would give this feature two matchers to keep in step — the exact drift the
  catalog exists to end. The server stays authoritative.

tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an
auto-MagicMock and therefore truthy, which is the trap that helper exists for
(note 2109) and a nullable FK walks straight into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:58:30 -04:00
bvandeusenandClaude Opus 5 879ef3053e fix(systems): the bootstrap ask reads the catalog, so its test must supply one (#3027)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m20s
CI & Build / Build & push image (push) Successful in 38s
CI caught the seam the promotion opened: the standard names now come from an
async catalog read, and the unit test patches systems_svc wholesale — so the
read raised, the fail-open swallowed it, and the ask shipped without the names
it is supposed to carry.

Stub standard_systems in that test, and assert the wiring rather than the
vocabulary: THAT the seeded set is these eight is migration 0087's business and
belongs in the inception integration test, against a real database.

Adds the case the promotion actually created — an unreachable or empty catalog
must still produce the ask. The names are an aid to the question, not the
question; degrading to a weaker nudge is fine, going silent is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:34:29 -04:00
bvandeusenandClaude Opus 5 a97547fbc6 feat(systems): the area vocabulary becomes a global table so a rule can point at one (#3027, milestone 307 step 1)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).

- canonical_systems: global, no user_id — a shared project inherits the
  vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
  renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
  mechanically and a real difference ("CI & runners") becomes a proposal a
  human confirms. propose_mappings reports; set_system_canonical is the only
  writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
  project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
  extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
  target's own rows and only creates entries an admin added on the source; an
  unknown slug restores unmapped rather than failing.

Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:31:18 -04:00
bvandeusen 6b1f5e8031 Merge pull request 'Three fixes where the capability already existed and only the door was missing (#2782, #2975, #2278)' (#130) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 16s
2026-08-24 07:56:44 -04:00
bvandeusenandClaude Fable 5 a8f35e465e refactor(plugin+tests): the last two parallel-family gaps — pageable list tools, one config preamble (#2278)
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Python tests (push) Successful in 2m21s
CI & Build / Plugin hooks (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Build & push image (push) Successful in 25s
DRY pass 3's remainder. Both halves start from enumeration, because the task's
candidate list was hypotheses and the process requires counting before
proposing — and counting changed the answer twice.

## The list_* family: a limit with no offset

Enumerated all 19 `list_*` MCP tools first. They are genuinely heterogeneous —
8 take `project_id`, 6 take `limit`, six take no arguments at all — so a
common-parameter guard would invent a convention the API does not have, which
is the over-DRY trap (§5). One contract IS real: a `limit` without an `offset`
is a truncation with no continuation. The caller is told there are 250 results,
handed 50, and given no way to ask for the rest.

Two tools had it, and both were capped over a service that already accepted an
offset: `snippets_svc.list_snippets(offset=0)` was simply not exposed, and
`list_processes` passed a hardcoded `offset=0` into `query_knowledge`. The
capability existed one layer down in both; only the door was missing — the
missing-sibling shape exactly. Both now expose it.

`tests/test_mcp_list_family.py` guards it, with `list_tags` exempted for a
stated reason (a ranked top-N over a bounded vocabulary has no "rest" to page
into). Candidates derived, decision explicit, same design as test_mcp_auth —
plus the reverse checks: a stale exemption, and an offset with no limit, which
would page through an unbounded result set. Verified non-vacuous by running the
sweep against the pre-fix tree, where it fails naming both tools.

## The verb pairs: no finding, which is the finding

`preview`/`apply` and `dry_run`/`commit` do not exist anywhere in the 102
tools — those were guesses about a shape Scribe never adopted. `count_*` does
not exist either. Of the create/delete stems only `project_rule` lacks a
`delete_X`, and deliberately: a project rule IS a rule, `delete_rule` removes
it, and the docstring says so. `force` sits on 6 of 7 duplicate-gated creates;
the exception is `create_system`, whose gate is an exact normalized-NAME match
rather than a semantic near-match — forcing it would split one area's records
across two piles, which its own message explains. No guard added: it would
need a seven-entry exemption list to defend against a hypothetical. Recorded
on the leave-alone list instead, which the process asks for by name.

## The hook config preamble

Not 3 of 6 hooks as recorded — all FIVE carried their own copy, and of four
lines rather than two. The extra two are a guard treating an unexpanded
`${...}` placeholder as unset, so it is never sent as a garbage Bearer token:
precisely the correctness detail a sixth hook would omit with nothing failing
loudly. Now `scribe_config` in scribe_defs.sh, which also declares the two
names it owns. It sets globals rather than echoing, so a token never passes
through a subshell's output where xtrace or a log could catch it, and returns
a status so a caller can bail (`|| exit 0`) or continue degraded — the
session-context hook still owes its static floor when Scribe is unconfigured.

`check_plugin.py` now runs shellcheck with `-x`. Without it the shared helpers
were invisible: every variable they set read as unassigned and every bug inside
them went unlinted at the call site, which is the opposite of what sharing them
was for. All twelve fail-open scenarios still pass, and all five hooks were
probed live against the instance — prior_art and after_write both still name
canon, autoinject returns context, session_context serves 11k chars of rules,
sync_processes stays silent. Plugin 0.1.46 (#2209).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 01:28:07 -04:00
bvandeusenandClaude Fable 5 64bfa5725f feat(telemetry): a read surface over retrieval_logs — the tuning loop had no read half (#2975)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 20s
`retrieval_logs` was write-only. `record_retrieval` inserted rows and nothing
in the tree ever selected from them: the only `select()` over RetrievalLog
lived in a test. So #1038's gate — "build the reranker once telemetry shows
precision is the bottleneck" — was unsatisfiable by construction, and the one
real tuning decision on record (the 0.68 write-path threshold, #2223) had to
be reached by hand-probing the live instance with eight payloads. This adds
the half that was missing.

`retrieval_summary(user_id, days=30)` returns two aggregates side by side,
each read from the table built for it — NOT a join. NoteUsageEvent's docstring
is explicit that the two are complements ("RetrievalLog tunes the threshold,
this tunes the corpus") and that RetrievalLog's JSONB `result_ids` cannot be
indexed at the per-note grain, so correlating through it would be both slower
and less honest than reading each source directly. That corrects the approach
sketched on the task.

  - `sources`, per surface: calls, zero_result_calls, cleared_threshold (how
    often the best hit beat the threshold in force for THAT call), the
    top_score spread as p10/p50/p90/min/max, avg_result_count, p90 duration.
    Zero-result calls are counted apart from low-scoring ones — they are a
    different failure and averaging them together would hide both.
  - `usage`, from note_usage_events: ranked surfacings, ambient surfacings,
    and pulls split into `pulled_by_agent` / `pulled_by_human`.

That split is not decoration. NoteUsageEvent's own comment says the mcp_/rest_
prefix is load-bearing and names #1038 while saying so: "is this dead weight?"
is answered by any pull, "was that injected line useful?" only by an agent
pull. `pull_through` exists to answer the second, so it counts agent pulls
over ranked surfacings; both halves ship so the first stays answerable.

Two things the code made me get right rather than guess:

  - Distinct-note counts get their own queries. `count(distinct note_id)` per
    (event, source) group cannot be summed across groups — a note surfaced by
    two sources is one distinct note and would be counted twice. A wrong
    number labelled "distinct" is worse than no number.
  - No CASE in the GROUP BY. #2663 is the bug where a second case() rendered
    its own expanding bind names, Postgres rejected the query, a broad except
    swallowed it, and every counter read zero in production while mocked tests
    passed. Grouping on raw `source` and classifying in Python cannot fail
    that way. For the same reason the readout distinguishes `read_failed` from
    an empty window, and its tests are integration against real Postgres —
    percentile_cont ... WITHIN GROUP only proves it parses against a database.

Exposed as the `retrieval_telemetry` MCP tool, added to `_READ_ONLY_TOOLS`:
it mutates nothing, but its name carries no read prefix, so the completeness
test cannot derive it and it would otherwise have failed closed for read-only
keys in silence — the same reason `enter_project` is spelled out there. Docs
updated to name both exceptions rather than leave the rule looking derivable.

Scoped to the caller's own telemetry: a retrieval log records what one user's
agent asked for, query text included, and is not a shared record kind — the
owner filter is the whole access rule, not a shortcut past access.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 22:48:54 -04:00
bvandeusenandClaude Fable 5 446d6da0d7 fix(snippets): an annotated record is not a diverged one — a standing verdict vouches at its commit (#2782)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 25s
Pull-time freshness confirms a cached body by containment: normalised cached
code must appear inside the fetched file. That is right for a record kept
verbatim and permanently wrong for a deliberately annotated one. A record
whose job is to say WHY the shape is what it is carries commentary the source
does not, so containment fails on every pull, forever — #2508 was reading
`diverged` although its declarations match the source exactly, and always
would. Annotation is a sanctioned record style, so this was two deliberate
designs colliding, and it was quietly poisoning the one honest drift signal:
decision #2707's scoreboard watches body_freshness for `diverged` and was
accruing false positives it could never age out.

The escape hatch is the verdict itself. verify_snippet is exactly where a
human or agent already judged this body a faithful rendering of that source,
and `verification.commit_sha` records the repo commit they judged it at — a
field whose own docstring (#2688) anticipated this: "makes 'the REPO moved on
since the check' computable, once the forge integration can compare it against
the current head." This is that comparison. When containment fails, a standing
verdict can still vouch, on four conditions and no fewer:

  - the verdict says `ok`;
  - it has not EXPIRED — verification_view recomputes code_sha against the
    record's current body, so editing the record retires the verdict;
  - it was not INVALIDATED by a push touching the location (#2691);
  - the file just fetched is at the very commit the verdict was stamped at.

That last one is what keeps it honest: the hatch vouches for a body against
ONE known commit, never against whatever the source became since. The moment
the file moves, containment resumes as the authority and the record reads
`diverged` until someone re-verifies — correct, because at that point nobody
has looked. The first three are checked by reusing verification_view rather
than restating its rule, so "expired" keeps meaning one thing.

Nothing is rewritten and no new freshness value is minted; `verification`
already travels in the same payload, so a reader can see the basis rather
than take "current" on faith. Verdicts predating commit stamping carry no
commit to compare and therefore do not vouch — they fall through to
containment rather than passing on age alone.

Tests pin the fix and, more usefully, every condition that switches it back
off: moved commit, expired verdict, push-invalidated verdict, non-ok verdict,
no verdict, and a pre-#2688 verdict with no commit_sha. Plus a regression
that a verbatim record still takes the containment path untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 22:36:04 -04:00
bvandeusen 136dbc16a6 Merge pull request 'Dead CSS deleted, and the instrument that found it sharpened (#2962, #2970)' (#129) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 14s
2026-08-23 21:34:17 -04:00
bvandeusenandClaude Fable 5 6871c25445 docs(frontend): the task-body comment no longer points at a class that was deleted (#2962)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 35s
`.editor-body` was removed from editor-shared.css in c5faaf3 — nothing used
it once TaskEditorView replaced it — so "Replace .editor-body for task
editor" now names something a reader cannot find. Say what .task-body is and
record that its predecessor is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:32:11 -04:00
bvandeusenandClaude Fable 5 57f6982f56 test(ledger): the transition/prefix references clear the flag end to end; plugin 0.1.45 (#2970)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 14s
Integration cover for the seam the unit tests only meet at either end: a
template that names no class at all (`<transition-group name="toast">`) and
one that builds its class (`` `status-${s}` ``) run through the real
extractor, through resolve_consumers, into `flag="unused-css"` — and the
rules they reach stop being reported, while a rule nothing can reach stays
listed. The prefix credits BOTH status rows, which is the documented
reading: the template does not say which one it built.

The manifest bump is what the last commit owed — it edited the
shape-accounting skill, and the installer compares versions to decide
whether to refresh the cache that actually executes (#2209), so plugin
edits that ship without a bump reach the repo and stop there. CI caught it
on 4323; 0.1.44 -> 0.1.45.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:30:21 -04:00
bvandeusenandClaude Fable 5 df18e897af feat(ledger): the consumer map reads transition names and concatenated prefixes (#2970)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 23s
Paying down #2962 measured `flag="unused-css"` against a hand audit and
found it had a permanent false-positive floor: roughly sixty classes it
called unused are alive and always would be, because two ordinary authoring
forms produce names no reader of `class=` attributes can see. A flag whose
list you cannot act on line by line is worse than no flag — act on it and
you delete live UI.

- A transition `name=` IS a class reference. `<Transition name="toast">`
  makes Vue apply `.toast-enter-active` and its siblings at runtime, and
  React's `<CSSTransition classNames="fade">` does the same with a different
  suffix set. Every spelling of the tag is read (`Transition`,
  `TransitionGroup`, `transition-group`), and the emitted suffix set is the
  union of Vue 3, Vue 2 and React: naming a class no rule defines costs
  nothing, since it resolves to no row. A bound `:name` stays unknowable.
- A concatenated name contributes its static head as a PREFIX reference.
  `` `status-${s}` ``, `'pri-' + p` and `class="card-{{ v }}"` all leave a
  head behind once the hole is blanked — and `_CLASS_TOKEN_RE` accepts a
  trailing hyphen, so until now the extractor emitted a junk token
  `"status-"` that matched nothing. It is now `status-*`, and
  resolve_consumers credits every row whose symbol starts with that head,
  each under the same own-file-else-fan-out rule as an exact token. `*`
  cannot occur in a class token, so the marker rides the existing
  dict[str, int] with no schema change. A head shorter than two characters
  says nothing and is dropped.

Crediting every candidate row is the honest reading: the template genuinely
does not say which one it built, and the alternative is reporting live rules
as dead. What the map still cannot see is a name assembled in a script —
`classList.add` — which stays deliberately out of scope; the skill, the
`flag=` docstring and the refresh payload docs all say so now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:27:27 -04:00
bvandeusenandClaude Fable 5 4179f3e560 refactor(frontend): the second sweep — scoped rules that no longer match their own template (#2962)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
The first sweep asked "does this class token appear anywhere outside a
<style> block?". That is too generous for a `<style scoped>` rule, which can
only ever match its own template, the component's root element, or whatever
`:deep()` reaches — so a scoped rule whose name lives only in some OTHER file
is dead regardless. The server's map had this right and my local pass did
not; this closes the gap.

79 scoped classes are absent from their own file. 48 are names their own file
BUILDS — `status-${task.status}`, `pri-${p}`, `toast--${type}`,
`perm-${permission}`, `diff-${op}`, `is-${kind}` — and 12 more are Vue
transition classes. Those are the map's documented blind spot and they stay.
The remaining 19 match nothing:

- AppHeader (9, -39 lines): the whole connection-status indicator —
  .status-indicator/.status-dot/.status-text and the five colour states,
  plus @keyframes pulse-dot and status-pulse, which had no other user. Same
  Phase 7 residue as the last commit. `.btn-icon.active` goes too: `active`
  is never applied in this component (its nav uses router-link-active).
- SettingsView (6): .status-badge/.status-on/.status-off,
  .perm-granted/.perm-denied, .location-row. This view renders no child
  components at all, so nothing can inherit its scope.
- WorkspaceNoteEditor: .note-row:hover .btn-delete and .btn-suggest-tags —
  those buttons are .btn-danger-outline/.btn-ghost now.
- ProjectListView .loading-msg; SnippetEditorView .field-row.three, whose
  media-query companion keeps its live .field-row half.

Checked against child-component roots before deleting, since a child's root
element does inherit the parent's scope id — none of the 19 is one. Template
and script regions byte-identical; both style checks still pass with only
the six pre-existing reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:21:43 -04:00
bvandeusenandClaude Fable 5 c5faaf38fb refactor(frontend): delete the dead CSS the consumer map surfaced — 102 classes, 588 lines (#2962)
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / Build & push image (push) Successful in 36s
Milestone 302's map flagged 195 css rows no template names. Re-derived the
list locally with a stricter test than the map's — a class is dead only if
its token appears NOWHERE outside a <style> block, in markup or script, and
is not the static half of a concatenated name (`priority-${p}`) — which
takes the map's known blind spot off the table. 123 survived that; 21 of
those are Vue <Transition>/<transition-group> classes generated at runtime
from a name= attribute (detail-fade, peek-slide, shortcuts-fade, tab-fade,
toast) and one is ProseMirror's vendor class. Those stay. The other 102 go.

- SettingsView.vue (80, -417 lines): whole features whose UI was removed —
  Ollama model management (model-*, pull-bar-*, suggestion-chip), push
  notifications (push-*), the voice library and voice blending (voice-*,
  blend-*), geo status (geo-*), MCP package rows, retention, the learned
  summary, and the form furniture that served them.
- editor-shared.css (16, -151): the standalone assist panel, superseded by
  the sidebar assist section — streaming now renders as .stream-preview in
  the main area, so .assist-panel*, .assist-sections*, .assist-preview-box
  and .typing-indicator have no markup left. @keyframes blink went with the
  last rule that animated it. .editor-body/.editor-main are dead too:
  TaskEditorView replaced them with .task-body/.task-main.
- theme.css: .btn-new-conv/.btn-send dropped from the touch-target list
  (names from another app), and the .hide-desktop utility no one used. The
  generated --fs-* token block is untouched.
- ShareDialog .user-result-email, KnowledgeView .today-link,
  ProjectView .edit-input (the remainder rule keeps its rationale comment
  and its two live selectors).

A selector dies only when every class in its descendant chain is checked —
`.live .dead` matches nothing either — and a rule only when its whole comma
list is dead, so mixed lists keep their live half. Template and script
regions are byte-identical; check_dangling_styles.py and
check_design_tokens.py both pass, with only the six pre-existing reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:16:06 -04:00
bvandeusen 649fdff2ea Merge pull request 'The CSS pay-down: 72 name families derived or dismissed with the consumer map in hand (milestone 302 step 4)' (#128) from dev into main
CI & Build / Python lint (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 46s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m10s
2026-08-23 21:03:33 -04:00
bvandeusenandClaude Fable 5 9190fa0f10 refactor(frontend): the pay-down edits the first script dropped after its assertion stop — SharedWithMe/ProjectView page-header+empty-msg remainders, DesignSystems/Settings field-hint+.input trims, pane form-buttons deletions (milestone 302 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 35s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:34:07 -04:00
bvandeusenandClaude Fable 5 6fb0cb38a5 refactor(frontend): CSS pay-down, derive batch (milestone 302 step 4) — page-header, error-msg/state-msg/empty-msg, empty-title/empty-sub, required, field-hint recipes into components.css; form-buttons into rules-shared.css; scoped copies trimmed to remainders/overrides; the three views' .input becomes fs-input + width/box-sizing remainder (canon #2336)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:30:43 -04:00
bvandeusen cba542a3ec Merge pull request 'Hooks say when Scribe didn't answer; the CSS consumer map (milestone 302 steps 1–3)' (#127) from dev into main
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 16s
2026-08-23 14:18:32 -04:00
bvandeusenandClaude Fable 5 c28c87c39e test(ledger): the consumer-map integration test owns its unreferenced rule — the seeded button.css row vanishes under the test's own tree (#2936)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 15s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 14:09:04 -04:00
bvandeusenandClaude Fable 5 8664d8ad14 feat(ledger): the consumer map surfaces — every css row carries used_by on list_shapes, flag="unused-css" (the map's negative space, surfaced never deleted), derive groups and the write-path family carry consumers, the derive line says "used by N template(s)", coverage payload unused_css + the standing block; docs, SKILL, plugin 0.1.44; the two step-2 tests expected 5 edges where fan-out makes 6 (milestone 302 step 3, #2936)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / integration (push) Failing after 44s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Successful in 31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 14:03:35 -04:00
bvandeusenandClaude Fable 5 ffbdf19116 feat(ledger): the CSS consumer map — code_shape_consumers edges (shape → file whose markup names the class, count), migration 0086, resolve_consumers (own-file row when the template defines the class, else every other definition), sync_repo_consumers rebuilt from the archive on every refresh, consumers_of; derived, so not backed up (milestone 302 step 2, #2935)
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 52s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:59:55 -04:00
bvandeusenandClaude Fable 5 dffbf43d84 feat(coverage): class_references + scan_archive — each template-bearing file's class tokens (static class=/className=, Vue :class object/array/ternary, React className={…}, Svelte class:x) read in the same tar walk as definitions; the CSS consumer map's extractor (milestone 302 step 1, #2934)
CI & Build / Plugin hooks (push) Failing after 1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:57:13 -04:00
bvandeusenandClaude Fable 5 31383bcebe test(hooks): the two tests that read a refused connection as "nothing recorded" now answer through a sink, and the silence case removes the shape it had left behind (#2932)
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 16s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:05:42 -04:00
bvandeusenandClaude Fable 5 0ab94b2a00 fix(plugin): write-path hooks say when Scribe did not answer — once per outage, shared marker, record nudge withheld on an unanswered call; check_plugin allows exactly that line when unreachable; plugin 0.1.43 (#2932)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Failing after 49s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:59:54 -04:00
bvandeusenandClaude Fable 5 9c00a4b6e1 fix(plugin): after-write hook waits 8s on the prior-art call — a cold-start round-trip (~4.6s after a redeploy) failed open at 4s and dropped the ledger line on the first write; plugin 0.1.42
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:06:08 -04:00
bvandeusen 144192754c Merge pull request 'Derive pay-down + CSS families by name (milestone 299 steps 5–6, note #2917)' (#126) from dev into main
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 14s
2026-08-23 00:32:07 -04:00
bvandeusenandClaude Fable 5 85111442a6 feat(ledger): CSS derive families are names, never bodies — name floor 2 for css, dup: grouping sym-only; derive line says "repeated name" and dismisses scoped-css; plugin 0.1.41 (note #2917)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:21:17 -04:00
bvandeusenandClaude Fable 5 a2b377b74d chore(plugin): 0.1.40 — the extractor rule change in scribe_defs.sh ships to the executing cache (#2904)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 16s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:12:54 -04:00
bvandeusenandClaude Fable 5 590203a293 refactor(tests+frontend): one http_sink helper for the hook tests; apiErrorMessage replaces ten hand-rolled error-body parses; type X, import specifiers are not definitions (#2904, milestone 299 step 6)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 9s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m11s
CI & Build / Build & push image (push) Successful in 38s
tests/helpers.http_sink replaces three module-local _Sink handlers (the
write-path tests and the after-write test). ProjectView + SettingsView
parsed `(e as {body?:{error?}}).body?.error || fallback` by hand ten times
beside the apiErrorMessage canon (#2853) - all ten now call it. The
extractor (server + the hook awk mirror) no longer reads `import { type Foo }`
as a definition of Foo - that was the last "identical body" sym family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:07:23 -04:00
bvandeusenandClaude Fable 5 449f437048 refactor(frontend): the near-duplicate report rules shared via dup-report.css — KnowledgeView and SnippetListView carried identical scoped copies (#2903, milestone 299 step 5, part 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 41s
KnowledgeView's own comment asked for this promotion once a second view grew the
panel. The sheet carries .dup-panel / .dup-empty,.dup-head / .dup-group /
.dup-members / .dup-member(+:hover) / .dup-score; each view keeps only its
extras (.dup-claimed, .dup-action).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:03:12 -04:00
bvandeusenandClaude Fable 5 48f0630dab test(coverage): the two-declaration floor check compares two multi-line rules — formatting is part of the fingerprint, as before (#2903)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:59:38 -04:00
bvandeusenandClaude Fable 5 a72605de8f refactor(frontend): the last pay-down, part 1 — three dead views deleted, editor rules shared, .page-container + .fs-input canon, rules-shared.css; a one-declaration CSS body is not a shape (#2903, milestone 299 step 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
The derive queue said the biggest duplicate families were whole views:
TaskViewerView, UserManagementView and LogsView were imported nowhere — left
behind when tasks moved to the editor and users/logs became SettingsView
tabs. Deleted (rule 22). Note/TaskEditorView carried six identical scoped
rules -> editor-shared.css (the .tag-suggest-row gap the scoped copies
actually rendered wins). Three views wrapped the page under three names ->
.page-container in components.css. Three scoped input recipes -> the design
system fs-input recipe (snippet #2336), verbatim, in components.css; width/
box-sizing stay with the caller. The three rules panes share .pane and the
pane heading via rules-shared.css (the auth-shared pattern, #2852).

Ledger: a single-declaration CSS rule keeps its selector in its fingerprint,
so `color: var(--fs-text-tertiary)` under five different names is no longer
a five-file "identical body" family — the first pay-down found that most of
the 148 dup families were exactly this, and nobody would consolidate them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:55:28 -04:00
bvandeusen 4fa8158329 Merge pull request 'Self-surfacing DRY — duplicate families named at the write and on arrival, tool-agnostic (milestone 299 steps 1–4)' (#125) from dev into main
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 14s
2026-08-22 13:41:24 -04:00