029692945ee5720867f61ac368530565b221fe00
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
029692945e |
fix(hooks): the by-name duplicate arm confirms its grep hits against the real extractor (#4227)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 15s
#4222 fixed one end of this defect — the extractors that decide what a payload DEFINES now blank comment and string spans before any line matcher runs. This is the other end: `scribe_local_dups`, which decides which OTHER files already define that name, and which was still a plain `git grep`. A grep sees lines, not spans, so the sentence class with only modifier rules is a deletion that went half-way. — real prose from a module docstring in this repo — matched the arm's pattern for `name=with`. Writing a genuine `change`, `beside` or `wrapped` would be told it already existed, and pointed at a docstring. EVERY HIT IS NOW CONFIRMED by running `scribe_defs` over the candidate file and keeping only names it actually reports. That is the only check that cannot disagree with the other end of the pipe, which is the whole point. MEASURED, NOT ASSUMED — both numbers the task reasoned from turned out wrong. - WHAT IT REMOVES, across 141 payload files of this repo: 15 of 210 report lines. Every one a string literal, a comment, a TypeScript `import { type Foo }`, or Vue's `const emit = defineEmits()` boilerplate. No real definition was lost. Where a name had both — `create_note` — the phantom in a test's `shape_form("async def create_note(...)")` argument dropped out and the definition in services/notes.py stayed. `with` went from four files to none, which is the correct answer: nothing here defines it, and it is a keyword in several of these languages. - WHAT IT COSTS: mean 193ms -> 222ms, worst 569ms -> 572ms. The task feared "over a second added to a PreToolUse hook" from 48 confirmations. It is about 15%, because the arm was already dominated by its twelve `git grep` calls, and because confirmation runs once per DISTINCT candidate file rather than once per (name, file) pair. A deliberately pathological payload — nine names that are ordinary English words — reaches 28 distinct files and 947KB; `scribe_defs` runs at ~33ms per 250KB. THE CANDIDATE CAP IS RAISED FROM FOUR TO TWELVE, and that is load-bearing. Confirmation REMOVES hits, so capping before it runs lets phantom matches crowd a real definition out of the window — hits dropped before anyone looked at them, which is #4042's bug in a new place. The display cap stays at four and now applies to CONFIRMED hits, which is where a cap belongs. #4042's own `|| true` inside the substitution is untouched, and its regression case still passes. The task's own advice not to fix this by tightening the grep pattern is followed and written down: requiring `(` or `{` or `:` after the name rejects `class with only…` and also `class Foo extends Bar {`, `class Foo : Base()` and `type Foo struct {`. This arm exists because it works with no server, no index and no binding (#2280, #2682), which makes a miss here invisible — a visible false positive is the better failure. Guards: tests/test_hook_duplicate_confirmation.py, against real git repos because what is pinned is the interaction between `git grep`, `head` and the extractor. Seven of the eight fail against the previous implementation; the eighth is #4042's regression case, whose job is to keep passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
84476d7ecf |
fix(drafter): a wrapped docstring line beginning "class AND the" defines a shape called AND (#4222)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m18s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Canceled after 8s
The definition extractor is line-oriented and knows nothing about what a
line is INSIDE. A docstring that wraps onto a line starting with a keyword
announces a definition: `AND` reached a live session as a divergence prompt
asking it to justify a symbol that does not exist, and `is` reached it as a
repo-wide duplicate of four files that define nothing of the kind.
Measured, not assumed: running the extractor over every scannable file with
and without the scan differs by twenty-two phantoms. Two of them — `with`
and `nobody`, both out of the module docstring in check_dangling_styles.py —
are persisted `code_shapes` rows that have been judged. Those need no
migration: sync_shapes marks a row it no longer extracts as vanished.
`ast` would be the honest tool for .py and is not what this uses, because
the extractor is mirrored rule for rule by an awk program in the hook, awk
cannot parse Python, and a fix only one of the pair can run is the drift the
mirror exists to prevent. Both sides now run the same left-to-right scan and
blank comment and string spans to their own newlines before any matcher sees
a line. Three things the scan has to get right, each of which cost real
definitions while it was being written:
- a string that HOLDS a marker is not a marker. `"red /* "` in
test_design_stylesheet.py and a triple quote inside a single-quoted
regex in plugin_context.py each ate every definition below them.
- `#` is a colour in CSS and a comment in Python, and the extractor is
handed no path. An alphanumeric straight after it settles it.
- an unterminated opener blanks NOTHING. The scan rewinds past it and
continues, so a stray marker costs one span rather than the rest of the
file.
The comment claiming the two extractors agree has been the only thing
holding them together, and a comment cannot fail. The mirror test now RUNS
the hook's awk over the same vectors: with the old program it reports the
phantoms, which is what a guard that can fail looks like. Across all 631
scannable files in this repo the two now agree line for line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
36b54bff1f |
fix(plugin): the ledger that proves a rule was read was being deleted by the compaction that asked about it (#4217)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 16s
Milestone 419's acceptance, and it failed the first time it was run — which
is the only reason this commit exists.
THE MEASUREMENT. Ran the step-5 readout against this session's real traffic
instead of a fixture. It reported 19 rules named by an arm and none opened.
That is false: the session had called `get_rule` 45 times. Across six real
sessions on this instance: 208 opens, 3 surviving ledger entries. 1.4%.
THE CAUSE. `.opened.ids` was doing two jobs with opposite lifetimes.
- "this context HOLDS rule 156" — false after a compaction, and three hooks
read it to decide whether to stay quiet. Clearing it is correct.
- "rule 156 WAS OPENED" — which no compaction makes untrue, and which the
session-end readout is built on.
`scribe_clear_session_ledgers` sweeps every `<sid>*.ids` on SessionStart
source=compact. Right for the first claim, and it was deleting the second.
The TTL did the same thing more quietly: `scribe_rules_live` ages an
exclusion ledger, which is right, and would have eaten the early part of any
long session's evidence too.
So the readout was reporting only the stretch since the last compaction while
reading as though it had reported the session — a statistic that cannot vary
being mistaken for a finding (#3311), which is the shape this whole milestone
exists to stop producing. It ran AT the seam it was blind to.
THE SPLIT. `scribe_rules_append` now writes both: the exclusion ledger it
always wrote, and `<kind>.keep.ids`, an evidence twin that is never aged and
never swept. The readout reads twins; the three `held` readers are untouched.
DERIVED, NOT LISTED, because a list is what broke this before — the comment
above the sweep says so about its own history. Every ledger written through
the appender gets a twin, including the next one somebody adds; a new ledger
is born on the swept side unless its name opts out. `scribe_checkpoint_allowed`
writes its twin explicitly since it bypasses the appender, and there the split
lands right on both sides: the cap counts the swept file, so a compaction
honestly restores the budget to stop an act the context can no longer justify,
while the record that a stop happened stays.
Removed `scribe_ledger_ids`, orphaned by the change — a dead helper beside a
live one is a thing the next reader trusts.
test_session_ledger_clear.py asserted every ledger dies and could not have
caught this: its fixture never created a twin, so the sweep was one glob away
from either mistake with only one of them guarded. Both sides now asserted.
The slippage tests build their ledgers through the real writers rather than
by hand, for the same reason — a fixture that writes the bytes itself keeps
passing after the writer stops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
ae773740b4 |
feat(plugin): the seam that erases the evidence is where the unresolved rules get named (#4216)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 14s
Step 5 of milestone 419. The milestone's subject is that a rule read and ignored is arithmetically identical to a rule read and followed, and the compaction is where that identity becomes permanent — the turns holding the evidence are summarised away, and the unjudged thing survives as nothing. WHY THIS IS ASSEMBLED IN THE HOOK. `rule_usage_events` has no session column; it is per user over a window. A session-scoped answer therefore cannot be asked of the server, and has to be built where a session is a thing that exists. Four ledgers four hooks already write: .rules.ids an arm NAMED the rule .opened.ids the session called get_rule (#4100) .acted.ids the session called rule_outcome (new here) .checkpoint.ids the rule HELD an act (#4214) Every one is an observed tool call. Nothing asks the model what it followed — milestone 386 ruled that out, because a model asked "did you apply rule 156?" says yes. Two subtractions: named-minus-opened is the arm talking to nobody, opened-minus-acted is the milestone's whole subject. PreCompact stdout is the compaction's custom instructions (#3680), not a message to the model, so the readout does not say "you slipped" — it says which ids must be carried through, which is the one thing a summary can do about an unjudged finding. SILENT WHEN NOTHING HAPPENED, and the accusations are conditional on having members. "0 rules unresolved" on every compaction is how a readout teaches its reader to skip it. Traffic is still reported, because the static instructions already ask for it in prose; these lines are the measured version. scribe_record_outcome.sh is the third ledger's writer, matched on mcp__.*__rule_outcome and mirroring scribe_record_opened.sh: TMPDIR only, silent, exit 0 on every path. A PostToolUse hook that spoke would put a line after every rule_outcome call and give recording an outcome a cost. Also: check_plugin.py skipped the new hook for want of a smoke event, which would have left the newest of the three ledgers as the only one the plugin lane never runs. Added, mirroring its sibling. tests/test_precompact_hook.py now isolates TMPDIR — the hook reads session ledgers from there, so without isolation a test would see whatever this real session had accumulated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
fdfb2d94ac |
feat(plugin): you altered the shape of something — here is everything that reads it (#4215)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 16s
Milestone 419 step 4. Five of the milestone's seven misses were the same move: acting on the thing in hand without reading the contract around it. Lesson #4207 says so in words, and was written by its author hours before a structurally identical mistake, having been surfaced twice in the turns between. Text delivered at the moment of acting is too weak a carrier for a reflex that has to change what the act IS. This looks it up instead. Rule 33 one scope down: its checks are between layers, and the same question exists between a definition and its callers. THREE KINDS OF EXPOSED NAME, because a contract breaks three ways that look nothing alike in source — the defined symbol (a rename or removal), its parameter names (arity), and the quoted keys of its dict literals (the shape of what it returns). THE THIRD IS THE ONE A SIGNATURE-WATCHER MISSES, and it is in because of the miss that produced this step. Two commits ago `get_writepath_config` gained one dict key; three arms read that dict inside a fail-open `except`, every one silently became a no-op, and ten tests went red with nothing pointing at the cause. No signature changed. Run against that exact edit, the check now names tests/helpers.py — the actual root cause — among six files, before the write. TWO GATES, AND THE SECOND IS WHAT MAKES IT USABLE. A change to the exposed set is necessary but not sufficient: a definition nothing else references has no contract to break, so the readers lookup runs second and an empty result ends it silently. Body-only edits say nothing, a subject is named once per session, and the ledger lives in the swept directory under the `.ids` convention, so the existing compaction-clear guards cover it — checked against test_session_ledger_clear's own parsers rather than assumed. LOCAL AND SERVERLESS, like the duplicate-name arm beside it. It needs the working tree and nothing else; the server has no checkout, so this is the only place the question can be asked. It is a NUDGE: scribe_prior_art.sh still returns no permissionDecision, which is the operator's recorded decision that a recall aid may not stand in the way of a write. A test asserts that here as well as in test_write_path_trigger.py, because this is the arm most likely to tempt someone into making it a gate — it reports something that may already be broken. The `sym` half delegates to `scribe_defs` rather than repeating its patterns: those cover nine languages and have been corrected several times, and a second copy would inherit today's version and quietly stop agreeing with it (#3497). Verified by lifting the test file's own helpers and driving all 19 cases against the real shell over a fixture git repo. Two of my own errors were caught that way and are fixed: the fixtures were arriving as single lines because Python `repr` inside bash single quotes leaves `\n` as two characters (the extractor is line-oriented, so the tests would have gone green against input no editor can produce), and the no-readers case put its subject in a file that was not the excluded one, so it had a reader and tested the opposite of its name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
91bc0fb01e |
feat(plugin): a high-confidence rule is put in front of a command, not beside its result (#4214)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
Milestone 419 step 3, the pre-act checkpoint. Every rule surface in this plugin returns `additionalContext`, which Claude Code delivers alongside the tool RESULT — so the rule is read after the call is written and lands as commentary on a decision already made. That is the milestone's central finding, measured over a session with seven misses, three caught by the operator and none by this system. The action arm can now return a `deny` instead. The act does not run, the rule's text can be read before the call exists, and the remedy is one `get_rule` call after which the act may be re-submitted unchanged. Nothing reaches the operator: a deny is a message to the model. "CONSEQUENTIAL" IS DERIVED, NOT ENUMERATED. The obvious implementation lists act kinds — a write to product code, a schema change, a bulk classification, a merge. Every one of those is consequential because THIS operator wrote rules about it, and shipping that list is this instance's corpus hard-coded into the product (rule 115). So the corpus decides: an act is consequential when the install's own rules speak to it above the checkpoint bar. A fresh install with no rules never stops anything. FOUR CONDITIONS, EACH PREVENTING A DIFFERENT WRONG. Above the bar; a rule and never a preference (which claims no such force); the band's top hit only (the ranker's confidence claim attaches to its first element); and only a rule the session has NOT opened — `held` is observable from the get_rule PostToolUse hook (#4100), not self-report. WHY "NOT OPENED" RATHER THAN "NO OUTCOME RECORDED". An outcome can be satisfied with one cheap call asserting compliance without producing any, and a checkpoint dismissible that way manufactures exactly the compliance data step 2 was built to measure. Reading a rule cannot be faked in that direction: after `get_rule` the statement is in context, which is the whole of what was wanted. THE BAR IS MEASURED. `retrieval_telemetry(days=30)`: write_path_rule p90 0.7628 max 0.8817; pre_tool_rule p90 0.7373 max 0.8293. 0.80 is above p90 on both and below max on both, so it selects from the top decile of an already selective arm and is still reachable. It ships as a setting with a Settings card, because a cosine distance in one model's geometry over one corpus cannot transfer. TWO GUARDS ON THE WORST CASE: at most one hold per rule and five per session, so a mis-set floor degrades to a noisy session rather than one that cannot proceed. The ledger lives in the swept directory and is named `.ids`, so the existing compaction-clear guards cover it. WRITES ARE NOT HELD, AND THAT IS THE OPERATOR'S DECISION RATHER THAN MINE. `scribe_prior_art.sh` carries a tested property that it never returns a permissionDecision — a recall aid may not stand in the way of a write. Three of the milestone's seven misses were file edits and none are reachable from the command side, so there is a live argument for extending this; that argument is exactly why the boundary is now asserted by a test rather than left to memory. The write-path arm computes and returns the same block so the decision can be revisited with evidence; the hook ignores it, and a change of mind is a hook edit rather than a feature. Verified by lifting `checkpoint_for` and `_rule_band` out of source with `ast` and exercising the shipped functions over 17 populations, by running the ledger and deny envelope in bash (10 cases, including that a refused hold is not written and that a garbled rule id fails closed), and by scripts/check_plugin.py — which caught the unminted plugin version, 0300 -> 0426. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
1f7ff7b215 |
fix(plugin): the prompt boundary retrieves against prompts, not plumbing (#4200)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 17s
Claude Code submits more than typed words through UserPromptSubmit. A task notification, a slash-command echo and the caveat banner a local command prints all arrive as user turns, reaching `.prompt` indistinguishable from something the operator wrote. scribe_autoinject.sh believed all of them. The cost that matters is not the wasted embedding — it is the log row. Every such call counts in the denominator of every prompt-boundary surface, so delivery rate reads low for a reason unrelated to retrieval; and each refusal lands in `near_misses`, where a later tuning decision reads it as demand. Measured while taking milestone 399's acceptance (#3898): 15 of the top 20 `preference_slot` near-misses were `<task-notification>` blocks, all matching ONE record — #140 "Let each action land before starting the next" — all within thousandths of the 0.70 floor. A notification that an action finished really does resemble a preference about letting actions land. Lowering the floor to serve that apparent demand would have injected that record into every notification: the instrument arguing for the wrong fix, which is #379 again. scribe_skip_prompt is a PREFIX test, not a substring one, and that is the whole safety argument. A real prompt may contain one of these tags — an operator pasting a transcript, or a `<system-reminder>` after typed words — and must still be retrieved against. Nothing an operator types begins with a client envelope. The compaction-resume injection is deliberately NOT filtered: it is machine-written, but it summarises real work, and a resumed session is where recalling a rule earns its keep. Client-side only, so no server contract moves and lagging plugin caches keep working. The tags are Claude Code protocol constructs, identical on every install — instance-agnostic under rule 115. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
a49e7ed2af |
fix(plugin): the hooks need no jq and no tac (#4107)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
Every hook opened `command -v jq >/dev/null 2>&1 || exit 0`, so on a machine without jq the operator got no session context, no rules, no prior art and no process sync — and not one word saying why, because `exit 0` is indistinguishable from "ran fine, nothing to say". jq is absent by default on macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers. That is not a prerequisite to document; it is the plugin handing its own packaging problem to whoever installs it. `tac` was worse: GNU-only, so the prior-art hook's enclosing-definition arm did nothing at all on every Mac, silently, from the day it shipped. It is not replaced but removed — scribe_defs judges each line independently, so extracting forward and taking `tail -1` is the same answer as reversing and taking the head, and it drops the early-exit `head` that #4042 was filed for. No server contract changed, so a lagging plugin cache keeps working. scribe_json.awk JSON -> IDX<TAB>PATH<TAB>VALUE. Two modes: `whole` for an event or a response body, `lines` for a transcript, where an unparseable record is dropped and the rest still read — the `map(try fromjson catch empty)` the jq program opened with. Arrays also report their LENGTH at `[#]`, which is what keeps "zero notes" distinct from "no answer" (#2932). scribe_turn.awk the turn-bounding program, replacing the thirty lines of jq in the Stop hook. scribe_defs.sh scribe_json_flat / _pick / _list / _len / _list_minus read, scribe_json_out writes the envelope (five copies of one shape, gone), scribe_urlenc replaces `jq -sRr '@uri'`. Percent-encoding goes through `od -tu1` rather than an awk character loop on purpose: awk's idea of a character follows the locale, so gawk reads an accented letter as one and mawk as two, and an encoder built on substr() would emit a different URL depending on which awk is installed. Encoding is defined on bytes. Verified byte-identical to `jq -sRr '@uri'`. Measured, not assumed. The per-event path costs 8ms against jq's 3ms. The transcript path was 70x slower until two fixes: the Stop hook now finds where the turn starts with a fixed-string grep before parsing (a needle carrying unescaped quotes cannot occur inside a JSON string, so it matches only at a record's top level — checked against a full JSON parse of a 27MB transcript: 152 prompt records, 152 matches, no misses, no extras), and the parser reads each token out of a 1024-byte window instead of copying the rest of the buffer per token, which was quadratic in line length on the 400KB tool results a transcript carries. Differential-tested against the jq program it replaces over 724 windows cut from three real transcripts — 724 identical, 0 mismatched, 45 of them exercising a real task close and a real reply. That sweep is what caught `scribe_turn.awk` never setting FS, which truncated every multi-word reply at its first space and was invisible to a test whose replies were all empty. check_plugin.py's `jq -R` lint becomes a guard against either binary coming back, and three smoke checks lose their `shutil.which("jq")` skip. jq is not in `ci-python` either, so those three announced a skip on every CI run and had never once run there: removing the dependency from the product also closed a permanent hole in its verification. They pass now across all ten hooks. tests/test_hook_json_reader.py is a differential against Python's `json` over nested objects, arrays, unicode, escapes, control characters, empty cases and a value longer than the token window, plus the envelope, the encoder and the turn analyzer. 139 cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
f1e63d207f |
fix(plugin): the sweep missed the arm that fires most — two ledger directories (#4101)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m46s
CI & Build / Build & push image (push) Skipped
CI caught two things in
|
||
|
|
c61f7301bc |
fix(plugin): a compaction clears every session ledger, not the two on the list (#4101)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
`scribe_session_context.sh` cleared `.rules.ids` and `.opened.ids` by name and left `.ids`, `.sync.ids` and `.derive.ids` standing, under a comment asserting that was a decision. Reading the note arms says it was not: their exclusions go straight into `semantic_search_notes`, so a surfaced note leaves the result set rather than being rendered as a reference the way #3750 gave a repeated rule, and unlike the rules ledger they never age. Hard, permanent, never cleared — a note surfaced in a session's first minute is unreachable for the rest of it, which is milestone 386's own defect alive on the arms that fire most often. The list was the bug, so the fix is not a longer list. `scribe_clear_session_ ledgers` matches the naming convention instead — a per-session ledger is `<sid>[.<kind>].ids` — which covers all five and covers the sixth on the day it is written. `<sid>.unreached` is deliberately outside it: that records an outage, not held context, and #2932 needs it to survive. tests/test_session_ledger_clear.py runs the hook rather than grepping it for `rm -f`, since grepping for the names is the pattern being removed. It pins both directions — `compact`/`clear` take all five, `startup`/`resume` take none — plus the convention the glob rests on, checked against the hooks themselves so a ledger named outside it fails loudly instead of silently never clearing. Also drops a stale comment pointing at a rules-etag marker that milestone 394 retired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
ad26b3f458 |
feat(retrieval): the ledger records what was OPENED, not merely what was shown (#4100)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped
Milestone 386 made a repeat REFERENCED rather than withheld, and the line it chose says "You saw it earlier this session". Nothing ever checked that. The arms emit a TEASER — title, trigger, get_rule(N) — so a session can be shown a rule twenty times and never read a word of it, and a compaction summarises the teaser away leaving nothing behind. The server was asserting something about the reader's context it had no way to know. Three states now, where there were two: never surfaced "it is not in this session's loaded set" named, unopened "Mentioned earlier this session but not opened — read it…" opened "You opened it earlier this session; pull it… again" The middle one is the honest one and the one that was missing. It keeps the full invitation, because a session that skipped a teaser is in nearly the position of one never shown it. HOW "OPENED" BECOMES OBSERVABLE. A new PostToolUse hook watches the get_rule call itself and appends to `<sid>.opened.ids`. PostToolUse does fire for MCP tools — the event's own output schema carries `updatedMCPToolOutput`, which would be meaningless otherwise — and the matcher is `mcp__.*__get_rule` so the server segment, which varies by install, is not pinned. This is NOT the self-report 386 rejected. That objection was to ASKING a model whether it holds a rule, which is unverifiable. A tool call is an event the harness reports whether anyone asks. Recording what a session DID and believing what it SAYS about itself are different kinds of evidence. Both ledgers clear together on compact/clear. Keeping `.opened.ids` across a compaction would have the arms telling a freshly-summarised session "you opened it earlier" about a rule now nowhere in its context — a more confident version of the bug being removed. Same reader (scribe_rules_live) for both, so ageing, last-entry-wins and the bare-id format are defined once. Also closes two smoke-coverage holes the checker was reporting as SKIP: the new recorder, and scribe_precompact_preserve.sh from #3680. The latter needed STATIC_FLOOR to become a set — PreCompact's contract is inverted, its stdout BECOMES the summarizer's instructions, so silence is its failure mode and a generic read of it looks like a leak. Step 2 of milestone 416, and a hard prerequisite for step 4: while suppression keys on shown, widening k marks records "seen" faster than they are read, and the ledger would degrade in proportion to the improvement. Plugin minted 2026.09.16.1232 -> 2026.09.16.2102. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
aa94c73d9e |
feat(plugin): a directory says which project it belongs to, git repo or not (#4085)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m1s
CI & Build / Build & push image (push) Skipped
All six hooks scoped their requests one way: `git remote get-url origin`,
resolved server-side through the repo bindings. That key does not exist
outside a git repo, so a session in a plain directory was unscoped in every
hook at once — no project context, no prior-art scoping, no project rules —
and silently, because a missing remote is indistinguishable from a remote
nobody bound.
A `.scribe` file is the second key, read by the shared scribe_scope_query so a
directory scopes the same way everywhere:
{"instance": "https://scribe.example.com", "project_id": 2, "project": "…"}
`instance` is why the file is not just a number: an id is a different project
on every Scribe, so a marker that travels — a copied directory, a shared
machine, a repo someone else clones — would otherwise scope the session to the
wrong project without a word. Compared host-only, and a mismatch drops the id:
no project beats the wrong project. A bare integer is accepted too, since it
is what a person writes by hand. The marker beats a git remote — someone put
the file there on purpose — which is also how a directory overrides its
binding.
Two things it found on the way:
* An explicit project_id that did not resolve rendered NO message at all —
the branch hung off `if project_id` as an `elif`, so a caller holding a
pointer it believed in got a context that silently omitted the project it
had asked for. Now reported.
* The refusal reason was a global set inside a function every caller reads
through `$( )`. The assignment died with the subshell, leaving the caller
to read an unset variable under `set -u` — which aborts the hook and costs
the whole session's SessionStart context, to fetch a warning about a file.
It comes back through stdout with the id instead, and a test pins it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
0fab08276c |
fix(plugin): an early-exiting head no longer voids its own output under pipefail (#4042)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 14s
In a repo where 3,000 files define `slug`, scribe_local_dups printed nothing; with 3 files it printed the duplicate line. Every hook runs under `set -uo pipefail`, and `hits=$(git grep -l … | head -4) || hits=""` lost head's four lines whenever git grep was still writing when head exited. That is a SIGPIPE, the substitution fails, and the outer fallback wipes the result. So the by-name duplicate arm went silent for exactly the most-duplicated names. Found while fixing the same trap in the new Stop hook (#4041). - The fallback moves inside the substitution, `$(… | head -N || true)`, at all five sites: scribe_defs.sh (local dups), scribe_prior_art.sh (names, old_first, shapes) and scribe_after_write.sh (names). A real upstream failure still yields empty output. - check_plugin gains a known-bad pattern for the shape, so no hook can bring it back. - Tests: the real function against a 3,000-file repo (past the pipe buffer), and the pattern shown to flag the old shape and pass the fix. Plugin version minted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c1aa1d8e92 |
feat(plugin): rule exclusions age, so salience decays without a context event (#3751)
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 27s
#3749 clears the ledger when an EVENT destroys context — a compaction, a /clear. This is the case with no event at all: a long session where a rule was named two hundred turns ago and has simply fallen out of attention. It is #3702's argument at the tier level — present in context and salient at the moment are different properties — applied to time instead of to tier. FORMAT: `id<TAB>epoch`, per entry. Not a whole-file mtime: that is one line of shell and wrong in exactly the session that needs it, since a single recent write keeps every stale id alive, and the ids that go stale first come from the rules that fire most. Not a turn counter, though it would be the truer model — an idle session does not forget. A hook has no turn number without keeping its own, which is a second piece of session state to write, read, clear on compaction and get wrong. Wall time costs a `date` call. The failure it accepts is a session left idle over lunch treating its rules as forgotten, worth one extra full line per rule and nothing else. TTL 2700s (45 minutes), reasoned rather than picked (rule 32). About one working stretch on a single task: long enough that a rule does not re-announce itself while you are still doing the thing it governs, short enough that a multi-hour session gets a refresh rather than one 9am mention. It leans short because since #3750 being wrong on the short side is the cheaper error — an expired entry costs one full line instead of one short one, and the exclusion re-arms the moment it is spent. There is no data on this yet; #3807's near-miss listing is what should revise it. BOTH READERS THROUGH ONE HELPER, in scribe_defs.sh. The two hooks share one ledger so a rule named by one arm is not re-offered by the other; a format only one of them understood would break that on the first read. The flat `tr '\n' ','` read would now send `156<TAB>1789002860` as an exclude id — verified, which is why this is not a per-hook edit. THE LAST ENTRY FOR AN ID WINS. The file is append-only, so a rule that ages out, is surfaced fresh and is appended again has two lines. Reading the first leaves it permanently expired, and it then re-announces itself on every call for the rest of the session — the mechanism meant to quieten things becoming the loudest thing in the hint. A BARE ID IS LIVE. That is the pre-#3751 format, and every session in flight when this ships has a ledger full of them. Reading unknown as EXPIRED would make all of those sessions re-announce every rule they had already been told, at once — the exact noise this prevents, delivered by the feature on the day it ships. Unknown means "not measured", never "old", the same discipline the nullable retrieval_logs columns use. GUARDS (tests/test_rule_ledger_ageing.py, real shell, no credentials) - old ages out AND recent survives, in ONE assertion (rule 167): either half alone passes against a broken helper — "old is gone" passes against one returning nothing, "recent survives" passes against the flat read this replaces, i.e. against the defect itself. - the ping-pong case, which is the one that costs the most to get wrong. - a bare id is live, including beside a stale stamped one. - missing/empty ledger excludes nothing. - an id repeated in the ledger appears once, with no empty list element. - structural: neither hook reads the rule ledger flat again — pinned on the flat-read SHAPE, so a rename of the helper is not a failure and a hook that ages correctly some other way is not either. The TTL's VALUE is deliberately not asserted. The tests read the constant out of the shell and assert the property around it, so a later tuning change stays a tuning change instead of a red build. Dropped a boundary test (`ttl` vs `ttl + 1`) before committing: racy by construction, since a ledger written at T is read at T+n and the two cases swap. A one-second distinction on a 45-minute window is also not observable behaviour, so it pinned a flake rather than a property. Plugin version minted to 2026.09.10.0221 — this is entirely hook-side, so without the bump the cache would never pick it up and the merge would ship nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
5925335ca0 |
feat(plugin): after-write hook — PostToolUse on Bash diffs the working tree and runs the prior-art + ledger arms on what was just written; shared scribe_defs.sh; plugin 0.1.39 (#2901, milestone 299 step 3)
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 32s
CI & Build / Python tests (push) Failing after 40s
CI & Build / Build & push image (push) Skipped
Edits made through sed/heredocs/scripts never reached the PreToolUse Write|Edit hook, so a whole class of writes got no prior-art hint, no ledger feed and no duplicate-family warning. scribe_after_write.sh asks git what changed since it last looked (per-session path+blob snapshot; first call = files touched in the last minute), extracts the definitions in the added lines and calls /api/plugin/prior-art with the same three dedup channels the pre hook keeps. Never blocks; silent on any failure. The extractor, the prose/data skip list and the local by-name arm move to scribe_defs.sh, sourced by both hooks. Version bump covers the step-2 hook change too (run 4239 failed only on the bump check). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |